Files
memby/server/internal/opensubtitles/client.go
T
2026-08-09 08:25:50 +12:00

466 lines
16 KiB
Go

// 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
}