package api import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "net/url" "sort" "strconv" "strings" "github.com/ponzischeme89/memby/server/internal/bazarr" "github.com/ponzischeme89/memby/server/internal/buildinfo" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/opensubtitles" "github.com/ponzischeme89/memby/server/internal/store" ) // Which backends may be asked for a subtitle, and how a candidate finds its way home. // // There are two providers now and they are not the same shape. Bazarr writes the file // beside the media file, so the gateway asks and forgets; OpenSubtitles hands back bytes, // which the gateway has to keep and serve itself. Everything above this file is written // against one vocabulary — search, download, a candidate carrying its source — and the // difference lives here and in `downloaded_subtitles`. // // The operator's switches are `store.SubtitlePolicy`, not environment variables, because // the two answer different questions and a household changes its mind about them. A // provider is offered only when it is configured *and* switched on *and* the // `subtitle_download` feature is on: an unconfigured deployment must never draw a row that // leads to a request nothing can answer. // subtitleSources is which providers this request may use. Nothing downstream branches on // a client, a viewer or a title — a source is on for the household or it is not. type subtitleSources struct { Bazarr bool OpenSubtitles bool } func (s subtitleSources) any() bool { return s.Bazarr || s.OpenSubtitles } // subtitlePolicy reads the operator's document, falling back to the defaults rather than // to nothing: a store that will not answer must cost the console its switches, not a // household its subtitles. func (s *Server) subtitlePolicy(ctx context.Context) store.SubtitlePolicy { if s.store == nil { return store.DefaultSubtitlePolicy() } policy, err := s.store.SubtitlePolicy(ctx) if err != nil { s.loggerFor(ctx).Warn("subtitle policy unavailable; using defaults", "error", err) return store.DefaultSubtitlePolicy() } return policy } func (s *Server) subtitleSources(ctx context.Context) subtitleSources { if !s.featureEnabled(ctx, featureSubtitleDownload) { return subtitleSources{} } policy := s.subtitlePolicy(ctx) return subtitleSources{ // Bazarr needs an address, which is deployment configuration and stays an // environment variable — it is a service the household runs, not a credential // somebody pastes into a console. Bazarr: s.bazarr != nil && policy.BazarrEnabled, // OpenSubtitles needs only a key, which the console holds, so it can be turned on // without a redeployment. The store already refuses to record it as on with no key. OpenSubtitles: policy.OpenSubtitlesEnabled && policy.OpenSubtitlesAPIKey != "", } } // openSubtitlesClient returns a client for the credentials currently saved, rebuilding it // when they change. // // It is cached rather than constructed per request for one reason that matters: the client // holds a login token, and logging in per download would spend a different allowance than // the one being conserved. The fingerprint is a hash so a credential never reaches a log // line or a comparison in a debugger. func (s *Server) openSubtitlesClient(ctx context.Context) *opensubtitles.Client { policy := s.subtitlePolicy(ctx) if !policy.OpenSubtitlesEnabled || policy.OpenSubtitlesAPIKey == "" { return nil } fingerprint := credentialFingerprint( policy.OpenSubtitlesAPIKey, policy.OpenSubtitlesUsername, policy.OpenSubtitlesPassword, ) s.openSubtitlesMu.Lock() defer s.openSubtitlesMu.Unlock() if s.openSubtitles != nil && s.openSubtitlesKey == fingerprint { return s.openSubtitles } s.openSubtitles = opensubtitles.New( policy.OpenSubtitlesAPIKey, openSubtitlesUserAgent(), policy.OpenSubtitlesUsername, policy.OpenSubtitlesPassword, s.cfg.BazarrTimeout, ) s.openSubtitlesKey = fingerprint return s.openSubtitles } // openSubtitlesUserAgent names Memby to the provider. It carries the gateway's own version // rather than a television's: the request is the gateway's, and the API asks a consumer to // identify the build so it can be told when one misbehaves. func openSubtitlesUserAgent() string { return "Memby/" + buildinfo.Version() } func credentialFingerprint(values ...string) string { sum := sha256.Sum256([]byte(strings.Join(values, "\x00"))) return hex.EncodeToString(sum[:8]) } // subtitleTarget is one Emby item resolved onto everything either provider needs. It is // resolved once per search or download, because both providers want the same three facts // about a title and reading them twice would double the Emby traffic of a feature that // runs while somebody's film is paused. type subtitleTarget struct { Title string // Bazarr's ids. Exactly one of RadarrID and EpisodeID is set when Bazarr can be used. bazarr bazarrTarget hasBazarr bool // OpenSubtitles' identity, which is an external id rather than a title. Empty when // Emby knows no provider id for the item or its series. query opensubtitles.Query hasQuery bool } // providerSubtitles searches every enabled provider at once and merges the answers. // // Concurrently, because a manual search is a live provider query measured in seconds and // running two in turn would double a wait somebody is standing in front of. A provider // that fails is dropped rather than failing the search: one working provider is a better // answer than an error, and the caller says so when both are empty. func (s *Server) providerSubtitles( ctx context.Context, sources subtitleSources, target subtitleTarget, language string, ) ([]subtitleCandidate, []error) { type outcome struct { candidates []subtitleCandidate err error } results := make(chan outcome, 2) requested := 0 if sources.Bazarr && target.hasBazarr { requested++ go func() { found, err := s.searchBazarr(ctx, target.bazarr) results <- outcome{candidates: bazarrCandidates(found), err: err} }() } if sources.OpenSubtitles && target.hasQuery { requested++ client := s.openSubtitlesClient(ctx) go func() { if client == nil { results <- outcome{} return } query := target.query query.Languages = openSubtitlesLanguages(language) found, err := client.Search(ctx, query) results <- outcome{candidates: openSubtitlesCandidates(found), err: err} }() } var candidates []subtitleCandidate var failures []error for range requested { result := <-results if result.err != nil { failures = append(failures, result.err) continue } candidates = append(candidates, result.candidates...) } return candidates, failures } // openSubtitlesLanguages is what a search asks for. // // English rides along with whatever the viewer chose, deliberately. A household that has // never set a language gets the auto value, and a search restricted to nothing comes back // with every language on earth ordered by somebody else's idea of relevance — where a // search that names one or two produces a list a person can read on a television. func openSubtitlesLanguages(language string) []string { normalized := normalizeSubtitleLanguage(language) if normalized == "" || normalized == subtitleLanguageAuto { return []string{"en"} } if normalized == "en" { return []string{"en"} } return []string{normalized, "en"} } func bazarrCandidates(found []bazarr.Subtitle) []subtitleCandidate { out := make([]subtitleCandidate, 0, len(found)) for _, subtitle := range found { if strings.TrimSpace(subtitle.Token) == "" { continue } out = append(out, subtitleCandidate{ Source: store.SubtitleProviderBazarr, Token: subtitle.Token, Language: normalizeSubtitleLanguage(subtitle.Language), LanguageLabel: subtitleLanguageLabel(subtitle.Language), Provider: subtitle.Provider, Score: clampPercent(subtitle.Score), Forced: subtitle.Forced, HearingImpaired: subtitle.HearingImpaired, OriginalFormat: subtitle.OriginalFormat, }) } return out } func openSubtitlesCandidates(found []opensubtitles.Subtitle) []subtitleCandidate { out := make([]subtitleCandidate, 0, len(found)) for _, subtitle := range found { if subtitle.FileID <= 0 { continue } out = append(out, subtitleCandidate{ Source: store.SubtitleProviderOpenSubtitles, // The token is the file id as a string, so one field carries both providers' // opaque handles and nothing above this file has to know the difference. Token: strconv.Itoa(subtitle.FileID), Language: normalizeSubtitleLanguage(subtitle.Language), LanguageLabel: subtitleLanguageLabel(subtitle.Language), Provider: "OpenSubtitles", Score: openSubtitlesScore(subtitle), Forced: subtitle.Forced, HearingImpaired: subtitle.HearingImpaired, MachineOnly: subtitle.MachineOnly, Release: subtitle.Release, format: subtitle.Format, }) } return out } // openSubtitlesScore turns the provider's two numbers into the one Bazarr already gives, // so a merged list can be ordered by a single figure that means roughly the same thing on // every row: how much confidence there is in this file. // // The rating is the meaningful half and it is what a viewer would look at; the download // count only breaks ties, because it measures age as much as quality — a subtitle uploaded // last week for a film from 1994 cannot out-download one that has been there for years. // A file nobody has rated is not a bad file, so it lands mid-scale rather than at the // bottom, the same judgement `heroUnratedScore` makes. func openSubtitlesScore(subtitle opensubtitles.Subtitle) int { score := 55 if subtitle.Rating > 0 { score = int(subtitle.Rating * 10) } if subtitle.FromTrusted { score += 5 } if subtitle.Downloads >= 1000 { score += 3 } // A machine translation is a real answer and sometimes the only one, so it is offered // — below everything a person wrote, and saying so on its own row. if subtitle.MachineOnly { score -= 25 } return clampPercent(score) } // resolveSubtitleTarget reads the item once and works out what each provider needs. // // Failure is per provider rather than for the whole request: a film Bazarr has never heard // of may still have an imdb id, and a title with no provider id at all may still be in // Bazarr's list. Only both failing is a failure. func (s *Server) resolveSubtitleTarget( ctx context.Context, cred emby.Credentials, itemID string, sources subtitleSources, ) (subtitleTarget, error) { raw, err := s.emby.Item(ctx, cred, itemID, "ProductionYear,SeriesName,ParentIndexNumber,IndexNumber,ProviderIds") if err != nil { return subtitleTarget{}, err } var item struct { Name string `json:"Name"` Type string `json:"Type"` SeriesID string `json:"SeriesId"` SeriesName string `json:"SeriesName"` ProductionYear int `json:"ProductionYear"` ParentIndexNumber int `json:"ParentIndexNumber"` IndexNumber int `json:"IndexNumber"` ProviderIDs map[string]string `json:"ProviderIds"` } if err := json.Unmarshal(raw, &item); err != nil { return subtitleTarget{}, fmt.Errorf("unreadable item from emby: %w", err) } target := subtitleTarget{Title: strings.TrimSpace(item.Name)} episode := strings.EqualFold(item.Type, "Episode") if !episode && !strings.EqualFold(item.Type, "Movie") { return subtitleTarget{}, fmt.Errorf("subtitles cannot be fetched for a %q", item.Type) } if sources.Bazarr { if resolved, err := s.resolveBazarrTarget(ctx, cred, itemID); err == nil { target.bazarr, target.hasBazarr = resolved, true target.Title = resolved.Title } else { s.loggerFor(ctx).Debug("no bazarr target", "item", itemID, "error", err) } } if sources.OpenSubtitles { query := opensubtitles.Query{ IMDBID: providerID(item.ProviderIDs, "imdb"), TMDBID: providerID(item.ProviderIDs, "tmdb"), Query: strings.TrimSpace(item.Name), Type: "movie", } if episode { query.Type = "episode" query.Season = item.ParentIndexNumber query.Episode = item.IndexNumber query.Query = strings.TrimSpace(item.SeriesName) // A show carries an id far more often than each of its episodes does, and the // API takes the series' id with a season and episode number, so the parent is // read whenever there is one to read. if item.SeriesID != "" { parents := s.itemProviderIDs(ctx, cred, item.SeriesID) query.ParentIMDBID = providerID(parents, "imdb") query.ParentTMDBID = providerID(parents, "tmdb") } if target.Title == "" || item.SeriesName != "" { target.Title = fmt.Sprintf("%s S%02dE%02d", item.SeriesName, item.ParentIndexNumber, item.IndexNumber) } } // searchParams answers nil for a query with no identity, which is the same rule // stated in one place; asking it here is what keeps this honest. target.query, target.hasQuery = query, opensubtitles.CanSearch(query) } if !target.hasBazarr && !target.hasQuery { return subtitleTarget{}, fmt.Errorf("no subtitle provider can identify %q", item.Name) } return target, nil } // itemProviderIDs reads one item's external ids. A failure is not fatal — it costs the // episode its parent's identity and the search falls back to the title. func (s *Server) itemProviderIDs( ctx context.Context, cred emby.Credentials, itemID string, ) map[string]string { raw, err := s.emby.Item(ctx, cred, itemID, "ProviderIds") if err != nil { return nil } var parsed struct { ProviderIDs map[string]string `json:"ProviderIds"` } if json.Unmarshal(raw, &parsed) != nil { return nil } return parsed.ProviderIDs } // fetchSubtitle carries out one candidate's download and reports what the viewer should be // told. Which provider it goes to is the candidate's own `Source`; an unknown one is // refused rather than guessed at, since guessing means handing one provider's opaque token // to another. func (s *Server) fetchSubtitle( ctx context.Context, cred emby.Credentials, itemID string, target subtitleTarget, candidate subtitleCandidate, ) (fetchedSubtitle, error) { switch candidate.Source { case store.SubtitleProviderOpenSubtitles: return s.fetchFromOpenSubtitles(ctx, itemID, candidate) case store.SubtitleProviderBazarr, "": return s.fetchFromBazarr(ctx, cred, itemID, target, candidate) default: return fetchedSubtitle{}, fmt.Errorf("unknown subtitle source %q", candidate.Source) } } // fetchedSubtitle is what a download produced. StoredID is set only by the provider that // hands back bytes — it names the row the gateway now serves — and is what lets the // response point the player at the new track rather than at whatever Emby happened to // return in the same language. type fetchedSubtitle struct { StoredID string // RefreshEmby is true for a provider that wrote a file Emby has not noticed. Bazarr // needs it; a subtitle the gateway serves itself does not, and refreshing anyway would // spend a couple of seconds of somebody's film waiting for nothing. RefreshEmby bool } func (s *Server) fetchFromBazarr( ctx context.Context, cred emby.Credentials, itemID string, target subtitleTarget, candidate subtitleCandidate, ) (fetchedSubtitle, error) { if s.bazarr == nil || !target.hasBazarr { return fetchedSubtitle{}, fmt.Errorf("bazarr cannot fetch for this title") } subtitle := bazarr.Subtitle{ Language: candidate.Language, Provider: candidate.Provider, Token: candidate.Token, Forced: candidate.Forced, HearingImpaired: candidate.HearingImpaired, OriginalFormat: candidate.OriginalFormat, } var err error if target.bazarr.EpisodeID > 0 { err = s.bazarr.DownloadEpisode(ctx, target.bazarr.SeriesID, target.bazarr.EpisodeID, subtitle) } else { err = s.bazarr.DownloadMovie(ctx, target.bazarr.RadarrID, subtitle) } if err != nil { return fetchedSubtitle{}, err } _ = cred // the refresh the caller makes needs it; the download does not. return fetchedSubtitle{RefreshEmby: true}, nil } // fetchFromOpenSubtitles is the half of this feature that is not Bazarr-shaped: the // provider returns a file, the gateway keeps it, and it is served back as a sidecar. The // bytes are stored before anything is reported as successful, because a download that // spent the household's allowance and then lost the file is the worst outcome available. func (s *Server) fetchFromOpenSubtitles( ctx context.Context, itemID string, candidate subtitleCandidate, ) (fetchedSubtitle, error) { client := s.openSubtitlesClient(ctx) if client == nil { return fetchedSubtitle{}, fmt.Errorf("opensubtitles is not configured") } fileID, err := strconv.Atoi(strings.TrimSpace(candidate.Token)) if err != nil || fileID <= 0 { return fetchedSubtitle{}, fmt.Errorf("unusable opensubtitles file id") } name, content, err := client.Download(ctx, fileID) if err != nil { return fetchedSubtitle{}, err } format := candidate.format if format == "" { format = subtitleFormatFromName(name) } stored := store.DownloadedSubtitle{ ID: storedSubtitleID(itemID, candidate), ItemID: itemID, Language: candidate.Language, Label: storedSubtitleLabel(candidate), Forced: candidate.Forced, HearingImpaired: candidate.HearingImpaired, Format: format, Provider: store.SubtitleProviderOpenSubtitles, Content: content, } if err := s.store.PutDownloadedSubtitle(ctx, stored); err != nil { return fetchedSubtitle{}, err } return fetchedSubtitle{StoredID: stored.ID}, nil } // storedSubtitleID names one file. It is derived from what was asked for rather than being // random, so fetching the same language for the same title twice replaces the file instead // of growing a second track a viewer has to tell apart by guessing. func storedSubtitleID(itemID string, candidate subtitleCandidate) string { variant := "plain" switch { case candidate.Forced: variant = "forced" case candidate.HearingImpaired: variant = "sdh" } language := candidate.Language if language == "" { language = "und" } return strings.Join([]string{"gw", itemID, language, variant}, ":") } // storedSubtitleIDPrefix is what marks a track as one the gateway serves rather than one // Emby knows about. The player matches on the id it was handed, so the two namespaces must // not be able to collide: Emby's are stream indices, which are plain numbers. const storedSubtitleIDPrefix = "gw:" func isStoredSubtitleID(id string) bool { return strings.HasPrefix(id, storedSubtitleIDPrefix) } func storedSubtitleLabel(candidate subtitleCandidate) string { label := candidate.LanguageLabel if strings.TrimSpace(label) == "" { label = subtitleLanguageLabel(candidate.Language) } switch { case candidate.Forced: label += " · Forced" case candidate.HearingImpaired: label += " · Hearing impaired" } // Named for where it came from, because a viewer looking at a track list should be // able to see which one arrived a minute ago and which was always in the file. return label + " · Downloaded" } func subtitleFormatFromName(name string) string { if index := strings.LastIndex(name, "."); index >= 0 && index < len(name)-1 { switch extension := strings.ToLower(name[index+1:]); extension { case "srt", "vtt", "ass", "ssa": return extension } } return "srt" } func storedSubtitleMIME(format string) string { switch strings.ToLower(strings.TrimSpace(format)) { case "vtt": return "text/vtt" case "ass", "ssa": return "text/x-ssa" default: return "application/x-subrip" } } // storedSubtitlesFor turns what the gateway holds for an item into playable tracks. // // The URL is a path rather than an absolute address on purpose: the gateway does not // reliably know its own externally reachable name, and the television does — it is talking // to it. The client resolves a relative subtitle URL against the gateway it is signed into // and appends its own token, exactly as it already does for artwork. func (s *Server) storedSubtitlesFor(ctx context.Context, itemID string) []playableSubtitle { if s.store == nil { return nil } held, err := s.store.DownloadedSubtitlesFor(ctx, itemID) if err != nil { s.loggerFor(ctx).Warn("stored subtitles unavailable", "item", itemID, "error", err) return nil } out := make([]playableSubtitle, 0, len(held)) for _, subtitle := range held { out = append(out, playableSubtitle{ ID: subtitle.ID, URL: storedSubtitlePath(subtitle), MimeType: storedSubtitleMIME(subtitle.Format), Language: subtitle.Language, Label: subtitle.Label, IsForced: subtitle.Forced, IsHearingImpaired: subtitle.HearingImpaired, DeliveryMethod: "External", Codec: subtitle.Format, }) } return out } // storedSubtitlePath is the route the file is served from. The extension is on the end // because media3 sniffs one when a MIME type is missing or wrong, and a subtitle served // from a path with no extension is the kind of thing that works on one decoder. func storedSubtitlePath(subtitle store.DownloadedSubtitle) string { format := strings.ToLower(strings.TrimSpace(subtitle.Format)) if format == "" { format = "srt" } return "/v1/subtitles/" + url.PathEscape(subtitle.ID) + "." + format } // handleStoredSubtitle serves one file the gateway fetched. // // It is authenticated like everything else under /v1 — the token arrives in the query // string, the way artwork's does, because a media player fetching a sidecar sends no // headers of Memby's. The response is immutable: an id names one fetch, and a re-fetch // writes a new body under the same id only when a viewer deliberately downloads the same // language again, so a long cache is right and a revalidation per playback is not. func (s *Server) handleStoredSubtitle(w http.ResponseWriter, r *http.Request, _ store.Session) { name := r.PathValue("file") id := name if index := strings.LastIndex(name, "."); index > 0 { id = name[:index] } unescaped, err := url.PathUnescape(id) if err != nil || !isStoredSubtitleID(unescaped) { writeError(w, http.StatusNotFound, "unknown subtitle") return } subtitle, err := s.store.DownloadedSubtitle(r.Context(), unescaped) if err != nil { writeError(w, http.StatusNotFound, "unknown subtitle") return } w.Header().Set("Content-Type", storedSubtitleMIME(subtitle.Format)) w.Header().Set("Content-Length", strconv.Itoa(len(subtitle.Content))) w.Header().Set("Cache-Control", "private, max-age=86400") w.WriteHeader(http.StatusOK) _, _ = w.Write(subtitle.Content) } // mergeSubtitleTracks puts the gateway's own tracks beside Emby's, dropping any it already // covers. // // The overlap is real and it is the reason this is not an append: a subtitle fetched // through Bazarr becomes an Emby track, and a household that has Bazarr on may still have // fetched the same language here first. Two identically labelled rows in a drop-up is the // kind of thing that makes a viewer distrust the whole menu, so where both exist Emby's // wins — it is the one in the file, and it survives this gateway being replaced. func mergeSubtitleTracks(embyTracks, stored []playableSubtitle) []playableSubtitle { if len(stored) == 0 { return embyTracks } covered := map[string]bool{} for _, track := range embyTracks { covered[subtitleVariantKey(track)] = true } out := embyTracks for _, track := range stored { if covered[subtitleVariantKey(track)] { continue } out = append(out, track) } return out } func subtitleVariantKey(track playableSubtitle) string { return fmt.Sprintf("%s|%t|%t", normalizeSubtitleLanguage(track.Language), track.IsForced, track.IsHearingImpaired) } // rankMergedCandidates orders what the viewer sees across both providers and caps the list. // // The viewer's language comes first, because it is the only thing they asked for. Within a // language a plain track beats a forced or hearing-impaired one, for the reason the // selection rule already gives — somebody who chose Italian wants the dialogue, not the // signs — and a machine translation sinks below everything a person wrote. Only then does // the score decide, so a provider cannot buy its way to the top of somebody's list with a // confident number about the wrong language. func rankMergedCandidates(found []subtitleCandidate, language string) []subtitleCandidate { preferred := normalizeSubtitleLanguage(language) if preferred == subtitleLanguageAuto { preferred = "" } ordered := make([]subtitleCandidate, len(found)) copy(ordered, found) sort.SliceStable(ordered, func(i, j int) bool { left, right := ordered[i], ordered[j] leftPreferred := preferred != "" && left.Language == preferred rightPreferred := preferred != "" && right.Language == preferred if leftPreferred != rightPreferred { return leftPreferred } if left.MachineOnly != right.MachineOnly { return right.MachineOnly } if leftRank, rightRank := candidateVariantRank(left), candidateVariantRank(right); leftRank != rightRank { return leftRank < rightRank } return left.Score > right.Score }) if len(ordered) > maxSubtitleResults { ordered = ordered[:maxSubtitleResults] } for i := range ordered { ordered[i].Label = mergedCandidateLabel(ordered[i]) } return ordered } func candidateVariantRank(candidate subtitleCandidate) int { switch { case candidate.Forced: return 2 case candidate.HearingImpaired: return 1 default: return 0 } } // mergedCandidateLabel is what one row says. It is composed here rather than on the // television so an older app renders a new wording correctly, the same reason alert labels // are the gateway's. // // The provider is named now, where the single-provider version deliberately did not: with // two backends configured the same language appears twice and "which of these is which" is // a question the row has to answer. A machine translation says so, because it is the one // property of a subtitle that changes whether somebody wants it at all. func mergedCandidateLabel(candidate subtitleCandidate) string { label := candidate.LanguageLabel if strings.TrimSpace(label) == "" { label = subtitleLanguageLabel(candidate.Language) } switch { case candidate.Forced: label += " · Forced" case candidate.HearingImpaired: label += " · Hearing impaired" } if candidate.MachineOnly { label += " · Machine translated" } if candidate.Score > 0 { label += fmt.Sprintf(" · %d%% match", clampPercent(candidate.Score)) } return label } // subtitleFailureMessage turns what went wrong into the sentence printed over an empty // list. A television has no log and no support channel, so this is the whole diagnosis — // and the quota case is separated out because it is the only one where pressing the button // again is definitely not the answer. func subtitleFailureMessage(failures []error) string { for _, err := range failures { if _, ok := err.(*opensubtitles.QuotaError); ok { return "Today's subtitle downloads have been used up." } } if len(failures) > 0 { return "The subtitle service did not answer." } return "No subtitles were found for this release." }