Release v0.2.34
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
// Package opensubtitles provides the slice of opensubtitles.com Memby uses to fetch a
|
||||
// subtitle a title does not have.
|
||||
//
|
||||
// It is the second subtitle provider and it is not shaped like the first. Bazarr's whole
|
||||
// appeal is that it writes the file beside the media file, so the gateway asks and then
|
||||
// forgets — Emby finds the result and the track arrives down the ordinary PlaybackInfo
|
||||
// path. OpenSubtitles hands back bytes, and the gateway has no access to the media
|
||||
// directory, so a file fetched here is stored by the gateway and served back as a sidecar.
|
||||
// That difference is worth stating because everything else about the two providers is the
|
||||
// same shape, and the storage is the only reason `downloaded_subtitles` exists.
|
||||
//
|
||||
// Three things about the API are not obvious and each would otherwise be found as a bug:
|
||||
//
|
||||
// - Identity is an id, not a title. Searching takes an imdb or tmdb id, which is a far
|
||||
// better match than the title-and-year guessing the Bazarr path is stuck with — and
|
||||
// Memby already has those ids, because the library import asks Emby for ProviderIds
|
||||
// so external ratings can be looked up.
|
||||
// - The search result is not the file. A row carries a `file_id`, and turning that into
|
||||
// bytes is a second call to /download which returns a short-lived link. It is the
|
||||
// /download call that spends the account's daily allowance, never the search.
|
||||
// - /download wants a logged-in token in practice. An API key alone is accepted but the
|
||||
// anonymous allowance is a handful of files a day, which in front of a television
|
||||
// reads as the feature being broken. Credentials are optional here and the token is
|
||||
// cached, because logging in per download would spend a different quota instead.
|
||||
package opensubtitles
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the REST API's home. It is a field on the client so the tests can
|
||||
// point at a fake, never so an operator can redirect credentials somewhere else.
|
||||
const DefaultBaseURL = "https://api.opensubtitles.com/api/v1"
|
||||
|
||||
// maxSubtitleBytes bounds what will be read from a download link. A subtitle is tens of
|
||||
// kilobytes; anything approaching this is not a subtitle, and the gateway stores what it
|
||||
// fetches, so an unbounded read here would be an unbounded row in Postgres.
|
||||
const maxSubtitleBytes = 4 << 20
|
||||
|
||||
// tokenLifetime is how long a login token is reused for. OpenSubtitles issues them for
|
||||
// about a day; renewing well inside that costs one request and avoids the case nobody
|
||||
// tests, which is the token expiring in the middle of somebody's film.
|
||||
const tokenLifetime = 12 * time.Hour
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
userAgent string
|
||||
username string
|
||||
password string
|
||||
http *http.Client
|
||||
|
||||
// The login token is shared by every viewer in the house, because the account is the
|
||||
// household's rather than anybody's. The mutex is held across the login request so a
|
||||
// launcher full of televisions cannot log in six times at once.
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
}
|
||||
|
||||
// Subtitle is one candidate from a search.
|
||||
//
|
||||
// FileID is what /download takes and it is the only field that has to survive the round
|
||||
// trip through a television. Rating and Downloads are the two numbers a viewer could
|
||||
// sensibly choose by, and they are folded into a single Score by the caller so a row from
|
||||
// here reads the same as a row from Bazarr.
|
||||
type Subtitle struct {
|
||||
FileID int
|
||||
SubtitleID string
|
||||
Language string
|
||||
Release string
|
||||
FileName string
|
||||
Format string
|
||||
Forced bool
|
||||
HearingImpaired bool
|
||||
MachineOnly bool
|
||||
FromTrusted bool
|
||||
Downloads int
|
||||
Rating float64
|
||||
}
|
||||
|
||||
// Query is what identifies a title. Exactly one of IMDBID, TMDBID and Query is normally
|
||||
// set; for an episode, ParentIMDBID with Season and Episode is the reliable shape, since
|
||||
// far more shows carry an id on the series than on every episode.
|
||||
type Query struct {
|
||||
IMDBID string
|
||||
TMDBID string
|
||||
ParentIMDBID string
|
||||
ParentTMDBID string
|
||||
Query string
|
||||
Season int
|
||||
Episode int
|
||||
Languages []string
|
||||
// Type narrows the search to "movie" or "episode". It is worth sending: a query by
|
||||
// title alone otherwise returns a film and the show named after it together.
|
||||
Type string
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("opensubtitles: status %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// QuotaError is the one failure worth telling a viewer about in its own words. Everything
|
||||
// else is "the provider did not answer"; this one is "you have used today's downloads",
|
||||
// which is not something pressing the button again will fix.
|
||||
type QuotaError struct {
|
||||
ResetTime string
|
||||
}
|
||||
|
||||
func (e *QuotaError) Error() string {
|
||||
if e.ResetTime != "" {
|
||||
return "opensubtitles: download quota exhausted, resets in " + e.ResetTime
|
||||
}
|
||||
return "opensubtitles: download quota exhausted"
|
||||
}
|
||||
|
||||
func New(apiKey, userAgent, username, password string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
baseURL: DefaultBaseURL,
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
userAgent: strings.TrimSpace(userAgent),
|
||||
username: strings.TrimSpace(username),
|
||||
password: password,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseURL points the client somewhere else. It exists for the tests.
|
||||
func (c *Client) SetBaseURL(base string) {
|
||||
c.baseURL = strings.TrimRight(base, "/")
|
||||
}
|
||||
|
||||
// HasAccount reports whether a download will be made against the household's own
|
||||
// allowance rather than the anonymous one. The console shows it, because the difference
|
||||
// is the difference between a working feature and one that stops after a few files.
|
||||
func (c *Client) HasAccount() bool {
|
||||
return c.username != "" && c.password != ""
|
||||
}
|
||||
|
||||
// Search asks the providers what exists for one title.
|
||||
//
|
||||
// A search costs nothing against the download allowance, which is why the television is
|
||||
// allowed to run one per press. An empty result is an ordinary answer.
|
||||
func (c *Client) Search(ctx context.Context, query Query) ([]Subtitle, error) {
|
||||
params := searchParams(query)
|
||||
if len(params) == 0 {
|
||||
return nil, fmt.Errorf("opensubtitles: nothing to search by")
|
||||
}
|
||||
var payload struct {
|
||||
Data []struct {
|
||||
Attributes struct {
|
||||
SubtitleID string `json:"subtitle_id"`
|
||||
Language string `json:"language"`
|
||||
DownloadCount int `json:"download_count"`
|
||||
HearingImpaired bool `json:"hearing_impaired"`
|
||||
ForeignPartsOnly bool `json:"foreign_parts_only"`
|
||||
FromTrusted bool `json:"from_trusted"`
|
||||
AITranslated bool `json:"ai_translated"`
|
||||
MachineTranslated bool `json:"machine_translated"`
|
||||
Ratings float64 `json:"ratings"`
|
||||
Release string `json:"release"`
|
||||
Files []struct {
|
||||
FileID int `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
} `json:"files"`
|
||||
} `json:"attributes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := c.get(ctx, "/subtitles", params, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Subtitle, 0, len(payload.Data))
|
||||
for _, row := range payload.Data {
|
||||
attributes := row.Attributes
|
||||
// A row with no file is not a candidate: there is nothing to hand to /download,
|
||||
// and a chooseable row that cannot be fetched is worse than one fewer row.
|
||||
if len(attributes.Files) == 0 || attributes.Files[0].FileID == 0 {
|
||||
continue
|
||||
}
|
||||
file := attributes.Files[0]
|
||||
out = append(out, Subtitle{
|
||||
FileID: file.FileID,
|
||||
SubtitleID: attributes.SubtitleID,
|
||||
Language: strings.TrimSpace(attributes.Language),
|
||||
Release: strings.TrimSpace(attributes.Release),
|
||||
FileName: strings.TrimSpace(file.FileName),
|
||||
Format: formatFromName(file.FileName),
|
||||
Forced: attributes.ForeignPartsOnly,
|
||||
HearingImpaired: attributes.HearingImpaired,
|
||||
MachineOnly: attributes.AITranslated || attributes.MachineTranslated,
|
||||
FromTrusted: attributes.FromTrusted,
|
||||
Downloads: attributes.DownloadCount,
|
||||
Rating: attributes.Ratings,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CanSearch reports whether a query identifies a title well enough to be worth sending.
|
||||
// It is the same rule searchParams applies, exported so a caller can decide not to offer
|
||||
// this provider for a title rather than sending a request that cannot answer.
|
||||
func CanSearch(query Query) bool {
|
||||
return len(searchParams(query)) > 0
|
||||
}
|
||||
|
||||
// searchParams is pure so the shape of a query can be pinned by a test. The parent ids are
|
||||
// only sent for an episode: on a film they mean nothing, and sending both an id and a
|
||||
// season number is how a search comes back empty for a title that plainly exists.
|
||||
func searchParams(query Query) url.Values {
|
||||
params := url.Values{}
|
||||
episode := query.Season > 0 || query.Episode > 0
|
||||
switch {
|
||||
case strings.TrimSpace(query.IMDBID) != "":
|
||||
params.Set("imdb_id", trimIMDB(query.IMDBID))
|
||||
case strings.TrimSpace(query.TMDBID) != "":
|
||||
params.Set("tmdb_id", strings.TrimSpace(query.TMDBID))
|
||||
case episode && strings.TrimSpace(query.ParentIMDBID) != "":
|
||||
params.Set("parent_imdb_id", trimIMDB(query.ParentIMDBID))
|
||||
case episode && strings.TrimSpace(query.ParentTMDBID) != "":
|
||||
params.Set("parent_tmdb_id", strings.TrimSpace(query.ParentTMDBID))
|
||||
case strings.TrimSpace(query.Query) != "":
|
||||
params.Set("query", strings.TrimSpace(query.Query))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if episode {
|
||||
if query.Season >= 0 && (query.Season > 0 || query.Episode > 0) {
|
||||
params.Set("season_number", strconv.Itoa(query.Season))
|
||||
}
|
||||
if query.Episode > 0 {
|
||||
params.Set("episode_number", strconv.Itoa(query.Episode))
|
||||
}
|
||||
}
|
||||
if languages := joinLanguages(query.Languages); languages != "" {
|
||||
params.Set("languages", languages)
|
||||
}
|
||||
if kind := strings.TrimSpace(query.Type); kind != "" {
|
||||
params.Set("type", kind)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// joinLanguages normalises the language list the API wants: lower case, comma separated,
|
||||
// sorted, and deduplicated. It is fussy about this — an unsorted list is rejected — which
|
||||
// is exactly the kind of thing that fails once in production and never in a review.
|
||||
func joinLanguages(languages []string) string {
|
||||
seen := map[string]bool{}
|
||||
values := make([]string, 0, len(languages))
|
||||
for _, language := range languages {
|
||||
language = strings.ToLower(strings.TrimSpace(language))
|
||||
if language == "" || seen[language] {
|
||||
continue
|
||||
}
|
||||
seen[language] = true
|
||||
values = append(values, language)
|
||||
}
|
||||
for i := 1; i < len(values); i++ {
|
||||
for j := i; j > 0 && values[j] < values[j-1]; j-- {
|
||||
values[j], values[j-1] = values[j-1], values[j]
|
||||
}
|
||||
}
|
||||
return strings.Join(values, ",")
|
||||
}
|
||||
|
||||
func trimIMDB(value string) string {
|
||||
return strings.TrimPrefix(strings.TrimSpace(value), "tt")
|
||||
}
|
||||
|
||||
func formatFromName(name string) string {
|
||||
if index := strings.LastIndex(name, "."); index >= 0 && index < len(name)-1 {
|
||||
extension := strings.ToLower(name[index+1:])
|
||||
if extension == "srt" || extension == "vtt" || extension == "ass" || extension == "ssa" {
|
||||
return extension
|
||||
}
|
||||
}
|
||||
return "srt"
|
||||
}
|
||||
|
||||
// Download turns a file id into bytes.
|
||||
//
|
||||
// It is two requests — a link, then the file — and it is the call that spends the
|
||||
// account's allowance, so it is never made speculatively and never on the playback path.
|
||||
func (c *Client) Download(ctx context.Context, fileID int) (name string, content []byte, err error) {
|
||||
body, err := json.Marshal(map[string]any{"file_id": fileID})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var payload struct {
|
||||
Link string `json:"link"`
|
||||
FileName string `json:"file_name"`
|
||||
Remaining int `json:"remaining"`
|
||||
ResetTime string `json:"reset_time"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.post(ctx, "/download", body, &payload); err != nil {
|
||||
var apiErr *APIError
|
||||
// 406 is what the API answers with when the allowance is gone. It is the one
|
||||
// failure a viewer can act on — by waiting — so it keeps its own type.
|
||||
if ok := asAPIError(err, &apiErr); ok && apiErr.StatusCode == http.StatusNotAcceptable {
|
||||
return "", nil, &QuotaError{}
|
||||
}
|
||||
return "", nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.Link) == "" {
|
||||
return "", nil, &QuotaError{ResetTime: payload.ResetTime}
|
||||
}
|
||||
|
||||
// The link is a plain file on a CDN and carries neither the API key nor the token.
|
||||
// Sending them would leak the household's credentials to a host that is not the API.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, payload.Link, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("opensubtitles: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", nil, &APIError{StatusCode: resp.StatusCode, Body: "download link"}
|
||||
}
|
||||
content, err = io.ReadAll(io.LimitReader(resp.Body, maxSubtitleBytes))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("opensubtitles: read subtitle: %w", err)
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return "", nil, fmt.Errorf("opensubtitles: empty subtitle file")
|
||||
}
|
||||
return payload.FileName, content, nil
|
||||
}
|
||||
|
||||
// Ping is the reachability and credential check the console uses. It reads the account
|
||||
// endpoint when there is an account and the plain info endpoint otherwise, so "the key
|
||||
// works" and "the login works" are two different answers.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
if c.HasAccount() {
|
||||
if _, err := c.authToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var payload struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
return c.get(ctx, "/infos/formats", nil, &payload)
|
||||
}
|
||||
|
||||
// authToken returns a login token, logging in if the cached one is missing or old. An
|
||||
// account that will not log in is not fatal: the download is attempted anonymously, which
|
||||
// works until the small anonymous allowance runs out and is a better answer than refusing
|
||||
// to try.
|
||||
func (c *Client) authToken(ctx context.Context) (string, error) {
|
||||
if !c.HasAccount() {
|
||||
return "", nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.token != "" && time.Now().Before(c.tokenExpiry) {
|
||||
return c.token, nil
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"username": c.username, "password": c.password})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
// Deliberately not through post(): that asks for a token, and this is how one is got.
|
||||
if err := c.do(ctx, http.MethodPost, "/login", nil, body, "", &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(payload.Token) == "" {
|
||||
return "", fmt.Errorf("opensubtitles: login returned no token")
|
||||
}
|
||||
c.token = payload.Token
|
||||
c.tokenExpiry = time.Now().Add(tokenLifetime)
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, params url.Values, out any) error {
|
||||
token, _ := c.authToken(ctx)
|
||||
return c.do(ctx, http.MethodGet, path, params, nil, token, out)
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, path string, body []byte, out any) error {
|
||||
token, _ := c.authToken(ctx)
|
||||
return c.do(ctx, http.MethodPost, path, nil, body, token, out)
|
||||
}
|
||||
|
||||
func (c *Client) do(
|
||||
ctx context.Context, method, path string, params url.Values,
|
||||
body []byte, token string, out any,
|
||||
) error {
|
||||
endpoint := c.baseURL + path
|
||||
if len(params) > 0 {
|
||||
endpoint += "?" + params.Encode()
|
||||
}
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Api-Key", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
// The API rejects a request with no User-Agent naming the consumer, and it is the one
|
||||
// header here that is about being a good citizen rather than about authentication.
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opensubtitles: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(raw))}
|
||||
}
|
||||
if out == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("opensubtitles: decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func asAPIError(err error, target **APIError) bool {
|
||||
if converted, ok := err.(*APIError); ok {
|
||||
*target = converted
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package opensubtitles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSearchParamsPrefersItsOwnIDOverTheParents(t *testing.T) {
|
||||
params := searchParams(Query{
|
||||
IMDBID: "tt0903747", ParentIMDBID: "tt0999999", Season: 1, Episode: 2,
|
||||
Languages: []string{"it", "en", "en"}, Type: "episode",
|
||||
})
|
||||
if got := params.Get("imdb_id"); got != "0903747" {
|
||||
t.Fatalf("imdb_id = %q, want the tt stripped", got)
|
||||
}
|
||||
if params.Has("parent_imdb_id") {
|
||||
t.Fatal("parent_imdb_id was sent alongside the episode's own id")
|
||||
}
|
||||
if got := params.Get("season_number"); got != "1" {
|
||||
t.Fatalf("season_number = %q", got)
|
||||
}
|
||||
// Sorted and deduplicated: the API rejects the list in any other shape.
|
||||
if got := params.Get("languages"); got != "en,it" {
|
||||
t.Fatalf("languages = %q, want %q", got, "en,it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchParamsFallsBackToTheSeriesForAnEpisode(t *testing.T) {
|
||||
params := searchParams(Query{ParentIMDBID: "tt0903747", Season: 5, Episode: 14})
|
||||
if got := params.Get("parent_imdb_id"); got != "0903747" {
|
||||
t.Fatalf("parent_imdb_id = %q", got)
|
||||
}
|
||||
if got := params.Get("episode_number"); got != "14" {
|
||||
t.Fatalf("episode_number = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A film must never be sent a season number: an id plus a season is how a search comes
|
||||
// back empty for a title that plainly exists.
|
||||
func TestSearchParamsSendsNoSeasonForAFilm(t *testing.T) {
|
||||
params := searchParams(Query{TMDBID: "550", Type: "movie"})
|
||||
if params.Has("season_number") || params.Has("episode_number") {
|
||||
t.Fatal("a film was searched for with episode numbers")
|
||||
}
|
||||
if got := params.Get("tmdb_id"); got != "550" {
|
||||
t.Fatalf("tmdb_id = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchParamsRefusesAQueryItCannotIdentify(t *testing.T) {
|
||||
if params := searchParams(Query{Languages: []string{"en"}}); len(params) != 0 {
|
||||
t.Fatalf("searchParams answered %v for a query with no identity", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDropsRowsWithNoFileToFetch(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Api-Key") != "key" {
|
||||
t.Errorf("api key header = %q", r.Header.Get("Api-Key"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{
|
||||
map[string]any{"attributes": map[string]any{
|
||||
"language": "en", "ratings": 8.5, "download_count": 120,
|
||||
"foreign_parts_only": true, "release": "BluRay",
|
||||
"files": []any{map[string]any{"file_id": 42, "file_name": "x.srt"}},
|
||||
}},
|
||||
map[string]any{"attributes": map[string]any{"language": "it", "files": []any{}}},
|
||||
}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New("key", "Memby/test", "", "", 5*time.Second)
|
||||
client.SetBaseURL(server.URL)
|
||||
found, err := client.Search(context.Background(), Query{IMDBID: "tt1", Languages: []string{"en"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("got %d candidates, want the one with a file", len(found))
|
||||
}
|
||||
if found[0].FileID != 42 || !found[0].Forced || found[0].Downloads != 120 {
|
||||
t.Fatalf("candidate decoded as %+v", found[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFollowsTheLinkWithoutTheCredentials(t *testing.T) {
|
||||
var files *httptest.Server
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/login":
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"token": "jwt"})
|
||||
case "/download":
|
||||
if r.Header.Get("Authorization") != "Bearer jwt" {
|
||||
t.Errorf("download authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"link": files.URL + "/f.srt", "file_name": "f.srt", "remaining": 19,
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
files = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// The CDN is not the API: neither the key nor the token belongs on this request.
|
||||
if r.Header.Get("Api-Key") != "" || r.Header.Get("Authorization") != "" {
|
||||
t.Error("credentials were sent to the download host")
|
||||
}
|
||||
_, _ = w.Write([]byte("1\n00:00:01,000 --> 00:00:02,000\nhello\n"))
|
||||
}))
|
||||
defer files.Close()
|
||||
|
||||
client := New("key", "Memby/test", "someone", "secret", 5*time.Second)
|
||||
client.SetBaseURL(api.URL)
|
||||
name, content, err := client.Download(context.Background(), 42)
|
||||
if err != nil {
|
||||
t.Fatalf("Download: %v", err)
|
||||
}
|
||||
if name != "f.srt" || len(content) == 0 {
|
||||
t.Fatalf("Download returned %q / %d bytes", name, len(content))
|
||||
}
|
||||
}
|
||||
|
||||
// The allowance running out is the one failure a viewer can act on, so it must not be
|
||||
// flattened into "the provider did not answer".
|
||||
func TestDownloadReportsAnExhaustedQuota(t *testing.T) {
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
_, _ = w.Write([]byte(`{"message":"quota exceeded"}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New("key", "Memby/test", "", "", 5*time.Second)
|
||||
client.SetBaseURL(api.URL)
|
||||
_, _, err := client.Download(context.Background(), 7)
|
||||
if _, ok := err.(*QuotaError); !ok {
|
||||
t.Fatalf("Download error = %v, want a QuotaError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginTokenIsFetchedOnce(t *testing.T) {
|
||||
logins := 0
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/login" {
|
||||
logins++
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"token": "jwt"})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}})
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New("key", "Memby/test", "someone", "secret", 5*time.Second)
|
||||
client.SetBaseURL(api.URL)
|
||||
for range 3 {
|
||||
if _, err := client.Search(context.Background(), Query{IMDBID: "tt1"}); err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("logged in %d times, want once", logins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFromNameFallsBackToSubRip(t *testing.T) {
|
||||
for name, want := range map[string]string{
|
||||
"a.srt": "srt", "b.VTT": "vtt", "c.ass": "ass", "d": "srt", "e.zip": "srt",
|
||||
} {
|
||||
if got := formatFromName(name); got != want {
|
||||
t.Errorf("formatFromName(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user