Files

728 lines
27 KiB
Go
Raw Permalink Normal View History

2026-08-09 08:25:50 +12:00
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."
}