package api import ( "context" "encoding/json" "errors" "fmt" "net/http" "sort" "strconv" "strings" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/subsync" ) // Fixing a subtitle's timing. // // The whole of the work is in internal/subsync, which is pure. What lives here is the part // that cannot be: choosing which track to measure against, reading both of them out of // wherever they happen to live, and storing the result as an ordinary sidecar so it is a // track on every later playback rather than something that exists only in this response. // // It is deliberately a separate route from the download one. A viewer whose subtitle is // out of sync already *has* the file they want, and sending them through a provider search // to get another copy of it — which may be equally out — is the wrong answer to what they // asked. // subtitleFixSuffix marks the repaired copy. It is part of the stored id, so fixing the // same track twice replaces the earlier attempt instead of growing a third row that a // viewer has to tell apart from the other two by guessing. const subtitleFixSuffix = "fixed" type subtitleFixRequest struct { SubtitleID string `json:"subtitleId"` } type subtitleFixResponse struct { // SubtitleID is the new track to select, absent when nothing needed changing. SubtitleID string `json:"subtitleId,omitempty"` Message string `json:"message"` // Changed is false when the track was already in sync. It is on the wire rather than // inferred from an empty id because "already right" is a success a viewer should be // told about plainly, not a silent no-op that reads as the button having failed. Changed bool `json:"changed"` // OffsetMs and Reference are what was done and what it was judged against. A viewer // deciding whether to trust the result needs both. OffsetMs int64 `json:"offsetMs"` Reference string `json:"reference,omitempty"` } // subtitleFixAvailable answers the smaller question the playback response needs: whether // opening the timing action can lead anywhere for this title. The handler remains // authoritative because a track can disappear or fail to parse between playback and the // press, but not advertising an impossible action avoids a guaranteed refusal in the menu. func (s *Server) subtitleFixAvailable(tracks []playableSubtitle) bool { if s.store == nil { return false } for _, target := range tracks { if len(referenceCandidates(target, tracks)) > 0 { return true } } return false } func (s *Server) handleSubtitleFix(w http.ResponseWriter, r *http.Request, sess store.Session) { ctx := r.Context() itemID := r.PathValue("id") if s.store == nil { writeError(w, http.StatusServiceUnavailable, "this server cannot fix subtitle timing") return } var req subtitleFixRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid subtitle fix request") return } if strings.TrimSpace(req.SubtitleID) == "" { writeError(w, http.StatusBadRequest, "no subtitle was named") return } cred := credentials(sess) tracks, mediaSourceID, _, _, _ := s.playbackSubtitles( ctx, cred, itemID, 0, nil, "", false, sessionPlaybackCapabilities(sess), ) target, ok := trackByID(tracks, req.SubtitleID) if !ok { writeError(w, http.StatusNotFound, "that subtitle is no longer on this title") return } log := s.loggerFor(ctx) fixed, err := s.fixSubtitleTiming(ctx, cred, itemID, mediaSourceID, target, tracks) if err != nil { var refusal *subsync.ErrNoAlignment if errors.As(err, &refusal) { // A refusal is the ordinary answer, not a fault: most of subsync's job is // declining to guess. It is reported as a 200 carrying the reason, because // the viewer needs the sentence and an error status would have the client // print its own generic one over the top of it. log.Info("subtitle timing not fixed", "item_id", itemID, "subtitle", req.SubtitleID, "reason", refusal.Reason, "score", refusal.Score, "margin", refusal.Margin) writeJSON(w, http.StatusOK, subtitleFixResponse{Message: refusal.Reason}) return } log.Warn("subtitle fix failed", "item_id", itemID, "subtitle", req.SubtitleID, "error", err) writeError(w, http.StatusBadGateway, subtitleFixFailureMessage(err)) return } log.Info("subtitle timing fixed", "item_id", itemID, "subtitle", req.SubtitleID, "reference", fixed.Reference, "correction", fixed.Correction, "score", fixed.Score, "changed", fixed.Changed) writeJSON(w, http.StatusOK, fixed.response()) } type subtitleFixOutcome struct { StoredID string Reference string Correction string OffsetMs int64 Score float64 Changed bool } func (o subtitleFixOutcome) response() subtitleFixResponse { if !o.Changed { return subtitleFixResponse{ Message: "This subtitle is already in time with " + o.Reference + ".", Reference: o.Reference, } } return subtitleFixResponse{ SubtitleID: o.StoredID, Message: fmt.Sprintf("Timing corrected by %s against %s. The fixed copy is in the list.", o.Correction, o.Reference), Changed: true, OffsetMs: o.OffsetMs, Reference: o.Reference, } } // fixSubtitleTiming reads both tracks, aligns them, and stores the result. func (s *Server) fixSubtitleTiming( ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string, target playableSubtitle, tracks []playableSubtitle, ) (subtitleFixOutcome, error) { brokenRaw, err := s.subtitleContent(ctx, cred, itemID, mediaSourceID, target) if err != nil { return subtitleFixOutcome{}, fmt.Errorf("read the subtitle being fixed: %w", err) } broken, err := subsync.Parse(brokenRaw) if err != nil { return subtitleFixOutcome{}, fmt.Errorf("parse the subtitle being fixed: %w", err) } reference, referenceTrack, err := s.referenceTrack(ctx, cred, itemID, mediaSourceID, target, tracks) if err != nil { return subtitleFixOutcome{}, err } opts := subsync.DefaultOptions() result, err := subsync.Align(broken, reference, opts) if err != nil { return subtitleFixOutcome{}, err } outcome := subtitleFixOutcome{ Reference: subtitleTrackName(referenceTrack), Correction: result.String(), OffsetMs: result.Offset.Milliseconds(), Score: result.Score, Changed: result.Correction(), } if !outcome.Changed { return outcome, nil } stored := store.DownloadedSubtitle{ ID: fixedSubtitleID(itemID, target), ItemID: itemID, Language: target.Language, Label: fixedSubtitleLabel(target), Forced: target.IsForced, HearingImpaired: target.IsHearingImpaired, Format: "srt", Provider: store.SubtitleProviderMemby, Content: subsync.FormatSRT(subsync.Shift(broken, result.Offset, result.Scale)), } if err := s.store.PutDownloadedSubtitle(ctx, stored); err != nil { return subtitleFixOutcome{}, fmt.Errorf("store the fixed subtitle: %w", err) } outcome.StoredID = stored.ID return outcome, nil } // referenceTrack picks and reads the yardstick. // // Reading is the expensive half — an embedded track is an Emby request each — so the // candidates are read lazily in the order subsync.Reference would rank them, and the first // one that parses wins. A track that will not parse is simply not a reference; failing the // whole fix because the third-choice yardstick is malformed would be perverse. func (s *Server) referenceTrack( ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string, target playableSubtitle, tracks []playableSubtitle, ) ([]subsync.Cue, playableSubtitle, error) { candidates := referenceCandidates(target, tracks) if len(candidates) == 0 { return nil, playableSubtitle{}, &subsync.ErrNoAlignment{ Reason: "there is no other subtitle on this title to check the timing against", } } for _, candidate := range candidates { raw, err := s.subtitleContent(ctx, cred, itemID, mediaSourceID, candidate) if err != nil { s.loggerFor(ctx).Debug("reference subtitle unreadable", "item_id", itemID, "subtitle", candidate.ID, "error", err) continue } cues, err := subsync.Parse(raw) if err != nil || len(cues) < subsync.DefaultOptions().MinCues { continue } return cues, candidate, nil } return nil, playableSubtitle{}, &subsync.ErrNoAlignment{ Reason: "none of the other subtitles on this title could be read as a timing reference", } } // referenceCandidates orders the tracks worth measuring against, best first. // // The rules are about what makes a usable yardstick rather than a good subtitle. A forced // track carries only what is foreign to the film's own audio, so it is mostly silence and // would agree with almost any shift — it is excluded rather than ranked last. A track the // gateway already fixed is preferred, since it has been checked against something. After // that, a track in the same language is the closest match in line breaks and therefore in // timing, and an embedded track outranks a downloaded one because it shipped with this // copy of the film. func referenceCandidates(target playableSubtitle, tracks []playableSubtitle) []playableSubtitle { out := make([]playableSubtitle, 0, len(tracks)) for _, track := range tracks { if track.ID == target.ID || track.IsForced { continue } if !isStoredSubtitleID(track.ID) && track.URL == "" { // An embedded track Emby will not deliver as text: it is burned in or needs a // transcode, and there is nothing to read. continue } out = append(out, track) } rank := func(track playableSubtitle) int { switch { case strings.HasSuffix(track.ID, ":"+subtitleFixSuffix): return 0 case target.Language != "" && strings.EqualFold(track.Language, target.Language): return 1 case !isStoredSubtitleID(track.ID): return 2 default: return 3 } } // A stable sort, so tracks of equal rank keep the order Emby listed them in — which // puts the default track first, and the default is usually the one that is right. sort.SliceStable(out, func(i, j int) bool { return rank(out[i]) < rank(out[j]) }) return out } // subtitleContent reads a track from wherever it lives: the gateway's own table for one it // fetched, Emby for one that came with the film. func (s *Server) subtitleContent( ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string, track playableSubtitle, ) ([]byte, error) { if isStoredSubtitleID(track.ID) { stored, err := s.store.DownloadedSubtitle(ctx, track.ID) if err != nil { return nil, err } return stored.Content, nil } index, err := strconv.Atoi(track.ID) if err != nil { return nil, fmt.Errorf("subtitle id %q is not an Emby stream index", track.ID) } return s.emby.SubtitleBytes(ctx, cred, itemID, mediaSourceID, index) } func trackByID(tracks []playableSubtitle, id string) (playableSubtitle, bool) { for _, track := range tracks { if track.ID == id { return track, true } } return playableSubtitle{}, false } // fixedSubtitleID keeps the repaired copy in the gateway's own namespace and distinct from // a downloaded file for the same language, so fixing a subtitle never overwrites the one it // was made from — a correction can be wrong, and the original has to still be there. func fixedSubtitleID(itemID string, target playableSubtitle) string { language := target.Language if language == "" { language = "und" } return strings.Join([]string{ "gw", itemID, language, subtitleFixSuffix, sanitiseIDPart(target.ID), }, ":") } // sanitiseIDPart keeps a source track's id usable inside another id. A stored track's own // id already contains colons, and nesting them would make the parts ambiguous. func sanitiseIDPart(id string) string { return strings.ReplaceAll(strings.TrimPrefix(id, storedSubtitleIDPrefix), ":", "-") } func fixedSubtitleLabel(target playableSubtitle) string { label := strings.TrimSpace(target.Label) if label == "" { label = subtitleLanguageLabel(target.Language) } // Named rather than silently substituted: this track sits in the menu beside the one // it was made from, and the two are otherwise indistinguishable. return label + " (timing fixed)" } func subtitleTrackName(track playableSubtitle) string { if label := strings.TrimSpace(track.Label); label != "" { return label } if language := subtitleLanguageLabel(track.Language); language != "" { return language } return "another subtitle" } // subtitleFixFailureMessage is the whole diagnosis. A television has no log and no support // channel, so the sentence has to say what happened and whether pressing again would help. func subtitleFixFailureMessage(err error) string { switch { case errors.Is(err, subsync.ErrNoCues): return "That subtitle could not be read, so its timing cannot be fixed." case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): return "Fixing the timing took too long. Try again." default: return "The timing could not be fixed just now." } }