728 lines
27 KiB
Go
728 lines
27 KiB
Go
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."
|
||
|
|
}
|