// 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" "github.com/ponzischeme89/memby/server/internal/timing" ) // 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: timing.Instrument(&http.Client{ Timeout: timeout, Transport: &http.Transport{ MaxIdleConns: 10, MaxIdleConnsPerHost: 5, IdleConnTimeout: 90 * time.Second, }, }, timing.StageOpenSubtitles), } } // 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 }