package api import ( "context" "encoding/json" "fmt" "net/http" "sort" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/bazarr" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/store" ) // Fetching a subtitle a title does not have. // // The whole feature rests on one property of Bazarr: it writes the subtitle file beside // the media file. So the gateway never stores a subtitle, never serves one, and never // learns a provider's credentials — it asks Bazarr to fetch, asks Emby to look again, and // the track then arrives down the same PlaybackInfo path as an embedded one. That is why // `playableSubtitle` needed no new shape and the player's existing selection rule works on // a downloaded track with no special case. // // The hard part is not the download, it is the identity. Bazarr keys everything on the // *arr's id (`radarrid` for a film, Sonarr's `episodeid` for an episode) and Emby knows // nothing about either, so an Emby item has to be matched onto one by title, year and — // for an episode — season and episode number. That matching is pure and unit-tested // (`bazarrMovieFor`, `bazarrEpisodeFor`), because it is where a wrong answer is worst: a // mismatch downloads a subtitle for the wrong film and writes it next to this one. const ( bazarrMoviesCacheKey = "bazarr:movies" bazarrSeriesCacheKey = "bazarr:series" bazarrEpisodesCacheKey = "bazarr:episodes:" // maxSubtitleResults caps what a television is shown. A manual search can return // dozens of near-identical releases, and a D-pad list is not the place to read them. maxSubtitleResults = 12 // embyRefreshSettleDelay is how long Emby is given to notice the new file before the // gateway re-reads the item's streams. A refresh is asynchronous, so returning // immediately would reliably report the track as missing on the one request that is // certain to be looking for it. embyRefreshSettleDelay = 1500 * time.Millisecond ) // subtitleCandidate is one row a viewer can choose from. // // Token is Bazarr's opaque provider handle and it round-trips through the television // untouched — nothing on either side parses it. Keeping it on the wire rather than in a // server-side cache means a viewer who reads the list slowly cannot have their choice // expire underneath them, which on a set being operated by remote control is the more // likely failure of the two. type subtitleCandidate struct { Token string `json:"token"` Language string `json:"language"` LanguageLabel string `json:"languageLabel"` Provider string `json:"provider"` Score int `json:"score"` Forced bool `json:"forced"` HearingImpaired bool `json:"hearingImpaired"` OriginalFormat bool `json:"originalFormat"` // Label is what the drop-up prints. It is composed here rather than on the TV so an // older app renders a new wording correctly, the same reason alert labels are the // gateway's. Label string `json:"label"` } type subtitleSearchResponse struct { Results []subtitleCandidate `json:"results"` // Message is what to say when there are none. A search that reached the providers and // found nothing is a different answer from one that could not identify the title, and // a viewer standing in front of the set deserves to be told which. Message string `json:"message,omitempty"` } type subtitleDownloadRequest struct { // The candidate exactly as it was handed out. Only Token, Language, Provider and the // three flags are read; anything else the client echoes is ignored. Candidate subtitleCandidate `json:"candidate"` } type subtitleDownloadResponse struct { Message string `json:"message"` // The item's subtitle tracks re-read from Emby after the refresh, so the player can // swap its media item and turn the new track on without a second round trip. Subtitles []playableSubtitle `json:"subtitles"` // Which of them is the one that was just fetched, when it can be identified. Empty // means "it is in the list somewhere" — the client falls back to its own rule rather // than guessing, exactly as it does for the server's ordinary selection. SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"` MediaSourceID string `json:"mediaSourceId"` PlaySessionID string `json:"playSessionId"` URL string `json:"url"` } // subtitleDownloadAvailable is the one thing the television needs to know: whether to // offer the option at all. Both halves matter — an operator can turn the feature off on a // deployment that has Bazarr, and a deployment without Bazarr must never show a row that // cannot do anything. func (s *Server) subtitleDownloadAvailable(ctx context.Context) bool { return s.bazarr != nil && s.featureEnabled(ctx, featureSubtitleDownload) } func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, sess store.Session) { ctx := r.Context() itemID := r.PathValue("id") if itemID == "" { writeError(w, http.StatusBadRequest, "item id is required") return } if !s.subtitleDownloadAvailable(ctx) { writeError(w, http.StatusNotFound, "subtitle downloads are not available") return } target, err := s.resolveBazarrTarget(ctx, credentials(sess), itemID) if err != nil { s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err) writeJSON(w, http.StatusOK, subtitleSearchResponse{ Results: []subtitleCandidate{}, Message: "Memby could not work out which title this is.", }) return } found, err := s.searchBazarr(ctx, target) if err != nil { s.loggerFor(ctx).Warn("subtitle search failed", "item", itemID, "title", target.Title, "error", err, ) writeJSON(w, http.StatusOK, subtitleSearchResponse{ Results: []subtitleCandidate{}, Message: "The subtitle service did not answer.", }) return } language := strings.TrimSpace(r.URL.Query().Get("language")) if language == "" { _, language = s.subtitlePreferenceFor(ctx, sess) } results := rankSubtitleCandidates(found, language) s.loggerFor(ctx).Info("subtitle search", "title", target.Title, "item", itemID, "language", clientLogValue(language), "found", len(found), "offered", len(results), ) response := subtitleSearchResponse{Results: results} if len(results) == 0 { response.Message = "No subtitles were found for this release." } writeJSON(w, http.StatusOK, response) } func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request, sess store.Session) { ctx := r.Context() itemID := r.PathValue("id") if itemID == "" { writeError(w, http.StatusBadRequest, "item id is required") return } if !s.subtitleDownloadAvailable(ctx) { writeError(w, http.StatusNotFound, "subtitle downloads are not available") return } var request subtitleDownloadRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } if strings.TrimSpace(request.Candidate.Token) == "" { writeError(w, http.StatusBadRequest, "a subtitle is required") return } cred := credentials(sess) target, err := s.resolveBazarrTarget(ctx, cred, itemID) if err != nil { s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err) writeError(w, http.StatusNotFound, "Memby could not work out which title this is") return } subtitle := bazarr.Subtitle{ Language: request.Candidate.Language, Provider: request.Candidate.Provider, Token: request.Candidate.Token, Forced: request.Candidate.Forced, HearingImpaired: request.Candidate.HearingImpaired, OriginalFormat: request.Candidate.OriginalFormat, } if target.EpisodeID > 0 { err = s.bazarr.DownloadEpisode(ctx, target.SeriesID, target.EpisodeID, subtitle) } else { err = s.bazarr.DownloadMovie(ctx, target.RadarrID, subtitle) } if err != nil { s.loggerFor(ctx).Warn("subtitle download failed", "title", target.Title, "item", itemID, "provider", clientLogValue(subtitle.Provider), "error", err, ) writeError(w, http.StatusBadGateway, "the subtitle could not be downloaded") return } // Bazarr has written the file; Emby does not know it exists. Refreshing is what makes // the track appear, and it is best-effort: if it fails the file is still on disk and // the next ordinary scan picks it up, so the viewer is told to try again rather than // told the download failed when it did not. if refreshErr := s.emby.RefreshItem(ctx, cred, itemID); refreshErr != nil { s.loggerFor(ctx).Warn("emby refresh after subtitle download failed", "item", itemID, "error", refreshErr, ) } select { case <-time.After(embyRefreshSettleDelay): case <-ctx.Done(): return } subtitles, mediaSourceID, playSessionID, negotiatedURL, _ := s.playbackSubtitles( ctx, cred, itemID, 0, nil, "", false, s.effectivePlaybackCapabilities(ctx, sess), ) streamURL := s.emby.StreamURL(cred, itemID) if negotiatedURL != "" { streamURL = negotiatedURL } s.loggerFor(ctx).Info("subtitle downloaded", "title", target.Title, "item", itemID, "language", clientLogValue(subtitle.Language), "provider", clientLogValue(subtitle.Provider), "subtitles", len(subtitles), ) writeJSON(w, http.StatusOK, subtitleDownloadResponse{ Message: downloadedSubtitleMessage(request.Candidate), Subtitles: subtitles, SelectedSubtitleID: newestSubtitleID(subtitles, request.Candidate), MediaSourceID: mediaSourceID, PlaySessionID: playSessionID, URL: streamURL, }) } // bazarrTarget is an Emby item resolved onto the ids Bazarr keys on. Exactly one of // RadarrID and EpisodeID is set. type bazarrTarget struct { Title string RadarrID int SeriesID int EpisodeID int } func (s *Server) searchBazarr(ctx context.Context, target bazarrTarget) ([]bazarr.Subtitle, error) { if target.EpisodeID > 0 { return s.bazarr.SearchEpisode(ctx, target.EpisodeID) } return s.bazarr.SearchMovie(ctx, target.RadarrID) } // resolveBazarrTarget turns an Emby item id into Bazarr's idea of the same thing. func (s *Server) resolveBazarrTarget( ctx context.Context, cred emby.Credentials, itemID string, ) (bazarrTarget, error) { raw, err := s.emby.Item(ctx, cred, itemID, "ProductionYear,SeriesName,ParentIndexNumber,IndexNumber") if err != nil { return bazarrTarget{}, err } var item struct { Name string `json:"Name"` Type string `json:"Type"` ProductionYear int `json:"ProductionYear"` SeriesName string `json:"SeriesName"` ParentIndexNumber int `json:"ParentIndexNumber"` IndexNumber int `json:"IndexNumber"` } if err := json.Unmarshal(raw, &item); err != nil { return bazarrTarget{}, fmt.Errorf("unreadable item from emby: %w", err) } if strings.EqualFold(item.Type, "Episode") { series, err := s.bazarrSeries(ctx) if err != nil { return bazarrTarget{}, err } // An episode carries its series' name but not its year, so the year is looked up // on the series item rather than guessed from the episode's air date — a show that // ran for years would otherwise match the wrong entry of a remake pair. matched := bazarrSeriesFor(item.SeriesName, s.seriesYear(ctx, cred, raw), series) if matched == nil { return bazarrTarget{}, fmt.Errorf("no bazarr series for %q", item.SeriesName) } episodes, err := s.bazarrEpisodes(ctx, matched.SonarrSeriesID) if err != nil { return bazarrTarget{}, err } episode := bazarrEpisodeFor(episodes, item.ParentIndexNumber, item.IndexNumber) if episode == nil { return bazarrTarget{}, fmt.Errorf( "no bazarr episode for %q S%02dE%02d", item.SeriesName, item.ParentIndexNumber, item.IndexNumber, ) } title := fmt.Sprintf("%s S%02dE%02d", item.SeriesName, episode.Season, episode.Episode) return bazarrTarget{ Title: title, SeriesID: matched.SonarrSeriesID, EpisodeID: episode.SonarrEpisodeID, }, nil } if !strings.EqualFold(item.Type, "Movie") { return bazarrTarget{}, fmt.Errorf("subtitles cannot be fetched for a %q", item.Type) } movies, err := s.bazarrMovies(ctx) if err != nil { return bazarrTarget{}, err } matched := bazarrMovieFor(item.Name, item.ProductionYear, movies) if matched == nil { return bazarrTarget{}, fmt.Errorf("no bazarr movie for %q", item.Name) } return bazarrTarget{Title: item.Name, RadarrID: matched.RadarrID}, nil } // seriesYear reads the parent series' production year, which is what disambiguates a // remake from its original. A failure is not fatal: zero means "match on title alone". func (s *Server) seriesYear(ctx context.Context, cred emby.Credentials, episode json.RawMessage) int { var parsed struct { SeriesID string `json:"SeriesId"` } if json.Unmarshal(episode, &parsed) != nil || parsed.SeriesID == "" { return 0 } raw, err := s.emby.Item(ctx, cred, parsed.SeriesID, "ProductionYear") if err != nil { return 0 } var series struct { ProductionYear int `json:"ProductionYear"` } if json.Unmarshal(raw, &series) != nil { return 0 } return series.ProductionYear } // bazarrMovieFor matches an Emby film onto Bazarr's list. // // Title alone is not enough — a remake and its original share one — so a year that both // sides know must agree. A year only one side knows is not treated as a disagreement, // because Bazarr carries the year as a string and an empty one is common. func bazarrMovieFor(title string, year int, movies []bazarr.Movie) *bazarr.Movie { key := normalizedShowTitle(title) if key == "" { return nil } var fallback *bazarr.Movie for i := range movies { candidate := &movies[i] if normalizedShowTitle(candidate.Title) != key { continue } candidateYear, _ := strconv.Atoi(strings.TrimSpace(candidate.Year)) if year > 0 && candidateYear > 0 && year == candidateYear { return candidate } if fallback == nil && (year == 0 || candidateYear == 0) { fallback = candidate } } return fallback } // bazarrSeriesFor is the same rule for a show. It is deliberately a separate function // rather than a generic one: the two lists have different field names and a shared helper // would have to take accessors, which is more machinery than two short loops. func bazarrSeriesFor(title string, year int, series []bazarr.Series) *bazarr.Series { key := normalizedShowTitle(title) if key == "" { return nil } var fallback *bazarr.Series for i := range series { candidate := &series[i] if normalizedShowTitle(candidate.Title) != key { continue } candidateYear, _ := strconv.Atoi(strings.TrimSpace(candidate.Year)) if year > 0 && candidateYear > 0 && year == candidateYear { return candidate } if fallback == nil && (year == 0 || candidateYear == 0) { fallback = candidate } } return fallback } // bazarrEpisodeFor matches on season and episode number, which both Emby and Sonarr agree // on. Titles are not compared: they differ between the two often enough (translations, // two-parters named differently) that they would reject correct matches. // // Season 0 is a legitimate season — specials — so a zero season number is only rejected // when the episode number is also missing. func bazarrEpisodeFor(episodes []bazarr.Episode, season, number int) *bazarr.Episode { if number <= 0 || season < 0 { return nil } for i := range episodes { if episodes[i].Season == season && episodes[i].Episode == number { return &episodes[i] } } return nil } // rankSubtitleCandidates orders what the viewer sees and caps the list. // // The viewer's language comes first, because it is the only thing they asked for; within // that, Bazarr's own score decides, because it is the only number on the row that means // anything on a television. Forced and hearing-impaired tracks sort below plain ones in // the same language for the reason the selection rule already gives — somebody who chose // Italian wants the dialogue, not the signs. func rankSubtitleCandidates(found []bazarr.Subtitle, language string) []subtitleCandidate { preferred := normalizeSubtitleLanguage(language) if preferred == subtitleLanguageAuto { preferred = "" } ordered := make([]bazarr.Subtitle, len(found)) copy(ordered, found) sort.SliceStable(ordered, func(i, j int) bool { left, right := ordered[i], ordered[j] leftPreferred := preferred != "" && normalizeSubtitleLanguage(left.Language) == preferred rightPreferred := preferred != "" && normalizeSubtitleLanguage(right.Language) == preferred if leftPreferred != rightPreferred { return leftPreferred } if leftVariant, rightVariant := subtitleVariantRank(left), subtitleVariantRank(right); leftVariant != rightVariant { return leftVariant < rightVariant } return left.Score > right.Score }) if len(ordered) > maxSubtitleResults { ordered = ordered[:maxSubtitleResults] } results := make([]subtitleCandidate, 0, len(ordered)) for _, subtitle := range ordered { if strings.TrimSpace(subtitle.Token) == "" { continue } results = append(results, subtitleCandidate{ Token: subtitle.Token, Language: normalizeSubtitleLanguage(subtitle.Language), LanguageLabel: subtitleLanguageLabel(subtitle.Language), Provider: subtitle.Provider, Score: subtitle.Score, Forced: subtitle.Forced, HearingImpaired: subtitle.HearingImpaired, OriginalFormat: subtitle.OriginalFormat, Label: subtitleCandidateLabel(subtitle), }) } return results } func subtitleVariantRank(subtitle bazarr.Subtitle) int { switch { case subtitle.Forced: return 2 case subtitle.HearingImpaired: return 1 default: return 0 } } // subtitleCandidateLabel is what one row says: the language, what kind of track it is, and // how well Bazarr thinks it matches. The provider is deliberately absent — a viewer has no // way to prefer one and the name would only crowd the row. func subtitleCandidateLabel(subtitle bazarr.Subtitle) string { label := subtitleLanguageLabel(subtitle.Language) switch { case subtitle.Forced: label += " · Forced" case subtitle.HearingImpaired: label += " · Hearing impaired" } if subtitle.Score > 0 { label += fmt.Sprintf(" · %d%% match", clampPercent(subtitle.Score)) } return label } func clampPercent(value int) int { if value < 0 { return 0 } if value > 100 { return 100 } return value } // subtitleLanguageLabel names a language for display, falling back to the code itself // rather than to a blank when Memby has no entry for it — an unnamed row is worse than an // unfamiliar one. func subtitleLanguageLabel(raw string) string { normalized := normalizeSubtitleLanguage(raw) for _, language := range subtitleLanguages { if language.Value == normalized { return language.Label } } if normalized == "" { return "Unknown" } return strings.ToUpper(normalized) } func downloadedSubtitleMessage(candidate subtitleCandidate) string { label := candidate.LanguageLabel if strings.TrimSpace(label) == "" { label = subtitleLanguageLabel(candidate.Language) } return label + " subtitles downloaded." } // newestSubtitleID picks the track the download produced out of the refreshed list. // // Emby gives a sidecar no marker saying when it arrived, so this matches on what was // asked for: an external track in the right language, preferring one whose forced and // hearing-impaired flags agree with the candidate. It is allowed to fail — an empty answer // means the client applies its own rule, which is the same thing it does on every other // playback response. func newestSubtitleID(subtitles []playableSubtitle, candidate subtitleCandidate) string { wanted := normalizeSubtitleLanguage(candidate.Language) if wanted == "" { return "" } best, bestRank := "", 0 for _, subtitle := range subtitles { if normalizeSubtitleLanguage(subtitle.Language) != wanted { continue } if !strings.EqualFold(subtitle.DeliveryMethod, "External") { continue } rank := 1 if subtitle.IsForced == candidate.Forced { rank++ } if subtitle.IsHearingImpaired == candidate.HearingImpaired { rank++ } if rank > bestRank { best, bestRank = subtitle.ID, rank } } return best } // The three listings exist only to turn an Emby item into an *arr id, so they are cached // for minutes: a household's library does not change between one subtitle search and the // next, and a manual search is already slow enough without three list requests in front // of it. A cache miss is not an error — it just costs the request. func (s *Server) bazarrMovies(ctx context.Context) ([]bazarr.Movie, error) { var movies []bazarr.Movie if s.cachedBazarr(ctx, bazarrMoviesCacheKey, &movies) { return movies, nil } s.bazarrMu.Lock() defer s.bazarrMu.Unlock() if s.cachedBazarr(ctx, bazarrMoviesCacheKey, &movies) { return movies, nil } movies, err := s.bazarr.Movies(ctx) if err != nil { return nil, err } s.storeBazarr(ctx, bazarrMoviesCacheKey, movies) return movies, nil } func (s *Server) bazarrSeries(ctx context.Context) ([]bazarr.Series, error) { var series []bazarr.Series if s.cachedBazarr(ctx, bazarrSeriesCacheKey, &series) { return series, nil } s.bazarrMu.Lock() defer s.bazarrMu.Unlock() if s.cachedBazarr(ctx, bazarrSeriesCacheKey, &series) { return series, nil } series, err := s.bazarr.Series(ctx) if err != nil { return nil, err } s.storeBazarr(ctx, bazarrSeriesCacheKey, series) return series, nil } func (s *Server) bazarrEpisodes(ctx context.Context, seriesID int) ([]bazarr.Episode, error) { key := bazarrEpisodesCacheKey + strconv.Itoa(seriesID) var episodes []bazarr.Episode if s.cachedBazarr(ctx, key, &episodes) { return episodes, nil } s.bazarrMu.Lock() defer s.bazarrMu.Unlock() if s.cachedBazarr(ctx, key, &episodes) { return episodes, nil } episodes, err := s.bazarr.Episodes(ctx, seriesID) if err != nil { return nil, err } s.storeBazarr(ctx, key, episodes) return episodes, nil } func (s *Server) cachedBazarr(ctx context.Context, key string, out any) bool { raw, err := s.cache.Get(ctx, key) if err != nil { return false } return json.Unmarshal(raw, out) == nil } func (s *Server) storeBazarr(ctx context.Context, key string, value any) { body, err := json.Marshal(value) if err != nil { return } if err := s.cache.Set(ctx, key, body, s.cfg.BazarrTTL); err != nil { s.loggerFor(ctx).Warn("bazarr cache write failed", "key", key, "error", err) } }