App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -0,0 +1,643 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user