466 lines
16 KiB
Go
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
|
||
|
|
}
|