Release v0.2.34
This commit is contained in:
@@ -5,31 +5,35 @@ import (
|
||||
"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/opensubtitles"
|
||||
"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.
|
||||
// Two providers answer this now and they are not the same shape. **Bazarr** writes the
|
||||
// subtitle file beside the media file, so the gateway asks and forgets — Emby finds the
|
||||
// result on a refresh and the track arrives down the same PlaybackInfo path as an embedded
|
||||
// one, which is why `playableSubtitle` needed no new shape. **OpenSubtitles** hands back
|
||||
// bytes, and the gateway has no reach into the media directory, so a file fetched there is
|
||||
// stored by the gateway and served back as a sidecar. `subtitle_providers.go` holds that
|
||||
// difference and everything here is written against the one vocabulary.
|
||||
//
|
||||
// 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.
|
||||
// The hard part is not the download, it is the identity, and the two providers make
|
||||
// opposite trades on it. 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 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. OpenSubtitles keys on an imdb or tmdb id, which Memby
|
||||
// already holds — the library import asks Emby for `ProviderIds` so external ratings can
|
||||
// be looked up — so there is no guessing at all on that path.
|
||||
|
||||
const (
|
||||
bazarrMoviesCacheKey = "bazarr:movies"
|
||||
@@ -53,6 +57,12 @@ const (
|
||||
// expire underneath them, which on a set being operated by remote control is the more
|
||||
// likely failure of the two.
|
||||
type subtitleCandidate struct {
|
||||
// Source is which backend produced this row, and it is what the download call
|
||||
// dispatches on. It round-trips through the television with the token, because the two
|
||||
// providers' tokens are opaque in different ways and handing one to the other is a
|
||||
// mistake nothing downstream could detect. Empty means Bazarr: an app built before
|
||||
// there was a second provider sends no source, and its rows all came from one place.
|
||||
Source string `json:"source,omitempty"`
|
||||
Token string `json:"token"`
|
||||
Language string `json:"language"`
|
||||
LanguageLabel string `json:"languageLabel"`
|
||||
@@ -61,6 +71,17 @@ type subtitleCandidate struct {
|
||||
Forced bool `json:"forced"`
|
||||
HearingImpaired bool `json:"hearingImpaired"`
|
||||
OriginalFormat bool `json:"originalFormat"`
|
||||
// MachineOnly marks a translation nobody wrote. It is on the wire rather than folded
|
||||
// into the label alone because it is the one property that changes whether a viewer
|
||||
// wants the row at all, and the ranking sinks it below everything a person wrote.
|
||||
MachineOnly bool `json:"machineOnly,omitempty"`
|
||||
// Release is the file's own release string, carried for the log rather than the screen
|
||||
// — "Interstellar.2014.1080p.BluRay" means nothing across a lounge.
|
||||
Release string `json:"-"`
|
||||
// format is the extension the provider's file carries. Lower case and unexported: it
|
||||
// is the gateway's own bookkeeping for a file it is about to store, and there is
|
||||
// nothing for a television to do with it.
|
||||
format string
|
||||
// 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.
|
||||
@@ -96,11 +117,11 @@ type subtitleDownloadResponse struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// offer the option at all. It is true when the feature is on and at least one provider is
|
||||
// both configured and switched on — a deployment with neither must never draw a row that
|
||||
// leads to a request nothing can answer.
|
||||
func (s *Server) subtitleDownloadAvailable(ctx context.Context) bool {
|
||||
return s.bazarr != nil && s.featureEnabled(ctx, featureSubtitleDownload)
|
||||
return s.subtitleSources(ctx).any()
|
||||
}
|
||||
|
||||
func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
@@ -110,12 +131,13 @@ func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, se
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
if !s.subtitleDownloadAvailable(ctx) {
|
||||
sources := s.subtitleSources(ctx)
|
||||
if !sources.any() {
|
||||
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := s.resolveBazarrTarget(ctx, credentials(sess), itemID)
|
||||
target, err := s.resolveSubtitleTarget(ctx, credentials(sess), itemID, sources)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
|
||||
writeJSON(w, http.StatusOK, subtitleSearchResponse{
|
||||
@@ -125,33 +147,32 @@ func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, se
|
||||
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)
|
||||
found, failures := s.providerSubtitles(ctx, sources, target, language)
|
||||
for _, failure := range failures {
|
||||
s.loggerFor(ctx).Warn("subtitle search failed",
|
||||
"item", itemID, "title", target.Title, "error", failure,
|
||||
)
|
||||
}
|
||||
results := rankMergedCandidates(found, language)
|
||||
s.loggerFor(ctx).Info("subtitle search",
|
||||
"title", target.Title,
|
||||
"item", itemID,
|
||||
"language", clientLogValue(language),
|
||||
"bazarr", sources.Bazarr && target.hasBazarr,
|
||||
"opensubtitles", sources.OpenSubtitles && target.hasQuery,
|
||||
"found", len(found),
|
||||
"offered", len(results),
|
||||
)
|
||||
response := subtitleSearchResponse{Results: results}
|
||||
if len(results) == 0 {
|
||||
response.Message = "No subtitles were found for this release."
|
||||
// Which of the two empty answers this is matters to somebody standing in front of
|
||||
// the set: providers that found nothing is a different thing from providers that
|
||||
// did not answer, and an exhausted allowance is a third.
|
||||
response.Message = subtitleFailureMessage(failures)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
@@ -163,7 +184,8 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
if !s.subtitleDownloadAvailable(ctx) {
|
||||
sources := s.subtitleSources(ctx)
|
||||
if !sources.any() {
|
||||
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
|
||||
return
|
||||
}
|
||||
@@ -173,54 +195,49 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(request.Candidate.Token) == "" {
|
||||
candidate := request.Candidate
|
||||
candidate.Language = normalizeSubtitleLanguage(candidate.Language)
|
||||
if strings.TrimSpace(candidate.Token) == "" {
|
||||
writeError(w, http.StatusBadRequest, "a subtitle is required")
|
||||
return
|
||||
}
|
||||
|
||||
cred := credentials(sess)
|
||||
target, err := s.resolveBazarrTarget(ctx, cred, itemID)
|
||||
target, err := s.resolveSubtitleTarget(ctx, cred, itemID, sources)
|
||||
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)
|
||||
}
|
||||
fetched, err := s.fetchSubtitle(ctx, cred, itemID, target, candidate)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle download failed",
|
||||
"title", target.Title, "item", itemID,
|
||||
"provider", clientLogValue(subtitle.Provider), "error", err,
|
||||
"source", clientLogValue(candidate.Source),
|
||||
"provider", clientLogValue(candidate.Provider), "error", err,
|
||||
)
|
||||
writeError(w, http.StatusBadGateway, "the subtitle could not be downloaded")
|
||||
writeError(w, http.StatusBadGateway, subtitleDownloadFailureMessage(err))
|
||||
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
|
||||
// Bazarr has written a file Emby does not know exists, and refreshing is what makes
|
||||
// the track appear. 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. A subtitle the gateway serves itself needs none
|
||||
// of this, and waiting anyway would spend a couple of seconds of somebody's film on
|
||||
// nothing.
|
||||
if fetched.RefreshEmby {
|
||||
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(
|
||||
@@ -230,24 +247,43 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
||||
if negotiatedURL != "" {
|
||||
streamURL = negotiatedURL
|
||||
}
|
||||
// A file the gateway stored names itself, so the player can be pointed at exactly the
|
||||
// track that was just fetched. Only Bazarr's path has to guess, and it says so by
|
||||
// answering empty.
|
||||
selected := fetched.StoredID
|
||||
if selected == "" {
|
||||
selected = newestSubtitleID(subtitles, candidate)
|
||||
}
|
||||
|
||||
s.loggerFor(ctx).Info("subtitle downloaded",
|
||||
"title", target.Title,
|
||||
"item", itemID,
|
||||
"language", clientLogValue(subtitle.Language),
|
||||
"provider", clientLogValue(subtitle.Provider),
|
||||
"language", clientLogValue(candidate.Language),
|
||||
"source", clientLogValue(candidate.Source),
|
||||
"provider", clientLogValue(candidate.Provider),
|
||||
"release", clientLogValue(candidate.Release),
|
||||
"subtitles", len(subtitles),
|
||||
)
|
||||
writeJSON(w, http.StatusOK, subtitleDownloadResponse{
|
||||
Message: downloadedSubtitleMessage(request.Candidate),
|
||||
Message: downloadedSubtitleMessage(candidate),
|
||||
Subtitles: subtitles,
|
||||
SelectedSubtitleID: newestSubtitleID(subtitles, request.Candidate),
|
||||
SelectedSubtitleID: selected,
|
||||
MediaSourceID: mediaSourceID,
|
||||
PlaySessionID: playSessionID,
|
||||
URL: streamURL,
|
||||
})
|
||||
}
|
||||
|
||||
// subtitleDownloadFailureMessage is the sentence a television prints when a fetch fails.
|
||||
// The allowance running out keeps its own wording for the reason the search's does:
|
||||
// pressing the button again will not fix it, and nothing else on the set can say so.
|
||||
func subtitleDownloadFailureMessage(err error) string {
|
||||
if _, ok := err.(*opensubtitles.QuotaError); ok {
|
||||
return "today's subtitle downloads have been used up"
|
||||
}
|
||||
return "the subtitle could not be downloaded"
|
||||
}
|
||||
|
||||
// bazarrTarget is an Emby item resolved onto the ids Bazarr keys on. Exactly one of
|
||||
// RadarrID and EpisodeID is set.
|
||||
type bazarrTarget struct {
|
||||
@@ -419,83 +455,6 @@ func bazarrEpisodeFor(episodes []bazarr.Episode, season, number int) *bazarr.Epi
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user