diff --git a/internal/activitylog/middleware.go b/internal/activitylog/middleware.go index fa9402c3..26c6674a 100644 --- a/internal/activitylog/middleware.go +++ b/internal/activitylog/middleware.go @@ -214,3 +214,12 @@ func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +// Flush implements http.Flusher directly as well: callers (and middlewares +// like chi's Compress) discover flushing via a plain type assertion, which +// Unwrap alone does not satisfy. +func (w *statusWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 69f6b427..4d2c657f 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -338,6 +338,7 @@ type startPlaybackRequest struct { HDR bool `json:"hdr"` HdrDetails *hdrDetails `json:"hdr_details,omitempty"` AudioPassthrough *audioPassthroughCapabilities `json:"audio_passthrough,omitempty"` + SupportsBitmapSubtitleBurnIn bool `json:"supports_bitmap_subtitle_burn_in,omitempty"` } // progressRequest represents the JSON body for POST /playback/{session_id}/progress. @@ -372,6 +373,7 @@ type playbackInfoResult struct { // subtitleURL represents a subtitle track URL in a playback response. type subtitleURL struct { Index int `json:"index"` + MediaFileID int `json:"media_file_id,omitempty"` Language string `json:"language"` Codec string `json:"codec,omitempty"` Label string `json:"label"` @@ -398,15 +400,16 @@ type changeAudioResponse struct { } type transcodeStartRequest struct { - SessionID string `json:"session_id"` - SeekSeconds float64 `json:"seek_seconds"` - TargetResolution string `json:"target_resolution"` - TargetCodecVideo string `json:"target_codec_video"` - TargetCodecAudio string `json:"target_codec_audio"` - TargetBitrateKbps int `json:"target_bitrate_kbps"` - SegmentDuration int `json:"segment_duration"` - SubtitleTrackIndex int `json:"subtitle_track_index"` - SubtitleBurnIn bool `json:"subtitle_burn_in"` + SessionID string `json:"session_id"` + SeekSeconds float64 `json:"seek_seconds"` + TargetResolution string `json:"target_resolution"` + TargetCodecVideo string `json:"target_codec_video"` + TargetCodecAudio string `json:"target_codec_audio"` + TargetBitrateKbps int `json:"target_bitrate_kbps"` + SegmentDuration int `json:"segment_duration"` + SubtitleTrackIndex int `json:"subtitle_track_index"` + SubtitleMediaFileID int `json:"subtitle_media_file_id,omitempty"` + SubtitleBurnIn bool `json:"subtitle_burn_in"` } type transcodeStartResponse struct { @@ -866,7 +869,20 @@ func (h *PlaybackHandler) resolveCapabilityPlaybackSelection( alt, err := h.findAlternateFile(ctx, requestedFile) if err == nil && alt != nil { effectiveFile := h.ensurePlaybackProbe(ctx, alt) - audioTrackIndex = normalizeAudioTrackIndex(effectiveFile, audioTrackIndex) + effectiveAudioTrackIndex := playback.MatchAudioTrackAcrossVersions( + requestedFile.AudioTracks, + effectiveFile.AudioTracks, + audioTrackIndex, + ) + if effectiveAudioTrackIndex != audioTrackIndex { + slog.InfoContext(ctx, "remapped audio track for alternate file", + "requested_file_id", requestedFile.ID, + "effective_file_id", effectiveFile.ID, + "requested_audio_track_index", audioTrackIndex, + "effective_audio_track_index", effectiveAudioTrackIndex, + ) + } + audioTrackIndex = effectiveAudioTrackIndex method, transcodeAudio = resolvePlaybackMethodForFile(effectiveFile, req, audioTrackIndex, adminSettings) return effectiveFile, method, transcodeAudio, audioTrackIndex } @@ -1587,7 +1603,12 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req if h.SubtitleRepo != nil && effectiveFile != nil { downloadedSubs, _ = h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), effectiveFile.ID) } - resp.SubtitleURLs = buildSubtitleURLs(session.ID, effectiveFile, downloadedSubs) + resp.SubtitleURLs = buildSubtitleURLs( + session.ID, + effectiveFile, + downloadedSubs, + req.SupportsBitmapSubtitleBurnIn, + ) // If stream nodes are available, generate proxy-based stream URLs. // Remux and transcode both use HLS via a transcode node, so the planner @@ -1667,8 +1688,8 @@ func playbackClientInfoFromRequest(r *http.Request) playback.ClientInfo { // subtitleURLExt returns the URL file extension for a subtitle codec. // ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for -// client-side rendering (JASSUB); PGS tracks get ".sup" for client-side -// bitmap rendering (libpgs); all other text formats get ".vtt". +// client-side rendering (JASSUB); PGS tracks get ".sup" for native clients +// capable of rendering bitmap sidecars; all other text formats get ".vtt". func subtitleURLExt(codec string) string { switch { case playback.IsASS(codec): @@ -1679,7 +1700,12 @@ func subtitleURLExt(codec string) string { return ".vtt" } -func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []subtitles.DownloadedSubtitle) []subtitleURL { +func buildSubtitleURLs( + sessionID string, + file *models.MediaFile, + downloaded []subtitles.DownloadedSubtitle, + includeBurnInOnly bool, +) []subtitleURL { if file == nil { return nil } @@ -1689,35 +1715,38 @@ func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []su for i, sub := range file.ExternalSubtitles { urls = append(urls, subtitleURL{ Index: i, + MediaFileID: file.ID, Language: sub.Language, Codec: sub.Format, Label: firstNonEmptyString(sub.Title, sub.EmbeddedTitle, filepath.Base(sub.Path), sub.Language), Source: "external", Forced: sub.Forced, HearingImpaired: sub.HearingImpaired, - URL: fmt.Sprintf("/stream/%s/subtitles/%d%s", sessionID, i, subtitleURLExt(sub.Format)), + URL: subtitleStreamURL(sessionID, i, sub.Format, file.ID), }) } embeddedOffset := len(file.ExternalSubtitles) for i, track := range file.SubtitleTracks { - // PGS bitmap tracks are deliverable as .sup streams for - // client-side rendering; DVD/DVB bitmap tracks still have no - // non-burn-in delivery path, so they stay hidden. - if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) { + // PGS remains universally deliverable as a .sup sidecar. DVD/DVB + // bitmap tracks have no usable sidecar representation, so advertise + // them only to clients that explicitly declare server-side burn-in + // support. Older Apple/Android clients otherwise expose a text URL + // that ffmpeg cannot serve. + if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) && !includeBurnInOnly { continue } - urls = append(urls, subtitleURL{ Index: embeddedOffset + i, + MediaFileID: file.ID, Language: track.Language, Codec: track.Codec, Label: firstNonEmptyString(track.Title, track.EmbeddedTitle, track.Language), Source: "embedded", Forced: track.Forced, HearingImpaired: track.HearingImpaired, - URL: fmt.Sprintf("/stream/%s/subtitles/%d%s", sessionID, embeddedOffset+i, subtitleURLExt(track.Codec)), - FontBundleURL: subtitleFontBundleURL(sessionID, embeddedOffset+i, track.Codec), + URL: subtitleStreamURL(sessionID, embeddedOffset+i, track.Codec, file.ID), + FontBundleURL: subtitleFontBundleURL(sessionID, embeddedOffset+i, track.Codec, file.ID), }) } @@ -1725,23 +1754,28 @@ func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []su for i, dl := range downloaded { urls = append(urls, subtitleURL{ Index: downloadedOffset + i, + MediaFileID: file.ID, Language: dl.Language, Codec: string(dl.Format), Label: dl.ReleaseName + " (" + dl.Provider + ")", Source: "downloaded", HearingImpaired: dl.HearingImpaired, - URL: fmt.Sprintf("/stream/%s/subtitles/%d%s", sessionID, downloadedOffset+i, subtitleURLExt(string(dl.Format))), + URL: subtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID), }) } return urls } -func subtitleFontBundleURL(sessionID string, trackIndex int, codec string) string { +func subtitleStreamURL(sessionID string, trackIndex int, codec string, fileID int) string { + return fmt.Sprintf("/stream/%s/subtitles/%d%s?file_id=%d", sessionID, trackIndex, subtitleURLExt(codec), fileID) +} + +func subtitleFontBundleURL(sessionID string, trackIndex int, codec string, fileID int) string { if !playback.IsASS(codec) { return "" } - return fmt.Sprintf("/stream/%s/subtitles/%d/fonts", sessionID, trackIndex) + return fmt.Sprintf("/stream/%s/subtitles/%d/fonts?file_id=%d", sessionID, trackIndex, fileID) } func firstNonEmptyString(values ...string) string { @@ -1986,8 +2020,9 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. if session.PlayMethod == playback.PlayTranscode { if ts := h.tm.GetTranscodeSession(sessionID); ts != nil { ts.SetAudioTrackIndex(req.AudioTrackIndex) - seekSeconds := req.Position - startSegment := computeStartSegment(seekSeconds, ts.Opts().SegmentDuration) + tsOpts := ts.Opts() + startSegment := computeStartSegment(req.Position, tsOpts.SegmentDuration) + seekSeconds := alignedSeekSeconds(req.Position, tsOpts.SegmentDuration, tsOpts.TargetCodecVideo) // Throttler + exit monitor re-arm via the session's restart hook. if restartErr := h.tm.RestartSessionLocked(context.WithoutCancel(r.Context()), sessionID, ts, seekSeconds, startSegment); restartErr != nil { slog.ErrorContext(r.Context(), "failed to restart transcode for audio switch", "component", "api", "session", sessionID, "error", restartErr) @@ -2061,7 +2096,6 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. nodeURL := plan.TranscodeNode.URL _ = h.sessionMgr.SetTranscodeNodeURL(sessionID, nodeURL) - seekSeconds := req.Position // Restart from the FULL live recipe, not a partial re-derivation. // An audio switch alters only audio selection — subtitle burn-in and // the segment cadence must be preserved, or the node re-encodes a @@ -2075,8 +2109,13 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. if segmentDuration <= 0 { segmentDuration = playback.DefaultSegmentDuration } + seekSeconds := alignedSeekSeconds(req.Position, segmentDuration, updatedSession.TargetVideoCodec) subtitleTrackIndex := session.SubtitleTrackIndex subtitleBurnIn := session.SubtitleBurnIn + subtitleCodec := "" + if subtitleBurnIn && subtitleTrackIndex >= 0 { + subtitleCodec = embeddedSubtitleCodec(file, subtitleTrackIndex) + } startSegment := computeStartSegment(seekSeconds, segmentDuration) // Derive the encode recipe the same way HandleStartTranscode @@ -2099,6 +2138,7 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: subtitleTrackIndex, SubtitleBurnIn: subtitleBurnIn, + SubtitleCodec: subtitleCodec, TotalDuration: float64(file.Duration), } if strings.TrimSpace(nodeReq.HWAccel) == "" { @@ -2154,6 +2194,7 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. AudioTrackIndex: nodeReq.AudioTrackIndex, SubtitleTrackIndex: nodeReq.SubtitleTrackIndex, SubtitleBurnIn: nodeReq.SubtitleBurnIn, + SubtitleCodec: nodeReq.SubtitleCodec, TotalDuration: nodeReq.TotalDuration, }) resp.StreamURL = h.buildProxyManifestURL(card, proxyNode) @@ -2254,6 +2295,49 @@ func (h *PlaybackHandler) loadAuthorizedFile(r *http.Request, fileID int) (*mode return file, nil } +// embeddedSubtitleCodec returns the probed codec of the embedded subtitle +// track at the given ffmpeg-relative subtitle ordinal (the same index the +// subtitles=si=N / [0:s:N] filters use), or "" when out of range. +func embeddedSubtitleCodec(file *models.MediaFile, ffmpegSubtitleIndex int) string { + if file == nil || ffmpegSubtitleIndex < 0 || ffmpegSubtitleIndex >= len(file.SubtitleTracks) { + return "" + } + return file.SubtitleTracks[ffmpegSubtitleIndex].Codec +} + +// resolveBurnInSubtitle maps a subtitle selection made against requestedFile +// onto effectiveFile. The 4K guard may replace the requested file with a +// lower-resolution version whose subtitle streams have a different order; a +// raw ordinal carried across that switch can burn the wrong language. +func resolveBurnInSubtitle(requestedFile, effectiveFile *models.MediaFile, requestedIndex int) (int, string, bool) { + if requestedFile == nil || effectiveFile == nil || requestedIndex < 0 || requestedIndex >= len(requestedFile.SubtitleTracks) { + return -1, "", false + } + if requestedFile.ID == effectiveFile.ID { + track := effectiveFile.SubtitleTracks[requestedIndex] + return requestedIndex, track.Codec, true + } + + selected := requestedFile.SubtitleTracks[requestedIndex] + for i, candidate := range effectiveFile.SubtitleTracks { + if subtitleTracksMatch(selected, candidate) { + return i, candidate.Codec, true + } + } + return -1, "", false +} + +func subtitleTracksMatch(a, b models.SubtitleTrack) bool { + return strings.EqualFold(strings.TrimSpace(a.Language), strings.TrimSpace(b.Language)) && + strings.EqualFold(strings.TrimSpace(a.Codec), strings.TrimSpace(b.Codec)) && + strings.EqualFold( + strings.TrimSpace(firstNonEmptyString(a.Title, a.EmbeddedTitle)), + strings.TrimSpace(firstNonEmptyString(b.Title, b.EmbeddedTitle)), + ) && + a.Forced == b.Forced && + a.HearingImpaired == b.HearingImpaired +} + // computeStartSegment returns the HLS segment number corresponding to a seek // position given the segment duration. Both remote and local transcode paths // use this to align ffmpeg output filenames with the VOD manifest. @@ -2267,6 +2351,25 @@ func computeStartSegment(seekSeconds float64, segmentDuration int) int { return int(seekSeconds / float64(segmentDuration)) } +// alignedSeekSeconds snaps an encoded transcode's ffmpeg start position down +// to the boundary of the segment computeStartSegment assigns it. The synthetic +// VOD manifest declares segment N to begin at exactly N×segmentDuration; +// spawning ffmpeg at the raw seek position makes segment N actually begin up +// to one segment later, and hls.js aligns that content to the declared +// position — shifting the session's entire timeline (audio, video, and every +// out-of-band subtitle cue) late by seek mod segmentDuration. Copy-mode +// sessions serve ffmpeg's real manifest, whose declared timings match the +// fragments it produces, so they keep the raw seek. +func alignedSeekSeconds(seekSeconds float64, segmentDuration int, targetVideoCodec string) float64 { + if strings.EqualFold(targetVideoCodec, "copy") || seekSeconds <= 0 { + return seekSeconds + } + if segmentDuration <= 0 { + segmentDuration = 2 + } + return float64(computeStartSegment(seekSeconds, segmentDuration) * segmentDuration) +} + // transcodeStartState holds the common parameters needed to finalize a // transcode start (update session state, log, and sync) for both remote // and local paths. @@ -2406,6 +2509,38 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re } file = h.ensurePlaybackProbe(r.Context(), file) requestedFile := file + if originalFileID := requestedMediaFileID(session); originalFileID > 0 && originalFileID != file.ID { + originalFile, loadErr := h.loadFileByPreferredID(r.Context(), originalFileID, 0) + if loadErr != nil || originalFile == nil { + requestedFile = nil + } else { + requestedFile = h.ensurePlaybackProbe(r.Context(), originalFile) + } + } + + // Subtitle ordinals are meaningful only within the file inventory that + // produced them. New clients echo the media_file_id advertised beside the + // selected subtitle URL. Clients that omit it retain the legacy behavior of + // selecting against RequestedMediaFileID so existing restart flows continue + // to remap original-file ordinals after the 4K guard switches versions. + subtitleSourceFile := requestedFile + if req.SubtitleBurnIn && req.SubtitleTrackIndex >= 0 { + switch { + case req.SubtitleMediaFileID <= 0: + // Legacy request: requestedFile is the historical source inventory. + case file != nil && req.SubtitleMediaFileID == file.ID: + subtitleSourceFile = file + case requestedFile != nil && req.SubtitleMediaFileID == requestedFile.ID: + subtitleSourceFile = requestedFile + default: + subtitleSourceFile = nil + } + if subtitleSourceFile == nil { + writeError(w, http.StatusUnprocessableEntity, "subtitle_source_unavailable", + "Media file inventory for the selected subtitle is unavailable") + return + } + } // Resume and seek-start requests generally cannot safely stream-copy video // into HLS output. Arbitrary HEVC seek points often land on non-keyframes, @@ -2423,6 +2558,21 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re req.TargetCodecVideo = "h264" } + // Subtitle burn-in composites subtitles into the video frames, which is + // impossible with -c:v copy. If the requested recipe would stream-copy + // video (e.g. a remux "original" restart that adds burn-in), force an + // encoding transcode so the burned frames are actually produced instead of + // the subtitle selection being silently dropped by the filter stage. + if req.SubtitleBurnIn && req.SubtitleTrackIndex >= 0 && strings.EqualFold(req.TargetCodecVideo, "copy") { + slog.Info("forcing video transcode for subtitle burn-in request", + "playback_session_id", req.SessionID, + "subtitle_track_index", req.SubtitleTrackIndex, + "requested_target_codec_video", req.TargetCodecVideo, + "effective_target_codec_video", "h264", + ) + req.TargetCodecVideo = "h264" + } + // 4K transcode guard: if source is 4K and allow_4k_transcode is disabled, // switch to an alternate non-4K file version for transcoding. // Skip the guard when target_codec_video is "copy" — no actual video @@ -2460,6 +2610,31 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re return } + // Resolve the burn-in track's probed codec so the ffmpeg arg builder can + // route bitmap codecs (PGS/DVD/DVB) to the overlay filter_complex pipeline + // instead of the text-only libass subtitles filter. Derived server-side + // from the effective file rather than trusted from the client. + subtitleCodec := "" + if req.SubtitleBurnIn && req.SubtitleTrackIndex >= 0 { + resolvedIndex, resolvedCodec, ok := resolveBurnInSubtitle(subtitleSourceFile, file, req.SubtitleTrackIndex) + if !ok { + writeError(w, http.StatusUnprocessableEntity, "subtitle_unavailable_in_version", + "Selected subtitle track is unavailable in the effective file version") + return + } + if resolvedIndex != req.SubtitleTrackIndex { + slog.Info("remapped subtitle burn-in track for alternate file", + "playback_session_id", req.SessionID, + "subtitle_source_file_id", subtitleSourceFile.ID, + "effective_file_id", file.ID, + "requested_subtitle_track_index", req.SubtitleTrackIndex, + "effective_subtitle_track_index", resolvedIndex, + ) + } + req.SubtitleTrackIndex = resolvedIndex + subtitleCodec = resolvedCodec + } + // Determine whether to run locally or forward to a remote transcode node. var plan nodepool.Plan if h.NodePlanner != nil { @@ -2481,7 +2656,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re SessionID: req.SessionID, InputPath: file.FilePath, SourceVideoCodec: file.CodecVideo, - SeekSeconds: req.SeekSeconds, + SeekSeconds: alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo), StartSegmentNumber: computeStartSegment(req.SeekSeconds, req.SegmentDuration), TargetResolution: req.TargetResolution, TargetCodecVideo: req.TargetCodecVideo, @@ -2492,6 +2667,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re AudioTrackIndex: session.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, + SubtitleCodec: subtitleCodec, TotalDuration: float64(file.Duration), } @@ -2553,6 +2729,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re AudioTrackIndex: nodeReq.AudioTrackIndex, SubtitleTrackIndex: nodeReq.SubtitleTrackIndex, SubtitleBurnIn: nodeReq.SubtitleBurnIn, + SubtitleCodec: nodeReq.SubtitleCodec, TotalDuration: nodeReq.TotalDuration, }) manifestURL := h.buildProxyManifestURL(card, plan.ProxyNode) @@ -2595,7 +2772,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID), SessionID: req.SessionID, SourceVideoCodec: file.CodecVideo, - SeekSeconds: req.SeekSeconds, + SeekSeconds: alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo), StartSegmentNumber: computeStartSegment(req.SeekSeconds, req.SegmentDuration), TargetResolution: req.TargetResolution, TargetCodecVideo: req.TargetCodecVideo, @@ -2608,6 +2785,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re AudioTrackIndex: session.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, + SubtitleCodec: subtitleCodec, TotalDuration: float64(file.Duration), FastStart: true, NodeType: "integrated", diff --git a/internal/api/handlers/playback_subtitle_urls_test.go b/internal/api/handlers/playback_subtitle_urls_test.go index b28c21a4..a338abe3 100644 --- a/internal/api/handlers/playback_subtitle_urls_test.go +++ b/internal/api/handlers/playback_subtitle_urls_test.go @@ -26,8 +26,9 @@ func TestSubtitleURLExt(t *testing.T) { } } -func TestBuildSubtitleURLs_IncludesPGSButNotOtherBitmaps(t *testing.T) { +func TestBuildSubtitleURLs_IncludesAllBitmapTracksForBurnInClients(t *testing.T) { file := &models.MediaFile{ + ID: 42, SubtitleTracks: []models.SubtitleTrack{ {Index: 0, Language: "en", Codec: "subrip"}, {Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"}, @@ -36,31 +37,61 @@ func TestBuildSubtitleURLs_IncludesPGSButNotOtherBitmaps(t *testing.T) { }, } - urls := buildSubtitleURLs("sess-1", file, nil) + urls := buildSubtitleURLs("sess-1", file, nil, true) - if len(urls) != 2 { - t.Fatalf("expected 2 subtitle URLs (text + PGS), got %d: %+v", len(urls), urls) + // Every bitmap track is deliverable now that server-side burn-in supports + // bitmap codecs; PGS additionally streams as .sup for client rendering. + if len(urls) != 4 { + t.Fatalf("expected 4 subtitle URLs (text + PGS + DVD + DVB), got %d: %+v", len(urls), urls) } srt := urls[0] - if srt.Codec != "subrip" || srt.URL != "/stream/sess-1/subtitles/0.vtt" { + if srt.MediaFileID != file.ID || srt.Codec != "subrip" || srt.URL != "/stream/sess-1/subtitles/0.vtt?file_id=42" { t.Errorf("unexpected text track entry: %+v", srt) } pgs := urls[1] - if pgs.Codec != "hdmv_pgs_subtitle" { + if pgs.MediaFileID != file.ID || pgs.Codec != "hdmv_pgs_subtitle" { t.Errorf("expected PGS track to be included, got %+v", pgs) } - if pgs.URL != "/stream/sess-1/subtitles/1.sup" { + if pgs.URL != "/stream/sess-1/subtitles/1.sup?file_id=42" { t.Errorf("PGS track should get a .sup URL, got %q", pgs.URL) } if pgs.FontBundleURL != "" { t.Errorf("PGS track must not advertise a font bundle, got %q", pgs.FontBundleURL) } + + if dvd := urls[2]; dvd.Codec != "dvd_subtitle" || dvd.Index != 2 { + t.Errorf("expected DVD bitmap track to be listed for burn-in selection, got %+v", dvd) + } + if dvb := urls[3]; dvb.Codec != "dvb_subtitle" || dvb.Index != 3 { + t.Errorf("expected DVB bitmap track to be listed for burn-in selection, got %+v", dvb) + } +} + +func TestBuildSubtitleURLs_HidesBurnInOnlyTracksByDefault(t *testing.T) { + file := &models.MediaFile{ + ID: 43, + SubtitleTracks: []models.SubtitleTrack{ + {Index: 0, Language: "en", Codec: "subrip"}, + {Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"}, + {Index: 2, Language: "fr", Codec: "dvd_subtitle"}, + {Index: 3, Language: "de", Codec: "dvb_subtitle"}, + }, + } + + urls := buildSubtitleURLs("sess-native", file, nil, false) + if len(urls) != 2 { + t.Fatalf("expected text and PGS only, got %d: %+v", len(urls), urls) + } + if urls[0].Codec != "subrip" || urls[1].Codec != "hdmv_pgs_subtitle" { + t.Fatalf("unexpected default subtitle URLs: %+v", urls) + } } func TestBuildSubtitleURLs_PGSIndexAccountsForExternalOffset(t *testing.T) { file := &models.MediaFile{ + ID: 44, ExternalSubtitles: []models.ExternalSubtitle{ {Path: "/media/movie.en.srt", Language: "en", Format: "srt"}, }, @@ -69,13 +100,49 @@ func TestBuildSubtitleURLs_PGSIndexAccountsForExternalOffset(t *testing.T) { }, } - urls := buildSubtitleURLs("sess-2", file, nil) + urls := buildSubtitleURLs("sess-2", file, nil, false) if len(urls) != 2 { t.Fatalf("expected 2 subtitle URLs, got %d: %+v", len(urls), urls) } pgs := urls[1] - if pgs.Index != 1 || pgs.URL != "/stream/sess-2/subtitles/1.sup" { + if pgs.Index != 1 || pgs.URL != "/stream/sess-2/subtitles/1.sup?file_id=44" { t.Errorf("PGS track index should include the external offset, got %+v", pgs) } } + +func TestResolveBurnInSubtitle_RemapsAlternateVersionByStableTraits(t *testing.T) { + requested := &models.MediaFile{ + ID: 1, + SubtitleTracks: []models.SubtitleTrack{ + {Language: "en", Codec: "subrip", Title: "English"}, + {Language: "ja", Codec: "hdmv_pgs_subtitle", Title: "Japanese", Forced: true}, + }, + } + effective := &models.MediaFile{ + ID: 2, + SubtitleTracks: []models.SubtitleTrack{ + {Language: "ja", Codec: "hdmv_pgs_subtitle", Title: "Japanese", Forced: true}, + {Language: "en", Codec: "subrip", Title: "English"}, + }, + } + + index, codec, ok := resolveBurnInSubtitle(requested, effective, 1) + if !ok || index != 0 || codec != "hdmv_pgs_subtitle" { + t.Fatalf("resolveBurnInSubtitle() = (%d, %q, %v), want (0, hdmv_pgs_subtitle, true)", index, codec, ok) + } +} + +func TestResolveBurnInSubtitle_RejectsMissingAlternateTrack(t *testing.T) { + requested := &models.MediaFile{ + ID: 1, + SubtitleTracks: []models.SubtitleTrack{ + {Language: "ja", Codec: "hdmv_pgs_subtitle", Title: "Japanese"}, + }, + } + effective := &models.MediaFile{ID: 2} + + if index, codec, ok := resolveBurnInSubtitle(requested, effective, 0); ok || index != -1 || codec != "" { + t.Fatalf("resolveBurnInSubtitle() = (%d, %q, %v), want unavailable", index, codec, ok) + } +} diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go index 69d9881d..7bcf5e9e 100644 --- a/internal/api/handlers/playback_test.go +++ b/internal/api/handlers/playback_test.go @@ -1258,7 +1258,7 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t changeReq := httptest.NewRequest( http.MethodPatch, "/api/v1/playback/"+sessionID+"/audio", - strings.NewReader(`{"audio_track_index":1,"position":120}`), + strings.NewReader(`{"audio_track_index":1,"position":121.5}`), ) changeReq = changeReq.WithContext(newAuthorizedPlaybackContext()) changeReq = withPlaybackRouteParam(changeReq, "session_id", sessionID) @@ -1279,10 +1279,13 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t if remoteStartReq.SessionID != sessionID { t.Fatalf("remote SessionID = %q, want %q", remoteStartReq.SessionID, sessionID) } - // Seek 120s with the node-default 2s segments => start segment 60. + // Seek 121.5s with the node-default 2s segments => align to 120s / segment 60. if remoteStartReq.StartSegmentNumber != 60 { t.Fatalf("remote StartSegmentNumber = %d, want 60", remoteStartReq.StartSegmentNumber) } + if remoteStartReq.SeekSeconds != 120 { + t.Fatalf("remote SeekSeconds = %v, want aligned 120", remoteStartReq.SeekSeconds) + } if remoteStartReq.TargetResolution != "720p" || remoteStartReq.TargetCodecVideo != "h264" { t.Fatalf("remote target recipe = %q/%q, want 720p/h264", remoteStartReq.TargetResolution, remoteStartReq.TargetCodecVideo) } @@ -1320,7 +1323,7 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t t.Fatalf("token TargetBitrateKbps = %d, want 2000 (recipe-complete)", claims.TargetBitrateKbps) } if claims.SeekSeconds != 120 { - t.Fatalf("token SeekSeconds = %v, want 120", claims.SeekSeconds) + t.Fatalf("token SeekSeconds = %v, want aligned 120", claims.SeekSeconds) } if claims.StartSegmentNumber != 60 { t.Fatalf("token StartSegmentNumber = %d, want 60", claims.StartSegmentNumber) @@ -1522,6 +1525,198 @@ func TestHandleStartTranscode_MPEG2SeekedCopyRemainsCopyVideo(t *testing.T) { } } +func TestHandleStartTranscode_BitmapBurnInForcesEncodeAndResolvesCodec(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + filePath := writePlaybackTestMediaFile(t, "movie-pgs.mkv") + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: filePath, + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "aac", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "aac", Default: true}, + }, + SubtitleTracks: []models.SubtitleTrack{ + {Index: 0, Language: "en", Codec: "subrip"}, + {Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"}, + }, + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) + + // A remux "original" restart that adds PGS burn-in still asks for codec + // copy — the server must force an encoding transcode and resolve the + // track's bitmap codec for the ffmpeg arg builder. + transcodeReq := httptest.NewRequest( + "POST", + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":125.0,"target_resolution":"","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":1,"subtitle_burn_in":true}`), + ) + transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) + + transcodeRR := httptest.NewRecorder() + handler.HandleStartTranscode(transcodeRR, transcodeReq) + if transcodeRR.Code != 202 { + t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) + } + + transcodeSession := handler.tm.GetTranscodeSession(session.ID) + if transcodeSession == nil { + t.Fatal("expected local transcode session") + } + t.Cleanup(func() { + _ = transcodeSession.Close() + }) + opts := transcodeSession.Opts() + if got := opts.TargetCodecVideo; got != "h264" { + t.Fatalf("burn-in target video codec = %q, want h264 (encode forced)", got) + } + if !opts.SubtitleBurnIn || opts.SubtitleTrackIndex != 1 { + t.Fatalf("burn-in selection lost: burnIn=%v index=%d", opts.SubtitleBurnIn, opts.SubtitleTrackIndex) + } + if got := opts.SubtitleCodec; got != "hdmv_pgs_subtitle" { + t.Fatalf("burn-in subtitle codec = %q, want hdmv_pgs_subtitle", got) + } + // Encode-forced restarts must snap the ffmpeg start to the segment + // boundary (timeline alignment contract). + if got := opts.SeekSeconds; got != 124.0 { + t.Fatalf("burn-in aligned seek = %v, want 124.0 (125.0 snapped to the segment boundary)", got) + } +} + +func TestHandleStartTranscode_BitmapRestartUsesDeclaredSubtitleInventory(t *testing.T) { + requested := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-4k.mkv"), + Resolution: "2160p", + CodecVideo: "hevc", + CodecAudio: "aac", + Container: "mkv", + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "aac", Default: true}, + }, + SubtitleTracks: []models.SubtitleTrack{ + {Index: 0, Language: "ja", Codec: "hdmv_pgs_subtitle", Title: "Japanese"}, + {Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle", Title: "English"}, + }, + } + effective := &models.MediaFile{ + ID: 99, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-1080p.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "aac", + Container: "mkv", + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "aac", Default: true}, + }, + SubtitleTracks: []models.SubtitleTrack{ + {Index: 0, Language: "en", Codec: "hdmv_pgs_subtitle", Title: "English"}, + {Index: 1, Language: "ja", Codec: "hdmv_pgs_subtitle", Title: "Japanese"}, + }, + } + tests := []struct { + name string + subtitleTrackIndex int + subtitleMediaFileID int + }{ + { + name: "legacy request uses original inventory", + subtitleTrackIndex: 1, + }, + { + name: "declared original inventory remaps to effective file", + subtitleTrackIndex: 1, + subtitleMediaFileID: requested.ID, + }, + { + name: "declared effective inventory keeps effective ordinal", + subtitleTrackIndex: 0, + subtitleMediaFileID: effective.ID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + session, err := sessionMgr.StartSessionWithFiles( + 1, + "profile-1", + effective.ID, + requested.ID, + playback.PlayRemux, + true, + ) + if err != nil { + t.Fatalf("StartSessionWithFiles: %v", err) + } + + handler := NewPlaybackHandler(sessionMgr, mapPlaybackFileResolver{files: map[int]*models.MediaFile{ + requested.ID: requested, + effective.ID: effective, + }}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) + + body := map[string]any{ + "session_id": session.ID, + "seek_seconds": 120, + "target_resolution": "720p", + "target_codec_video": "h264", + "target_codec_audio": "aac", + "target_bitrate_kbps": 2000, + "segment_duration": 2, + "subtitle_track_index": tt.subtitleTrackIndex, + "subtitle_burn_in": true, + } + if tt.subtitleMediaFileID > 0 { + body["subtitle_media_file_id"] = tt.subtitleMediaFileID + } + encodedBody, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal transcode request: %v", err) + } + transcodeReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(string(encodedBody)), + ) + transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) + + transcodeRR := httptest.NewRecorder() + handler.HandleStartTranscode(transcodeRR, transcodeReq) + if transcodeRR.Code != http.StatusAccepted { + t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) + } + + transcodeSession := handler.tm.GetTranscodeSession(session.ID) + if transcodeSession == nil { + t.Fatal("expected local transcode session") + } + t.Cleanup(func() { _ = transcodeSession.Close() }) + opts := transcodeSession.Opts() + if opts.SubtitleTrackIndex != 0 || opts.SubtitleCodec != "hdmv_pgs_subtitle" { + t.Fatalf("burn-in resolved to index=%d codec=%q, want English index 0 PGS", opts.SubtitleTrackIndex, opts.SubtitleCodec) + } + }) + } +} + func TestHandleStartPlayback_MarksMissingFileAndSkipsSessionCreation(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) marker := &recordingMissingMarker{} @@ -1742,3 +1937,31 @@ func TestFindAlternateFile_DoesNotCrossEdition(t *testing.T) { t.Fatalf("alternate.ID = %d, want 3", alternate.ID) } } + +func TestAlignedSeekSeconds(t *testing.T) { + tests := []struct { + name string + seek float64 + segDur int + targetVideo string + want float64 + }{ + // Encoded transcodes snap down to the declared segment boundary so the + // synthetic manifest's timeline matches the produced content exactly. + {"encoded mid-segment seek snaps down", 1158.673, 2, "h264", 1158}, + {"encoded boundary seek unchanged", 1158, 2, "h264", 1158}, + {"encoded zero seek unchanged", 0, 2, "h264", 0}, + {"segment duration defaults to 2", 1158.673, 0, "h264", 1158}, + // Copy-mode serves ffmpeg's real manifest; raw seek stands. + {"copy keeps raw seek", 1158.673, 2, "copy", 1158.673}, + {"copy case-insensitive", 1158.673, 2, "COPY", 1158.673}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := alignedSeekSeconds(tt.seek, tt.segDur, tt.targetVideo); got != tt.want { + t.Fatalf("alignedSeekSeconds(%v, %d, %q) = %v, want %v", + tt.seek, tt.segDur, tt.targetVideo, got, tt.want) + } + }) + } +} diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go index 536454e9..1e6b0e38 100644 --- a/internal/api/handlers/stream.go +++ b/internal/api/handlers/stream.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "strconv" + "strings" "github.com/go-chi/chi/v5" @@ -44,9 +45,13 @@ type StreamHandler struct { // PlaybackConfig returns the current playback config; read it through // ffmpegPath(). May be nil (tests). PlaybackConfig func() config.PlaybackConfig - SubtitleRepo subtitles.Repository // optional; enables S3-sourced subtitles - S3Client subtitles.S3Client // optional; needed for fetching S3 subtitles - S3Bucket string // bucket for subtitle storage + // SubtitleCache stores full-track PGS (.sup) extracts under the transcode + // dir so repeat selections skip the whole-file ffmpeg demux. May be nil + // (tests / minimal setups) — extraction then always streams uncached. + SubtitleCache *playback.SubtitleCache + SubtitleRepo subtitles.Repository // optional; enables S3-sourced subtitles + S3Client subtitles.S3Client // optional; needed for fetching S3 subtitles + S3Bucket string // bucket for subtitle storage } // ffmpegPath returns the currently configured ffmpeg binary path. @@ -203,8 +208,13 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) { return } - file, err := h.fileResolver.GetByID(r.Context(), session.MediaFileID) + fileID, err := subtitleSourceFileID(r, session) if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + file, err := h.fileResolver.GetByID(r.Context(), fileID) + if err != nil || file == nil { writeError(w, http.StatusNotFound, "not_found", "Media file not found") return } @@ -310,6 +320,29 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found") } +// subtitleSourceFileID pins a subtitle URL to the file whose track list was +// used to create it. A quality/seek restart may change session.MediaFileID to +// an alternate version; interpreting the old combined track index against the +// alternate file can silently serve a different language. Only the session's +// requested or current effective file may be named by the authenticated URL. +func subtitleSourceFileID(r *http.Request, session *playback.Session) (int, error) { + if session == nil { + return 0, errors.New("playback session is required") + } + raw := strings.TrimSpace(r.URL.Query().Get("file_id")) + if raw == "" { + return session.MediaFileID, nil + } + fileID, err := strconv.Atoi(raw) + if err != nil || fileID <= 0 { + return 0, errors.New("invalid subtitle source file") + } + if fileID != session.MediaFileID && fileID != session.RequestedMediaFileID { + return 0, errors.New("subtitle source file does not belong to playback session") + } + return fileID, nil +} + // HandleSubtitleFonts extracts embedded container font attachments for ASS/SSA // playback. The web player loads these bytes into JASSUB before creating the // renderer so libass can resolve script font names deterministically. @@ -337,7 +370,12 @@ func (h *StreamHandler) HandleSubtitleFonts(w http.ResponseWriter, r *http.Reque return } - file, err := h.fileResolver.GetByID(r.Context(), session.MediaFileID) + fileID, err := subtitleSourceFileID(r, session) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + file, err := h.fileResolver.GetByID(r.Context(), fileID) if err != nil { writeError(w, http.StatusNotFound, "not_found", "Media file not found") return @@ -460,16 +498,25 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re outFormat = "sup" } - // ASS and PGS are fetched exactly once and consumed whole by their - // client-side renderers (JASSUB / libpgs), so they must never be - // windowed. Note subtitleSeekPosition falls back to the session's - // last reported position even without a ?position= query — relying - // on StreamExtractSubtitle's codec guard alone would still log a + // ASS is fetched exactly once and consumed whole by its client-side + // renderer (JASSUB), so it must never be windowed. PGS defaults to + // the same whole-track behavior, but a client that manages its own + // sliding window (the web player's libpgs hook) opts in explicitly + // with ?windowed=1 + ?position=/?duration=; there is deliberately no + // session-position fallback for sup — an implicit window would + // silently drop cues for clients that fetch once. Note + // subtitleSeekPosition falls back to the session's last reported + // position even without a ?position= query — relying on + // StreamExtractSubtitle's codec guard alone would still log a // misleading nonzero seek here. var seek, duration float64 - if outFormat == "vtt" { + var allowWindow bool + switch outFormat { + case "vtt": seek = subtitleSeekPosition(r, session) duration = subtitleWindowDuration(r) + case "sup": + allowWindow, seek, duration = playback.PGSWindowRequest(r.URL.Query()) } slog.InfoContext(r.Context(), "subtitle stream requested", "component", "api", "file_id", file.ID, @@ -481,28 +528,41 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re "duration_seconds", duration, ) - switch outFormat { - case "ass": - w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8") - case "sup": - w.Header().Set("Content-Type", "application/octet-stream") - default: - w.Header().Set("Content-Type", "text/vtt; charset=utf-8") - } - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusOK) - - err := playback.StreamExtractSubtitle(r.Context(), playback.StreamExtractOpts{ + opts := playback.StreamExtractOpts{ InputPath: file.FilePath, TrackIndex: embeddedIndex, SourceCodec: track.Codec, SeekSeconds: seek, DurationSeconds: duration, + AllowWindow: allowWindow, FFmpegPath: h.ffmpegPath(), - Writer: w, - }) - if err != nil { + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Full-track PGS extracts are expensive (whole-file demux) and byte- + // identical across requests, so they are served from / teed into the + // subtitle cache; windowed PGS requests extract their slice from the + // cached full track when present (warming it in the background when + // not). All other formats stream uncached: VTT is already windowed + // and fast, ASS is small. + if outFormat == "sup" { + err := h.SubtitleCache.ServeSUPExtract(w, r, opts, playback.StreamExtractSubtitle) + playback.LogSubtitleStreamError(r.Context(), err, file.ID, embeddedIndex) + return + } + + switch outFormat { + case "ass": + w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8") + default: + w.Header().Set("Content-Type", "text/vtt; charset=utf-8") + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + + opts.Writer = w + if err := playback.StreamExtractSubtitle(r.Context(), opts); err != nil { // Headers already committed — best we can do is log and let // the client see a truncated response. playback.LogSubtitleStreamError(r.Context(), err, file.ID, embeddedIndex) diff --git a/internal/api/handlers/stream_test.go b/internal/api/handlers/stream_test.go index 11d89759..507be166 100644 --- a/internal/api/handlers/stream_test.go +++ b/internal/api/handlers/stream_test.go @@ -173,6 +173,47 @@ func TestHandleSubtitle_ListDownloadedSubtitlesErrorReturns500(t *testing.T) { } } +func TestHandleSubtitle_NilMediaFileReturns404(t *testing.T) { + baseMgr := playback.NewSessionManager(0, 0) + session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + handler := NewStreamHandler(baseMgr, errStreamFileResolver{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.vtt", nil) + req = req.WithContext(newAuthorizedPlaybackContext()) + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("session_id", session.ID) + routeCtx.URLParams.Add("track", "0.vtt") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) + + rr := httptest.NewRecorder() + handler.HandleSubtitle(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, body = %s; want 404", rr.Code, rr.Body.String()) + } +} + +func TestSubtitleSourceFileIDPinsURLAcrossEffectiveFileSwitch(t *testing.T) { + session := &playback.Session{MediaFileID: 200, RequestedMediaFileID: 100} + + request := httptest.NewRequest(http.MethodGet, "/subtitles/4.vtt?file_id=100", nil) + fileID, err := subtitleSourceFileID(request, session) + if err != nil { + t.Fatalf("subtitleSourceFileID: %v", err) + } + if fileID != 100 { + t.Fatalf("fileID = %d, want original subtitle source 100", fileID) + } + + request = httptest.NewRequest(http.MethodGet, "/subtitles/4.vtt?file_id=300", nil) + if _, err := subtitleSourceFileID(request, session); err == nil { + t.Fatal("expected unrelated subtitle source file to be rejected") + } +} + func TestHandleTransportStartFailure_KeepsSessionForNonMissingError(t *testing.T) { filePath := writePlaybackTestMediaFile(t, "movie.mkv") file := &models.MediaFile{ diff --git a/internal/api/handlers/subtitle_prefs.go b/internal/api/handlers/subtitle_prefs.go index 079bc846..de8af80e 100644 --- a/internal/api/handlers/subtitle_prefs.go +++ b/internal/api/handlers/subtitle_prefs.go @@ -111,6 +111,19 @@ func (h *SubtitlePrefHandler) HandleSetSubtitlePref(w http.ResponseWriter, r *ht if req.ShowForcedSubtitles != nil { pref.ShowForcedSubtitles = *req.ShowForcedSubtitles pref.HasShowForcedSubtitles = true + } else { + // This endpoint replaces the combined subtitle-preference row. Preserve + // an existing forced-subtitle override when clients update only the track + // selection; otherwise an omitted optional field silently resets it. + existing, getErr := store.GetSubtitlePreference(r.Context(), profileID, seriesID) + if getErr != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to preserve subtitle preference") + return + } + if existing != nil && existing.HasShowForcedSubtitles { + pref.ShowForcedSubtitles = existing.ShowForcedSubtitles + pref.HasShowForcedSubtitles = true + } } if err := store.SetSubtitlePreference(r.Context(), pref); err != nil { diff --git a/internal/api/handlers/subtitle_prefs_test.go b/internal/api/handlers/subtitle_prefs_test.go new file mode 100644 index 00000000..6c474a7e --- /dev/null +++ b/internal/api/handlers/subtitle_prefs_test.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/userstore" +) + +func TestSetSubtitlePreferencePreservesOmittedForcedOverride(t *testing.T) { + store := newPlaybackTestStore(t) + if err := store.SetSubtitlePreference(context.Background(), userstore.SubtitlePreference{ + ProfileID: "profile-1", + SeriesID: "series-1", + SubtitleLanguage: "en", + SubtitleTrackIndex: 1, + SubtitleMode: "always", + ShowForcedSubtitles: false, + HasShowForcedSubtitles: true, + }); err != nil { + t.Fatalf("seed subtitle preference: %v", err) + } + + handler := NewSubtitlePrefHandler(testUserStoreProvider{store: store}) + req := httptest.NewRequest(http.MethodPut, "/subtitle-prefs/series-1", strings.NewReader(`{ + "subtitle_language":"ja", + "subtitle_track_index":2, + "subtitle_mode":"always" + }`)) + req = req.WithContext(newAuthorizedPlaybackContext()) + req = withPlaybackRouteParam(req, "series_id", "series-1") + rec := httptest.NewRecorder() + + handler.HandleSetSubtitlePref(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String()) + } + + pref, err := store.GetSubtitlePreference(context.Background(), "profile-1", "series-1") + if err != nil { + t.Fatalf("get subtitle preference: %v", err) + } + if pref == nil || !pref.HasShowForcedSubtitles || pref.ShowForcedSubtitles { + t.Fatalf("forced-subtitle override was not preserved: %+v", pref) + } + if pref.SubtitleLanguage != "ja" || pref.SubtitleTrackIndex != 2 { + t.Fatalf("track selection was not updated: %+v", pref) + } +} diff --git a/internal/api/middleware/flush_test.go b/internal/api/middleware/flush_test.go new file mode 100644 index 00000000..11949ec7 --- /dev/null +++ b/internal/api/middleware/flush_test.go @@ -0,0 +1,69 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// Progressive responses (streamed subtitle extracts, remux output) rely on +// http.Flusher reaching the real connection through every wrapper in the +// middleware chain. A wrapper that drops Flush silently degrades streaming +// to whole-response buffering, so assert the full chain forwards it. +func TestMiddlewareChainForwardsFlush(t *testing.T) { + var sawFlusher bool + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + f, ok := w.(http.Flusher) + sawFlusher = ok + if ok { + _, _ = w.Write([]byte("chunk")) + f.Flush() + } + }) + + // Same wrapping order as the API router: RequestLogger outermost, then + // Metrics; the handler sees the innermost wrapper. + chain := RequestLogger("test-node")(Metrics(handler)) + + rec := httptest.NewRecorder() + chain.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/stream/x/subtitles/0.vtt", nil)) + + if !sawFlusher { + t.Fatal("handler's ResponseWriter does not implement http.Flusher through the middleware chain") + } + if !rec.Flushed { + t.Fatal("Flush did not propagate to the underlying ResponseWriter") + } +} + +func TestStatusWritersRecordImplicitOKOnFlush(t *testing.T) { + t.Run("metrics", func(t *testing.T) { + rec := httptest.NewRecorder() + writer := &statusWriter{ResponseWriter: rec, status: http.StatusOK} + + writer.Flush() + writer.WriteHeader(http.StatusInternalServerError) + + if writer.status != http.StatusOK || !writer.written { + t.Fatalf("status = %d, written = %v; want committed 200", writer.status, writer.written) + } + if rec.Code != http.StatusOK { + t.Fatalf("response status = %d, want 200", rec.Code) + } + }) + + t.Run("request logger", func(t *testing.T) { + rec := httptest.NewRecorder() + writer := &requestStatusWriter{ResponseWriter: rec, status: http.StatusOK} + + writer.Flush() + writer.WriteHeader(http.StatusInternalServerError) + + if writer.status != http.StatusOK || !writer.wroteHeader { + t.Fatalf("status = %d, wroteHeader = %v; want committed 200", writer.status, writer.wroteHeader) + } + if rec.Code != http.StatusOK { + t.Fatalf("response status = %d, want 200", rec.Code) + } + }) +} diff --git a/internal/api/middleware/metrics.go b/internal/api/middleware/metrics.go index e3f1cf2b..b2aa0127 100644 --- a/internal/api/middleware/metrics.go +++ b/internal/api/middleware/metrics.go @@ -72,6 +72,17 @@ func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { return nil, nil, fmt.Errorf("underlying ResponseWriter does not implement http.Hijacker") } +// Flush implements http.Flusher so progressive responses keep flushing +// through the metrics wrapper instead of silently buffering. +func (w *statusWriter) Flush() { + if !w.written { + w.WriteHeader(http.StatusOK) + } + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + // Unwrap lets http.ResponseController reach the underlying connection (e.g. // for the per-response write deadlines used by streaming handlers). func (w *statusWriter) Unwrap() http.ResponseWriter { diff --git a/internal/api/middleware/request_logger.go b/internal/api/middleware/request_logger.go index 1b71f9e1..7a8f3fc7 100644 --- a/internal/api/middleware/request_logger.go +++ b/internal/api/middleware/request_logger.go @@ -102,6 +102,18 @@ func (w *requestStatusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { return nil, nil, fmt.Errorf("underlying ResponseWriter does not implement http.Hijacker") } +// Flush implements http.Flusher so progressive responses (streamed subtitle +// extracts, remux output) keep flushing through the logging wrapper instead of +// silently buffering until the handler returns. +func (w *requestStatusWriter) Flush() { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + // Unwrap lets http.ResponseController reach the underlying connection (e.g. // for the per-response write deadlines used by streaming handlers). func (w *requestStatusWriter) Unwrap() http.ResponseWriter { diff --git a/internal/api/router.go b/internal/api/router.go index 4512f4d5..be29f4ef 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -917,6 +917,9 @@ func NewRouter(deps Dependencies) chi.Router { streamHandler.PlaybackConfig = func() config.PlaybackConfig { return deps.CurrentConfig().Playback } + streamHandler.SubtitleCache = playback.NewSubtitleCache(func() string { + return deps.CurrentConfig().Playback.TranscodeDir + }) } restartStatus := deps.ServerRestartStatus diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index dc331051..44402469 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -555,6 +555,26 @@ type subtitleDefaults struct { TrackSignature *userstore.SubtitleTrackSignature } +func (d subtitleDefaults) applyToItemDetail(detail *ItemDetail) { + detail.EffectiveSubtitleLanguage = d.Language + detail.HasEffectiveSubtitleLang = d.HasLanguage + detail.EffectiveSubtitleMode = d.Mode + detail.HasEffectiveSubtitleMode = d.HasMode + detail.EffectiveShowForcedSubtitles = d.ShowForced + detail.HasEffectiveShowForcedSubtitles = d.HasShowForced + detail.EffectiveSubtitleTrackSignature = d.TrackSignature +} + +func (d subtitleDefaults) applyToWatchDetail(detail *WatchDetail) { + detail.EffectiveSubtitleLanguage = d.Language + detail.HasEffectiveSubtitleLang = d.HasLanguage + detail.EffectiveSubtitleMode = d.Mode + detail.HasEffectiveSubtitleMode = d.HasMode + detail.EffectiveShowForcedSubtitles = d.ShowForced + detail.HasEffectiveShowForcedSubtitles = d.HasShowForced + detail.EffectiveSubtitleTrackSignature = d.TrackSignature +} + type versionDefaults struct { Resolution string HDR bool @@ -1526,6 +1546,14 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M item.ContentID, ) detail.OverlaySummary = overlays.BuildSummary(files) + // Movie pre-play selectors need the same effective subtitle defaults + // the watch payload resolves — including any per-item override saved + // from a previous play — so the item detail can't omit them. Scoped to + // movies: episodes resolve theirs in buildEpisodeDetail, and other + // types have no subtitle selector to feed. + if item.Type == "movie" { + s.effectiveSubtitleDefaults(ctx, filter, item.ContentID, files).applyToItemDetail(detail) + } } // Trailers/extras apply to movies and series only. @@ -2365,14 +2393,7 @@ func (s *DetailService) buildEpisodeDetail(ctx context.Context, episode *models. episode.SeriesID, ) detail.OverlaySummary = overlays.BuildSummary(files) - defaults := s.effectiveSubtitleDefaults(ctx, filter, episode.SeriesID, files) - detail.EffectiveSubtitleLanguage = defaults.Language - detail.HasEffectiveSubtitleLang = defaults.HasLanguage - detail.EffectiveSubtitleMode = defaults.Mode - detail.HasEffectiveSubtitleMode = defaults.HasMode - detail.EffectiveShowForcedSubtitles = defaults.ShowForced - detail.HasEffectiveShowForcedSubtitles = defaults.HasShowForced - detail.EffectiveSubtitleTrackSignature = defaults.TrackSignature + s.effectiveSubtitleDefaults(ctx, filter, episode.SeriesID, files).applyToItemDetail(detail) if seriesCtx.versionPref.HasAny { if seriesCtx.versionPref.Resolution != "" { detail.EffectiveVersionResolution = stringPtr(seriesCtx.versionPref.Resolution) @@ -2425,14 +2446,7 @@ func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, fi item.ContentID, ) detail.Year = item.Year - defaults := s.effectiveSubtitleDefaults(ctx, filter, item.ContentID, files) - detail.EffectiveSubtitleLanguage = defaults.Language - detail.HasEffectiveSubtitleLang = defaults.HasLanguage - detail.EffectiveSubtitleMode = defaults.Mode - detail.HasEffectiveSubtitleMode = defaults.HasMode - detail.EffectiveShowForcedSubtitles = defaults.ShowForced - detail.HasEffectiveShowForcedSubtitles = defaults.HasShowForced - detail.EffectiveSubtitleTrackSignature = defaults.TrackSignature + s.effectiveSubtitleDefaults(ctx, filter, item.ContentID, files).applyToWatchDetail(detail) return detail, nil case !errors.Is(err, ErrItemNotFound): return nil, err @@ -2483,14 +2497,7 @@ func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, fi detail.SeasonNumber = episode.SeasonNumber detail.EpisodeNumber = episode.EpisodeNumber detail.SeriesID = episode.SeriesID - defaults := s.effectiveSubtitleDefaults(ctx, filter, episode.SeriesID, files) - detail.EffectiveSubtitleLanguage = defaults.Language - detail.HasEffectiveSubtitleLang = defaults.HasLanguage - detail.EffectiveSubtitleMode = defaults.Mode - detail.HasEffectiveSubtitleMode = defaults.HasMode - detail.EffectiveShowForcedSubtitles = defaults.ShowForced - detail.HasEffectiveShowForcedSubtitles = defaults.HasShowForced - detail.EffectiveSubtitleTrackSignature = defaults.TrackSignature + s.effectiveSubtitleDefaults(ctx, filter, episode.SeriesID, files).applyToWatchDetail(detail) if versionPref := s.effectiveVersionDefaults(ctx, filter, episode.SeriesID); versionPref.HasAny { if versionPref.Resolution != "" { detail.EffectiveVersionResolution = stringPtr(versionPref.Resolution) diff --git a/internal/jellycompat/image_proxy_tags.go b/internal/jellycompat/image_proxy_tags.go index 79711304..b91eb109 100644 --- a/internal/jellycompat/image_proxy_tags.go +++ b/internal/jellycompat/image_proxy_tags.go @@ -53,6 +53,28 @@ func (w *compatImageProxyTagResponseWriter) Write(p []byte) (int, error) { return w.body.Write(p) } +// Flush implements http.Flusher for the passthrough (non-JSON) path only. +// While buffering a JSON body for tag rewriting there is nothing downstream +// to flush, and flushing the inner writer would commit headers before +// finish() has rewritten the response. +func (w *compatImageProxyTagResponseWriter) Flush() { + if !w.passthrough { + contentType := w.Header().Get("Content-Type") + if contentType == "" || isJSONResponse(contentType) { + return + } + w.passthrough = true + status := w.status + if status == 0 { + status = http.StatusOK + } + w.ResponseWriter.WriteHeader(status) + } + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + func (w *compatImageProxyTagResponseWriter) finish() { if w.passthrough { return diff --git a/internal/jellycompat/image_proxy_tags_test.go b/internal/jellycompat/image_proxy_tags_test.go index aaa8a07e..7e2423f4 100644 --- a/internal/jellycompat/image_proxy_tags_test.go +++ b/internal/jellycompat/image_proxy_tags_test.go @@ -100,6 +100,32 @@ func TestCompatImageProxyTagVariantMiddlewareLeavesOtherClientsUnchanged(t *test } } +func TestCompatImageProxyTagVariantMiddlewareForwardsPreBodyFlushForStreamingResponse(t *testing.T) { + codec := NewResourceIDCodec() + handler := compatImageProxyTagVariantMiddleware(codec)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + t.Fatal("wrapped response writer does not implement http.Flusher") + } + flusher.Flush() + _, _ = w.Write([]byte("data: ready\n\n")) + })) + + req := httptest.NewRequest(http.MethodGet, "/Events", nil) + req.Header.Set("User-Agent", "Infuse-Direct/8.4.6") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if !rec.Flushed { + t.Fatal("pre-body flush did not reach the underlying response writer") + } + if rec.Code != http.StatusOK || rec.Body.String() != "data: ready\n\n" { + t.Fatalf("status = %d, body = %q; want streamed 200 response", rec.Code, rec.Body.String()) + } +} + func TestCompatImageProxyRouteIDCanonicalizesNumericRouteWithoutRegistration(t *testing.T) { routeID := EncodeNumericID(EncodedIDItem, 12345).String() proxyRouteID := compatImageProxyRouteID(NewResourceIDCodec(), routeID) diff --git a/internal/jellycompat/logging.go b/internal/jellycompat/logging.go index e11198e2..28136679 100644 --- a/internal/jellycompat/logging.go +++ b/internal/jellycompat/logging.go @@ -42,6 +42,19 @@ func (w *loggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { return nil, nil, fmt.Errorf("underlying ResponseWriter does not implement http.Hijacker") } +// Flush implements http.Flusher so progressive responses (subtitle extracts, +// streamed media) keep flushing through the logging wrapper. +func (w *loggingResponseWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap returns the underlying ResponseWriter for http.ResponseController. +func (w *loggingResponseWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + func requestLoggerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() @@ -135,6 +148,19 @@ func (w *debugResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { return nil, nil, fmt.Errorf("underlying ResponseWriter does not implement http.Hijacker") } +// Flush implements http.Flusher. The debug writer tees into its capture +// buffer on Write, so flushing the underlying writer is always safe. +func (w *debugResponseWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap returns the underlying ResponseWriter for http.ResponseController. +func (w *debugResponseWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + // newDebugLogMiddleware creates a middleware that logs full request/response // pairs to the given file. Enable by setting JELLYCOMPAT_DEBUG_LOG=/path/to/file. // When userAgentFilter is non-empty, only requests whose User-Agent contains diff --git a/internal/playback/audio_select.go b/internal/playback/audio_select.go index 0fa6364f..068b82f0 100644 --- a/internal/playback/audio_select.go +++ b/internal/playback/audio_select.go @@ -85,6 +85,34 @@ func SelectAudioTrack(tracks []models.AudioTrack, preferredLang string, seriesPr return 0 } +// MatchAudioTrackAcrossVersions maps a selection made against one file's +// audio inventory onto another version of the same content. Track ordering is +// not stable across encodes, so carrying the raw ordinal can select a different +// language. Prefer the stable signature, then the selected language, and +// finally the effective file's default track. +func MatchAudioTrackAcrossVersions( + requestedTracks []models.AudioTrack, + effectiveTracks []models.AudioTrack, + requestedIndex int, +) int { + if len(effectiveTracks) == 0 { + return 0 + } + if len(requestedTracks) == 0 { + return SelectAudioTrack(effectiveTracks, "", nil) + } + if requestedIndex < 0 || requestedIndex >= len(requestedTracks) { + requestedIndex = SelectAudioTrack(requestedTracks, "", nil) + } + + selected := requestedTracks[requestedIndex] + return SelectAudioTrack(effectiveTracks, "", &AudioTrackPreference{ + AudioTrackIndex: requestedIndex, + AudioLanguage: selected.Language, + TrackSignature: AudioTrackSignatureFromTrack(selected), + }) +} + // BrowserSupportsAudioCodec returns true if the given audio codec can be // played natively by web browsers without transcoding. func BrowserSupportsAudioCodec(codec string) bool { diff --git a/internal/playback/audio_select_test.go b/internal/playback/audio_select_test.go index 8d2903a8..427a1fc1 100644 --- a/internal/playback/audio_select_test.go +++ b/internal/playback/audio_select_test.go @@ -180,3 +180,33 @@ func TestSelectAudioTrack_PrefersExactTrackSignatureOverIndexFallback(t *testing t.Fatalf("SelectAudioTrack() = %d, want 1", got) } } + +func TestMatchAudioTrackAcrossVersionsRemapsReorderedLanguage(t *testing.T) { + requested := []models.AudioTrack{ + {Language: "ja", Codec: "aac", Channels: 2, Title: "Japanese"}, + {Language: "en", Codec: "eac3", Channels: 6, Title: "English 5.1"}, + } + effective := []models.AudioTrack{ + {Language: "en", Codec: "eac3", Channels: 6, Title: "English 5.1"}, + {Language: "ja", Codec: "aac", Channels: 2, Title: "Japanese"}, + } + + if got := playback.MatchAudioTrackAcrossVersions(requested, effective, 1); got != 0 { + t.Fatalf("MatchAudioTrackAcrossVersions() = %d, want English track 0", got) + } +} + +func TestMatchAudioTrackAcrossVersionsFallsBackToLanguageAcrossCodecs(t *testing.T) { + requested := []models.AudioTrack{ + {Language: "ja", Codec: "aac", Channels: 2}, + {Language: "en", Codec: "truehd", Channels: 8}, + } + effective := []models.AudioTrack{ + {Language: "en", Codec: "eac3", Channels: 6}, + {Language: "es", Codec: "aac", Channels: 2, Default: true}, + } + + if got := playback.MatchAudioTrackAcrossVersions(requested, effective, 1); got != 0 { + t.Fatalf("MatchAudioTrackAcrossVersions() = %d, want English track 0", got) + } +} diff --git a/internal/playback/prepare_file.go b/internal/playback/prepare_file.go index e3b72d27..195340c2 100644 --- a/internal/playback/prepare_file.go +++ b/internal/playback/prepare_file.go @@ -114,7 +114,7 @@ func buildPrepareFileArgs(opts TranscodeOpts, outputPath string) []string { ) args = append(args, "-i", opts.InputPath) args = append(args, "-map_metadata", "-1", "-map_chapters", "-1") - args = appendStreamSelectionArgs(args, opts.AudioTrackIndex) + args = appendStreamSelectionArgs(args, opts) if isVideoCopy { args = append(args, "-c:v", "copy") diff --git a/internal/playback/recipecard.go b/internal/playback/recipecard.go index 980da82b..9e97d834 100644 --- a/internal/playback/recipecard.go +++ b/internal/playback/recipecard.go @@ -44,6 +44,7 @@ type RecipeCard struct { HWDevice string `json:"hw_device,omitempty"` SubtitleTrackIndex int `json:"subtitle_track_index"` SubtitleBurnIn bool `json:"subtitle_burn_in,omitempty"` + SubtitleCodec string `json:"subtitle_codec,omitempty"` AudioTrackIndex int `json:"audio_track_index"` TargetBitrateKbps int `json:"target_bitrate_kbps,omitempty"` TotalDuration float64 `json:"total_duration"` @@ -75,6 +76,7 @@ func NewRecipeCard(userID int, profileID string, mediaFileID int, transcodeNodeU HWDevice: opts.HWDevice, SubtitleTrackIndex: opts.SubtitleTrackIndex, SubtitleBurnIn: opts.SubtitleBurnIn, + SubtitleCodec: opts.SubtitleCodec, AudioTrackIndex: opts.AudioTrackIndex, TargetBitrateKbps: opts.TargetBitrateKbps, TotalDuration: opts.TotalDuration, @@ -131,6 +133,7 @@ func (c RecipeCard) TranscodeOpts(outputDir, ffmpegPath string, logSink FFmpegLo HWDevice: c.HWDevice, SubtitleTrackIndex: c.SubtitleTrackIndex, SubtitleBurnIn: c.SubtitleBurnIn, + SubtitleCodec: c.SubtitleCodec, AudioTrackIndex: c.AudioTrackIndex, TargetBitrateKbps: c.TargetBitrateKbps, TotalDuration: c.TotalDuration, @@ -173,6 +176,7 @@ func (c RecipeCard) ToClaims() streamtoken.Claims { StartSegmentNumber: c.StartSegmentNumber, SubtitleTrackIndex: c.SubtitleTrackIndex, SubtitleBurnIn: c.SubtitleBurnIn, + SubtitleCodec: c.SubtitleCodec, TargetBitrateKbps: c.TargetBitrateKbps, TotalDuration: c.TotalDuration, FastStart: c.FastStart, @@ -210,6 +214,7 @@ func RecipeCardFromClaims(c *streamtoken.Claims) RecipeCard { StartSegmentNumber: c.StartSegmentNumber, SubtitleTrackIndex: c.SubtitleTrackIndex, SubtitleBurnIn: c.SubtitleBurnIn, + SubtitleCodec: c.SubtitleCodec, AudioTrackIndex: c.AudioTrackIndex, TargetBitrateKbps: c.TargetBitrateKbps, TotalDuration: c.TotalDuration, diff --git a/internal/playback/recipecard_test.go b/internal/playback/recipecard_test.go index 88979b47..c83d926d 100644 --- a/internal/playback/recipecard_test.go +++ b/internal/playback/recipecard_test.go @@ -23,6 +23,7 @@ func TestRecipeCardRoundTripOpts(t *testing.T) { HWDevice: "/dev/dri/renderD128", SubtitleTrackIndex: 3, SubtitleBurnIn: true, + SubtitleCodec: "hdmv_pgs_subtitle", AudioTrackIndex: 1, TargetBitrateKbps: 8000, TotalDuration: 7200, @@ -45,6 +46,9 @@ func TestRecipeCardRoundTripOpts(t *testing.T) { if !got.SubtitleBurnIn { t.Errorf("SubtitleBurnIn lost in round trip") } + if got.SubtitleCodec != "hdmv_pgs_subtitle" { + t.Errorf("SubtitleCodec = %q, want hdmv_pgs_subtitle", got.SubtitleCodec) + } if got.AudioTrackIndex != 1 || got.SubtitleTrackIndex != 3 { t.Errorf("track indices wrong: audio=%d sub=%d", got.AudioTrackIndex, got.SubtitleTrackIndex) } @@ -104,6 +108,7 @@ func TestRecipeCardClaimsRoundTrip(t *testing.T) { StartSegmentNumber: 450, SubtitleTrackIndex: 3, SubtitleBurnIn: true, + SubtitleCodec: "hdmv_pgs_subtitle", AudioTrackIndex: 1, TargetBitrateKbps: 8000, TotalDuration: 7200, @@ -125,6 +130,7 @@ func TestRecipeCardClaimsRoundTrip(t *testing.T) { got.TargetCodecVideo != card.TargetCodecVideo || got.TargetCodecAudio != card.TargetCodecAudio || got.SegmentDuration != card.SegmentDuration || got.StartSegmentNumber != card.StartSegmentNumber || got.SubtitleTrackIndex != card.SubtitleTrackIndex || got.SubtitleBurnIn != card.SubtitleBurnIn || + got.SubtitleCodec != card.SubtitleCodec || got.AudioTrackIndex != card.AudioTrackIndex || got.TargetBitrateKbps != card.TargetBitrateKbps || got.TotalDuration != card.TotalDuration || got.FastStart != card.FastStart { t.Fatalf("encode parameters lost in round trip:\n have %+v\n want %+v", got, card) diff --git a/internal/playback/subtitle_cache.go b/internal/playback/subtitle_cache.go new file mode 100644 index 00000000..14f6504d --- /dev/null +++ b/internal/playback/subtitle_cache.go @@ -0,0 +1,540 @@ +package playback + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// SubtitleCache stores full-track PGS (.sup) subtitle extracts on disk so +// repeat selections of the same embedded track don't re-run a whole-file +// ffmpeg demux (minutes for a large remux). Only complete, unwindowed .sup +// extracts are cached — VTT extracts are already windowed and fast, and ASS +// extracts are small; neither pays the full-demux cost PGS does. +// +// Entries are keyed by the source file path, subtitle stream ordinal, and the +// source's mtime+size, all encoded in the cache filename. Invalidation is +// therefore implicit: when the source changes, the lookup key changes and the +// old entry becomes garbage that eviction reclaims. Entry recency for LRU is +// tracked by bumping the cache file's mtime on every hit (portable, unlike +// atime which is often disabled via noatime/relatime mounts). +// +// Concurrency: the first requester of an uncached track streams the extract +// progressively to its client while teeing bytes into a temp file that is +// atomically renamed into the cache on clean ffmpeg exit (and discarded on +// any error, so a partial entry is never served). Concurrent requesters for +// the same track while a fill is in flight simply run their own un-teed +// extract — no worse than today's behavior, and it avoids making a viewer's +// first-byte latency depend on another client's connection. +type SubtitleCache struct { + // transcodeDir returns the current transcode directory; the cache lives + // in a subtitle-cache subdirectory beneath it, created lazily. An empty + // return disables the cache for that call. + transcodeDir func() string + // maxBytes is the total-size eviction budget for committed entries. + maxBytes int64 + + mu sync.Mutex + inflight map[string]struct{} + + // warmSem bounds concurrent background warms server-wide (each warm + // demuxes an entire source file — heavy sequential IO). Acquisition is + // non-blocking: warms beyond the budget are dropped, not queued; the + // next windowed miss for that track re-attempts the warm. + warmSem chan struct{} +} + +const ( + subtitleCacheDirName = "subtitle-cache" + // defaultSubtitleCacheMaxBytes caps the cache at 2 GiB — PGS tracks run + // 15-80 MB, so this holds a few dozen tracks. + // TODO: expose as a config knob following the download.artifact_max_bytes + // pattern (internal/config/config.go DownloadConfig.ArtifactMaxBytes). + defaultSubtitleCacheMaxBytes = 2 << 30 + // stalePartMaxAge is how long an orphaned .part temp file (leftover from + // a crash mid-fill) survives before eviction sweeps remove it. + stalePartMaxAge = time.Hour + // subtitleCacheWarmSlots caps concurrent background warms server-wide. + // Two lets a second household stream warm while the first is still + // demuxing, without letting a burst of playbacks saturate disk IO. + subtitleCacheWarmSlots = 2 + // subtitleCacheWarmTimeout bounds a single background warm. A full-file + // demux of a large remux on network storage can take minutes; anything + // beyond this is stuck and should release its slot. + subtitleCacheWarmTimeout = 30 * time.Minute +) + +// SUPExtractFunc runs one ffmpeg subtitle extract described by opts, writing +// output to opts.Writer. Production callers pass StreamExtractSubtitle; +// tests substitute fakes. The cache invokes it with the caller's options +// rewritten as needed (tee writer for fills, cached-.sup input for windowed +// serves, cleared window for background warms). +type SUPExtractFunc func(ctx context.Context, opts StreamExtractOpts) error + +// NewSubtitleCache builds a cache rooted under the transcode directory +// returned by transcodeDir at call time (so runtime config changes are +// honored). Pass nil to disable caching entirely. +func NewSubtitleCache(transcodeDir func() string) *SubtitleCache { + return &SubtitleCache{ + transcodeDir: transcodeDir, + maxBytes: defaultSubtitleCacheMaxBytes, + inflight: make(map[string]struct{}), + warmSem: make(chan struct{}, subtitleCacheWarmSlots), + } +} + +// ServeSUPExtract serves the .sup extract for one source+track described by +// opts (opts.Writer is ignored; the cache supplies it). Full-track requests +// (no AllowWindow): a cache hit is served with http.ServeContent (Range +// support, Content-Length, Last-Modified from the source file's mtime, +// revalidatable instead of no-store); a miss invokes extract with a writer +// that streams to the client while teeing bytes into a temp file, atomically +// published as the cache entry on clean extract exit and discarded on any +// error (ffmpeg failure or client disconnect) — a partial entry is never +// served. Windowed requests (opts.AllowWindow): the output covers only a +// slice of the track, so it is never cached; but when the full-track entry +// already exists, the windowed extract runs against the small cached .sup +// instead of re-demuxing the original file, and when it doesn't, a detached +// background warm is kicked off so subsequent windows get that fast path. A +// nil receiver disables caching and just streams. +// +// The caller sets any extra response headers (e.g. CORS) before calling. +// The returned error is the extract error; cache hits return nil. +func (c *SubtitleCache) ServeSUPExtract(w http.ResponseWriter, r *http.Request, opts StreamExtractOpts, extract SUPExtractFunc) error { + if opts.AllowWindow { + return c.serveWindowedSUP(w, r, opts, extract) + } + + if cached, modTime, ok := c.Lookup(opts.InputPath, opts.TrackIndex); ok { + defer func() { _ = cached.Close() }() + slog.DebugContext(r.Context(), "subtitle stream served from cache", + "input", opts.InputPath, "track", opts.TrackIndex) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Cache-Control", "private, no-cache") + http.ServeContent(w, r, "", modTime, cached) + return nil + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + + // BeginFill returns nil when another fill for this track is already in + // flight (or the cache dir is unusable); this request then streams its + // own uncached extract. + fill := c.BeginFill(opts.InputPath, opts.TrackIndex) + var writer io.Writer = w + if fill != nil { + writer = fill.Tee(w) + } + + opts.Writer = writer + err := extract(r.Context(), opts) + if fill != nil { + if err != nil { + fill.Discard() + } else if commitErr := fill.Commit(); commitErr != nil { + slog.WarnContext(r.Context(), "subtitle cache commit failed", + "input", opts.InputPath, "track", opts.TrackIndex, "error", commitErr) + } + } + return err +} + +// serveWindowedSUP streams a windowed slice of the track. The output is a +// position-dependent slice so it is never cached itself, but the cache still +// speeds it up: with a committed full-track entry the extract's input is +// rewritten to the cached .sup (15-80 MB, so the -ss scan is near-instant +// versus re-demuxing a multi-GB source); without one, a background warm is +// started so later windows — the client re-fetches on every seek — hit the +// fast path. +func (c *SubtitleCache) serveWindowedSUP(w http.ResponseWriter, r *http.Request, opts StreamExtractOpts, extract SUPExtractFunc) error { + if cachedPath, _, ok := c.cachedEntryPath(opts.InputPath, opts.TrackIndex); ok { + slog.DebugContext(r.Context(), "windowed subtitle extract using cached full track", + "input", opts.InputPath, "track", opts.TrackIndex, "cache_entry", cachedPath) + opts.InputPath = cachedPath + opts.InputIsExtractedSup = true + } else { + c.WarmInBackground(opts, extract) + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + + opts.Writer = w + return extract(r.Context(), opts) +} + +// WarmInBackground starts a detached full-track extract that fills the cache +// entry for opts' source+track, so future windowed requests can extract from +// the small cached .sup instead of the original file. The warm runs on a +// background context with a generous timeout — it must survive the request +// that triggered it. BeginFill's in-flight coalescing guarantees at most one +// fill per track (a concurrent client-driven fill wins and the warm is +// skipped), and warmSem bounds warms server-wide: beyond the budget the warm +// is dropped, not queued — the next windowed miss re-attempts it. A nil +// receiver is a no-op. +func (c *SubtitleCache) WarmInBackground(opts StreamExtractOpts, extract SUPExtractFunc) { + if c == nil || extract == nil { + return + } + select { + case c.warmSem <- struct{}{}: + default: + slog.Debug("subtitle cache warm skipped: all warm slots busy", + "input", opts.InputPath, "track", opts.TrackIndex) + return + } + fill := c.BeginFill(opts.InputPath, opts.TrackIndex) + if fill == nil { + // Another fill (client-driven or a previous warm) is already in + // flight, or the cache is unusable — either way, nothing to do. + <-c.warmSem + return + } + + // Full-track options: the warm ignores the triggering request's window + // and writes only to the cache temp file (no response writer). + opts.SeekSeconds = 0 + opts.DurationSeconds = 0 + opts.AllowWindow = false + opts.InputIsExtractedSup = false + opts.Writer = fill.Tee(io.Discard) + + go func() { + defer func() { <-c.warmSem }() + ctx, cancel := context.WithTimeout(context.Background(), subtitleCacheWarmTimeout) + defer cancel() + + start := time.Now() + slog.Info("subtitle cache warm started", + "input", opts.InputPath, "track", opts.TrackIndex) + if err := extract(ctx, opts); err != nil { + fill.Discard() + slog.Warn("subtitle cache warm failed", + "input", opts.InputPath, "track", opts.TrackIndex, + "elapsed_ms", time.Since(start).Milliseconds(), "error", err) + return + } + if err := fill.Commit(); err != nil { + slog.Warn("subtitle cache warm commit failed", + "input", opts.InputPath, "track", opts.TrackIndex, "error", err) + return + } + slog.Info("subtitle cache warm finished", + "input", opts.InputPath, "track", opts.TrackIndex, + "elapsed_ms", time.Since(start).Milliseconds()) + }() +} + +// dir resolves the cache directory, or "" when caching is disabled. +func (c *SubtitleCache) dir() string { + if c == nil || c.transcodeDir == nil { + return "" + } + base := c.transcodeDir() + if base == "" { + return "" + } + return filepath.Join(base, subtitleCacheDirName) +} + +// subtitleCacheKeyPrefix identifies a source file + track ordinal regardless +// of source version; the full key appends mtime+size so a changed source +// yields a different filename. +func subtitleCacheKeyPrefix(inputPath string, trackIndex int) string { + sum := sha256.Sum256([]byte(inputPath)) + return fmt.Sprintf("%x-s%d-", sum[:12], trackIndex) +} + +func subtitleCacheKey(inputPath string, trackIndex int, mtime time.Time, size int64) string { + return fmt.Sprintf("%s%d-%d.sup", subtitleCacheKeyPrefix(inputPath, trackIndex), mtime.UnixNano(), size) +} + +// Lookup opens the cached full-track .sup extract for the given source file +// and subtitle stream ordinal. The source is stat'ed on every lookup: an +// mtime or size mismatch means the entry (if any) is stale and reads as a +// miss. On a hit the returned modTime is the *source* file's mtime — stable +// across hits, suitable for Last-Modified — while the cache file's own mtime +// is bumped to record recency for LRU eviction. The caller owns closing the +// returned file. +func (c *SubtitleCache) Lookup(inputPath string, trackIndex int) (f *os.File, modTime time.Time, ok bool) { + path, modTime, ok := c.cachedEntryPath(inputPath, trackIndex) + if !ok { + return nil, time.Time{}, false + } + f, err := os.Open(path) + if err != nil { + return nil, time.Time{}, false + } + return f, modTime, true +} + +// cachedEntryPath reports whether a committed entry exists for the given +// source+track and returns its path plus the source file's mtime. Like +// Lookup it stats the source on every call (a changed source reads as a +// miss) and bumps the entry's mtime to record recency for LRU eviction. +// Callers that hand the path to an external reader (ffmpeg) rather than +// opening it themselves use this instead of Lookup. +func (c *SubtitleCache) cachedEntryPath(inputPath string, trackIndex int) (path string, srcModTime time.Time, ok bool) { + dir := c.dir() + if dir == "" { + return "", time.Time{}, false + } + src, err := os.Stat(inputPath) + if err != nil { + return "", time.Time{}, false + } + path = filepath.Join(dir, subtitleCacheKey(inputPath, trackIndex, src.ModTime(), src.Size())) + if _, err := os.Stat(path); err != nil { + return "", time.Time{}, false + } + // Recency bump for LRU. Best-effort: a failure (e.g. read-only remount) + // only degrades eviction ordering, not correctness. + now := time.Now() + if err := os.Chtimes(path, now, now); err != nil { + slog.Debug("subtitle cache recency bump failed", "path", path, "error", err) + } + return path, src.ModTime(), true +} + +// SubtitleCacheFill is an in-progress cache population for one track. Bytes +// are written to a temp file via the writer returned by Tee; Commit renames +// it into place atomically, Discard throws it away. Exactly one of Commit or +// Discard must be called. +type SubtitleCacheFill struct { + c *SubtitleCache + key string + inputPath string + trackIndex int + srcMtime time.Time + srcSize int64 + tmp *os.File + // failed flips when a temp-file write errors (e.g. disk full); the tee + // keeps serving the client and Commit refuses to publish the entry. + failed bool +} + +// BeginFill reserves the in-flight slot for the given track and creates the +// temp file the tee will write into. Returns nil — meaning "stream without +// caching" — when caching is disabled, the source can't be stat'ed, the +// cache directory can't be created, or another fill for the same track is +// already in flight. +func (c *SubtitleCache) BeginFill(inputPath string, trackIndex int) *SubtitleCacheFill { + dir := c.dir() + if dir == "" { + return nil + } + src, err := os.Stat(inputPath) + if err != nil { + return nil + } + if err := os.MkdirAll(dir, 0o755); err != nil { + slog.Warn("subtitle cache dir create failed", "dir", dir, "error", err) + return nil + } + key := subtitleCacheKey(inputPath, trackIndex, src.ModTime(), src.Size()) + + c.mu.Lock() + if _, busy := c.inflight[key]; busy { + c.mu.Unlock() + return nil + } + c.inflight[key] = struct{}{} + c.mu.Unlock() + + tmp, err := os.CreateTemp(dir, key+".part-*") + if err != nil { + c.release(key) + slog.Warn("subtitle cache temp create failed", "dir", dir, "error", err) + return nil + } + return &SubtitleCacheFill{ + c: c, + key: key, + inputPath: inputPath, + trackIndex: trackIndex, + srcMtime: src.ModTime(), + srcSize: src.Size(), + tmp: tmp, + } +} + +func (c *SubtitleCache) release(key string) { + c.mu.Lock() + delete(c.inflight, key) + c.mu.Unlock() +} + +// Tee wraps the response writer so every chunk also lands in the fill's temp +// file. The returned writer implements http.Flusher (delegating to w when w +// does), so copyAndFlush keeps flushing cues to the client in real time. A +// temp-file write failure never fails the response — the fill is marked +// failed and the client keeps streaming. +func (f *SubtitleCacheFill) Tee(w io.Writer) io.Writer { + flusher, _ := w.(http.Flusher) + return &subtitleTeeWriter{w: w, flusher: flusher, fill: f} +} + +type subtitleTeeWriter struct { + w io.Writer + flusher http.Flusher + fill *SubtitleCacheFill +} + +func (t *subtitleTeeWriter) Write(p []byte) (int, error) { + if !t.fill.failed { + if _, err := t.fill.tmp.Write(p); err != nil { + t.fill.failed = true + slog.Warn("subtitle cache tee write failed; continuing uncached", + "track", t.fill.trackIndex, "error", err) + } + } + return t.w.Write(p) +} + +func (t *subtitleTeeWriter) Flush() { + if t.flusher != nil { + t.flusher.Flush() + } +} + +// Commit publishes the temp file as the cache entry: fsync, atomic rename, +// stale-sibling cleanup, then size-cap eviction. It refuses to publish (and +// discards instead) when a tee write failed or when the source file changed +// while the extract ran — a partial or mismatched entry must never be served. +func (f *SubtitleCacheFill) Commit() error { + if f.failed { + f.Discard() + return errors.New("subtitle cache fill had write errors; discarded") + } + if src, err := os.Stat(f.inputPath); err != nil || + !src.ModTime().Equal(f.srcMtime) || src.Size() != f.srcSize { + f.Discard() + return errors.New("source file changed during extract; cache fill discarded") + } + defer f.c.release(f.key) + + tmpPath := f.tmp.Name() + if err := f.tmp.Sync(); err != nil { + f.closeAndRemoveTmp() + return fmt.Errorf("sync subtitle cache temp: %w", err) + } + if err := f.tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("close subtitle cache temp: %w", err) + } + dir := filepath.Dir(tmpPath) + final := filepath.Join(dir, f.key) + if err := os.Rename(tmpPath, final); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("publish subtitle cache entry: %w", err) + } + + f.c.removeStaleSiblings(dir, f.inputPath, f.trackIndex, f.key) + f.c.evict(dir) + return nil +} + +// Discard abandons the fill: the temp file is removed and the in-flight slot +// released. Safe to call after a failed Commit (idempotent enough — the temp +// file is already gone and re-removal is a no-op). +func (f *SubtitleCacheFill) Discard() { + f.closeAndRemoveTmp() + f.c.release(f.key) +} + +func (f *SubtitleCacheFill) closeAndRemoveTmp() { + _ = f.tmp.Close() + if err := os.Remove(f.tmp.Name()); err != nil && !os.IsNotExist(err) { + slog.Warn("subtitle cache temp remove failed", "path", f.tmp.Name(), "error", err) + } +} + +// removeStaleSiblings deletes committed entries for the same source+track +// with a different mtime/size suffix — the source was replaced, so those can +// never be served again. +func (c *SubtitleCache) removeStaleSiblings(dir, inputPath string, trackIndex int, keepKey string) { + prefix := subtitleCacheKeyPrefix(inputPath, trackIndex) + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + name := e.Name() + if name == keepKey || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".sup") { + continue + } + if err := os.Remove(filepath.Join(dir, name)); err != nil && !os.IsNotExist(err) { + slog.Warn("subtitle cache stale entry remove failed", "name", name, "error", err) + } + } +} + +// evict is the scan-on-write LRU pass: when committed entries exceed the +// byte budget, the oldest-mtime entries are removed until the total fits. +// It also sweeps orphaned .part temp files older than stalePartMaxAge +// (crash leftovers). No background daemon — commits are rare enough that a +// directory scan per commit is cheap. +func (c *SubtitleCache) evict(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + type cacheEnt struct { + path string + size int64 + mtime time.Time + } + var ( + ents []cacheEnt + total int64 + ) + now := time.Now() + for _, e := range entries { + info, err := e.Info() + if err != nil { + continue + } + path := filepath.Join(dir, e.Name()) + if strings.Contains(e.Name(), ".part-") { + if now.Sub(info.ModTime()) > stalePartMaxAge { + _ = os.Remove(path) + } + continue + } + if !strings.HasSuffix(e.Name(), ".sup") { + continue + } + ents = append(ents, cacheEnt{path: path, size: info.Size(), mtime: info.ModTime()}) + total += info.Size() + } + if total <= c.maxBytes { + return + } + sort.Slice(ents, func(i, j int) bool { return ents[i].mtime.Before(ents[j].mtime) }) + for _, e := range ents { + if total <= c.maxBytes { + break + } + if err := os.Remove(e.path); err != nil { + if !os.IsNotExist(err) { + slog.Warn("subtitle cache eviction remove failed", "path", e.path, "error", err) + } + continue + } + slog.Info("evicted cached subtitle track (LRU)", "path", e.path, "bytes", e.size) + total -= e.size + } +} diff --git a/internal/playback/subtitle_cache_test.go b/internal/playback/subtitle_cache_test.go new file mode 100644 index 00000000..f2255878 --- /dev/null +++ b/internal/playback/subtitle_cache_test.go @@ -0,0 +1,684 @@ +package playback + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// newTestCache builds a cache rooted under a temp transcode dir and returns +// it with the path of a fake source media file. +func newTestCache(t *testing.T) (*SubtitleCache, string) { + t.Helper() + base := t.TempDir() + source := filepath.Join(base, "movie.mkv") + if err := os.WriteFile(source, []byte("fake mkv contents"), 0o644); err != nil { + t.Fatal(err) + } + return NewSubtitleCache(func() string { return base }), source +} + +// fillEntry populates the cache for source+track with the given payload via +// the real BeginFill → Tee → Commit path. +func fillEntry(t *testing.T, c *SubtitleCache, source string, track int, payload string) { + t.Helper() + fill := c.BeginFill(source, track) + if fill == nil { + t.Fatalf("BeginFill returned nil for track %d", track) + } + if _, err := fill.Tee(io.Discard).Write([]byte(payload)); err != nil { + t.Fatalf("tee write: %v", err) + } + if err := fill.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } +} + +// supExtractOpts builds the base extract options a handler would pass to +// ServeSUPExtract for a PGS track. +func supExtractOpts(source string, track int) StreamExtractOpts { + return StreamExtractOpts{ + InputPath: source, + TrackIndex: track, + SourceCodec: "hdmv_pgs_subtitle", + } +} + +// windowedSupOpts builds options for a windowed (?windowed=1) PGS request. +func windowedSupOpts(source string, track int, seek, duration float64) StreamExtractOpts { + opts := supExtractOpts(source, track) + opts.AllowWindow = true + opts.SeekSeconds = seek + opts.DurationSeconds = duration + return opts +} + +// waitForCacheEntry polls until the cache holds a committed entry for +// source+track — used to observe asynchronous background warms. +func waitForCacheEntry(t *testing.T, c *SubtitleCache, source string, track int) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if f, _, ok := c.Lookup(source, track); ok { + _ = f.Close() + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("cache entry for track %d never appeared", track) +} + +func readAllAndClose(t *testing.T, f *os.File) string { + t.Helper() + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestSubtitleCacheMissThenHit(t *testing.T) { + c, source := newTestCache(t) + + if _, _, ok := c.Lookup(source, 0); ok { + t.Fatal("expected miss on empty cache") + } + + fillEntry(t, c, source, 0, "PGS DATA TRACK 0") + + f, modTime, ok := c.Lookup(source, 0) + if !ok { + t.Fatal("expected hit after commit") + } + if got := readAllAndClose(t, f); got != "PGS DATA TRACK 0" { + t.Fatalf("cached content = %q", got) + } + src, err := os.Stat(source) + if err != nil { + t.Fatal(err) + } + if !modTime.Equal(src.ModTime()) { + t.Fatalf("hit modTime = %v, want source mtime %v", modTime, src.ModTime()) + } + + // A different track ordinal is a distinct entry. + if _, _, ok := c.Lookup(source, 1); ok { + t.Fatal("expected miss for uncached track ordinal") + } +} + +func TestSubtitleCacheInvalidatedBySourceMtime(t *testing.T) { + c, source := newTestCache(t) + fillEntry(t, c, source, 0, "old extract") + + newTime := time.Now().Add(2 * time.Hour) + if err := os.Chtimes(source, newTime, newTime); err != nil { + t.Fatal(err) + } + if _, _, ok := c.Lookup(source, 0); ok { + t.Fatal("expected miss after source mtime changed") + } + + // Re-filling under the new source identity overwrites, and the stale + // sibling entry is cleaned up. + fillEntry(t, c, source, 0, "new extract") + f, _, ok := c.Lookup(source, 0) + if !ok { + t.Fatal("expected hit after refill") + } + if got := readAllAndClose(t, f); got != "new extract" { + t.Fatalf("cached content = %q", got) + } + if n := countCacheEntries(t, c); n != 1 { + t.Fatalf("stale sibling not removed: %d entries", n) + } +} + +func TestSubtitleCacheInvalidatedBySourceSize(t *testing.T) { + c, source := newTestCache(t) + fillEntry(t, c, source, 0, "old extract") + + src, err := os.Stat(source) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(source, []byte("different length contents!"), 0o644); err != nil { + t.Fatal(err) + } + // Restore the original mtime so only size differs. + if err := os.Chtimes(source, src.ModTime(), src.ModTime()); err != nil { + t.Fatal(err) + } + if _, _, ok := c.Lookup(source, 0); ok { + t.Fatal("expected miss after source size changed") + } +} + +func TestSubtitleCacheDiscardLeavesNothing(t *testing.T) { + c, source := newTestCache(t) + fill := c.BeginFill(source, 0) + if fill == nil { + t.Fatal("BeginFill returned nil") + } + if _, err := fill.Tee(io.Discard).Write([]byte("partial byt")); err != nil { + t.Fatal(err) + } + fill.Discard() + + if _, _, ok := c.Lookup(source, 0); ok { + t.Fatal("discarded fill must not be served") + } + if n := countCacheFiles(t, c); n != 0 { + t.Fatalf("discard left %d files (temp not removed?)", n) + } +} + +func TestSubtitleCacheCommitRefusesChangedSource(t *testing.T) { + c, source := newTestCache(t) + fill := c.BeginFill(source, 0) + if fill == nil { + t.Fatal("BeginFill returned nil") + } + if _, err := fill.Tee(io.Discard).Write([]byte("extract from old source")); err != nil { + t.Fatal(err) + } + // Source replaced mid-extract. + newTime := time.Now().Add(time.Hour) + if err := os.Chtimes(source, newTime, newTime); err != nil { + t.Fatal(err) + } + if err := fill.Commit(); err == nil { + t.Fatal("Commit must refuse when source changed mid-fill") + } + if n := countCacheFiles(t, c); n != 0 { + t.Fatalf("refused commit left %d files", n) + } +} + +func TestSubtitleCacheTeeWriteFailureKeepsServingClient(t *testing.T) { + c, source := newTestCache(t) + fill := c.BeginFill(source, 0) + if fill == nil { + t.Fatal("BeginFill returned nil") + } + // Force temp-file writes to fail (simulates disk full). + _ = fill.tmp.Close() + + var client strings.Builder + n, err := fill.Tee(&client).Write([]byte("bytes for the viewer")) + if err != nil || n != len("bytes for the viewer") { + t.Fatalf("client write must succeed despite cache failure: n=%d err=%v", n, err) + } + if client.String() != "bytes for the viewer" { + t.Fatalf("client got %q", client.String()) + } + if err := fill.Commit(); err == nil { + t.Fatal("Commit must fail after tee write error") + } + if _, _, ok := c.Lookup(source, 0); ok { + t.Fatal("failed fill must not be served") + } +} + +func TestSubtitleCacheEvictionUnderCap(t *testing.T) { + c, source := newTestCache(t) + c.maxBytes = 25 // each payload below is 10 bytes + + base := time.Now().Add(-time.Hour) + for track := 0; track < 3; track++ { + fillEntry(t, c, source, track, fmt.Sprintf("0123456%03d", track)) + // Pin distinct LRU mtimes: track 0 oldest. + path := entryPath(t, c, source, track) + mt := base.Add(time.Duration(track) * time.Minute) + if err := os.Chtimes(path, mt, mt); err != nil { + t.Fatal(err) + } + } + // 4th commit (10 bytes) pushes the total to 40 > 25; eviction must + // remove the two oldest entries (tracks 0 and 1) to get back to 20. + fillEntry(t, c, source, 3, "0123456003") + + for track, want := range map[int]bool{0: false, 1: false, 2: true, 3: true} { + _, _, ok := c.Lookup(source, track) + if ok != want { + t.Errorf("track %d cached = %v, want %v", track, ok, want) + } + } +} + +func TestSubtitleCacheCoalescing(t *testing.T) { + c, source := newTestCache(t) + + first := c.BeginFill(source, 0) + if first == nil { + t.Fatal("first BeginFill returned nil") + } + if second := c.BeginFill(source, 0); second != nil { + second.Discard() + t.Fatal("second BeginFill for in-flight track must return nil") + } + // A different track is independent. + other := c.BeginFill(source, 1) + if other == nil { + t.Fatal("BeginFill for a different track must not be blocked") + } + other.Discard() + + if _, err := first.Tee(io.Discard).Write([]byte("data")); err != nil { + t.Fatal(err) + } + if err := first.Commit(); err != nil { + t.Fatal(err) + } + // Slot released after commit. + if again := c.BeginFill(source, 0); again == nil { + t.Fatal("BeginFill must work again after Commit") + } else { + again.Discard() + } +} + +func TestSubtitleCacheCoalescingConcurrent(t *testing.T) { + c, source := newTestCache(t) + + const workers = 16 + var ( + wg sync.WaitGroup + mu sync.Mutex + fills []*SubtitleCacheFill + ) + start := make(chan struct{}) + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + if f := c.BeginFill(source, 0); f != nil { + mu.Lock() + fills = append(fills, f) + mu.Unlock() + } + }() + } + close(start) + wg.Wait() + + if len(fills) != 1 { + t.Fatalf("exactly one concurrent BeginFill must win, got %d", len(fills)) + } + fills[0].Discard() +} + +func TestServeSUPExtractCacheFlow(t *testing.T) { + c, source := newTestCache(t) + + extractCalls := 0 + extract := func(_ context.Context, opts StreamExtractOpts) error { + extractCalls++ + _, err := opts.Writer.Write([]byte("SUP PAYLOAD")) + return err + } + + // First request: miss → streamed 200 with no-store, entry committed. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/sub.sup", nil) + if err := c.ServeSUPExtract(rec, req, supExtractOpts(source, 0), extract); err != nil { + t.Fatal(err) + } + if extractCalls != 1 { + t.Fatalf("extract calls = %d", extractCalls) + } + if rec.Body.String() != "SUP PAYLOAD" { + t.Fatalf("miss body = %q", rec.Body.String()) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Fatalf("miss Cache-Control = %q", cc) + } + + // Second request: hit → served from cache, no extract, revalidatable. + rec = httptest.NewRecorder() + if err := c.ServeSUPExtract(rec, req, supExtractOpts(source, 0), extract); err != nil { + t.Fatal(err) + } + if extractCalls != 1 { + t.Fatal("cache hit must not invoke extract") + } + if rec.Body.String() != "SUP PAYLOAD" { + t.Fatalf("hit body = %q", rec.Body.String()) + } + if cc := rec.Header().Get("Cache-Control"); cc != "private, no-cache" { + t.Fatalf("hit Cache-Control = %q", cc) + } + if rec.Header().Get("Last-Modified") == "" { + t.Fatal("hit must carry Last-Modified") + } + if cl := rec.Header().Get("Content-Length"); cl != "11" { + t.Fatalf("hit Content-Length = %q", cl) + } + + // Range request against the cached entry. + rec = httptest.NewRecorder() + rangeReq := httptest.NewRequest(http.MethodGet, "/sub.sup", nil) + rangeReq.Header.Set("Range", "bytes=4-10") + if err := c.ServeSUPExtract(rec, rangeReq, supExtractOpts(source, 0), extract); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusPartialContent || rec.Body.String() != "PAYLOAD" { + t.Fatalf("range: code=%d body=%q", rec.Code, rec.Body.String()) + } +} + +// A windowed request against a cached track must run its extract with the +// cached .sup as input (small file → near-instant window) instead of +// re-demuxing the original media, must never publish its sliced output as a +// cache entry, and must bump the entry's LRU recency. +func TestServeSUPExtractWindowedUsesCachedTrack(t *testing.T) { + c, source := newTestCache(t) + fillEntry(t, c, source, 0, "FULL TRACK") + + // Age the entry so the LRU recency bump is observable. + entry := entryPath(t, c, source, 0) + old := time.Now().Add(-time.Hour) + if err := os.Chtimes(entry, old, old); err != nil { + t.Fatal(err) + } + + var got StreamExtractOpts + extractCalls := 0 + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/sub.sup?windowed=1&position=1200&duration=3600", nil) + err := c.ServeSUPExtract(rec, req, windowedSupOpts(source, 0, 1200, 3600), func(_ context.Context, opts StreamExtractOpts) error { + extractCalls++ + got = opts + _, err := opts.Writer.Write([]byte("WINDOW SLICE")) + return err + }) + if err != nil { + t.Fatal(err) + } + if extractCalls != 1 { + t.Fatalf("extract calls = %d", extractCalls) + } + if rec.Body.String() != "WINDOW SLICE" { + t.Fatalf("windowed body = %q", rec.Body.String()) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Fatalf("windowed Cache-Control = %q", cc) + } + if got.InputPath != entry { + t.Fatalf("windowed extract input = %q, want cached entry %q", got.InputPath, entry) + } + if !got.InputIsExtractedSup { + t.Fatal("windowed extract from cache must set InputIsExtractedSup") + } + if got.SeekSeconds != 1200 || got.DurationSeconds != 3600 || !got.AllowWindow { + t.Fatalf("window parameters not preserved: %+v", got) + } + + // The full-track entry must be untouched, with recency bumped. + f, _, ok := c.Lookup(source, 0) + if !ok { + t.Fatal("full-track entry lost") + } + if content := readAllAndClose(t, f); content != "FULL TRACK" { + t.Fatalf("full-track entry corrupted: %q", content) + } + info, err := os.Stat(entry) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().After(old.Add(time.Minute)) { + t.Fatalf("windowed serve must bump LRU recency: mtime = %v", info.ModTime()) + } +} + +// A windowed miss must trigger exactly one detached background warm no +// matter how many windowed requests arrive while it runs, and once the warm +// commits, the next windowed request extracts from the cached track. +func TestServeSUPExtractWindowedMissWarmsOnce(t *testing.T) { + c, source := newTestCache(t) + + var ( + mu sync.Mutex + warmOpts []StreamExtractOpts + windowOpts []StreamExtractOpts + warmRelease = make(chan struct{}) + ) + extract := func(_ context.Context, opts StreamExtractOpts) error { + if opts.AllowWindow { + mu.Lock() + windowOpts = append(windowOpts, opts) + mu.Unlock() + _, err := opts.Writer.Write([]byte("WINDOW SLICE")) + return err + } + mu.Lock() + warmOpts = append(warmOpts, opts) + mu.Unlock() + <-warmRelease + _, err := opts.Writer.Write([]byte("FULL TRACK")) + return err + } + + // N windowed misses: each still streams its own windowed slice from the + // original file; only the first starts a warm (BeginFill coalescing keeps + // the rest out — deterministic because the in-flight slot is reserved + // synchronously before ServeSUPExtract returns). + for i := 0; i < 4; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/sub.sup?windowed=1&position=100&duration=3600", nil) + if err := c.ServeSUPExtract(rec, req, windowedSupOpts(source, 0, 100, 3600), extract); err != nil { + t.Fatal(err) + } + if rec.Body.String() != "WINDOW SLICE" { + t.Fatalf("windowed body = %q", rec.Body.String()) + } + } + close(warmRelease) + waitForCacheEntry(t, c, source, 0) + + mu.Lock() + if len(warmOpts) != 1 { + t.Fatalf("warm extracts = %d, want exactly 1", len(warmOpts)) + } + warm := warmOpts[0] + if warm.InputPath != source || warm.SeekSeconds != 0 || warm.DurationSeconds != 0 || warm.AllowWindow || warm.InputIsExtractedSup { + t.Fatalf("warm must be a full-track extract of the original file: %+v", warm) + } + if len(windowOpts) != 4 { + t.Fatalf("windowed extracts = %d, want 4", len(windowOpts)) + } + for _, wo := range windowOpts { + if wo.InputPath != source || wo.InputIsExtractedSup { + t.Fatalf("pre-warm windowed extract must read the original file: %+v", wo) + } + } + mu.Unlock() + + // Warm committed → the next windowed request reads the cached track. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/sub.sup?windowed=1&position=200&duration=3600", nil) + if err := c.ServeSUPExtract(rec, req, windowedSupOpts(source, 0, 200, 3600), extract); err != nil { + t.Fatal(err) + } + mu.Lock() + last := windowOpts[len(windowOpts)-1] + mu.Unlock() + if last.InputPath != entryPath(t, c, source, 0) || !last.InputIsExtractedSup { + t.Fatalf("post-warm windowed extract must read the cached track: %+v", last) + } +} + +// Warms beyond the server-wide slot budget are dropped, not queued, and a +// dropped warm must not leave an in-flight reservation behind. +func TestWarmInBackgroundSemaphoreDrop(t *testing.T) { + c, source := newTestCache(t) + + release := make(chan struct{}) + extract := func(_ context.Context, opts StreamExtractOpts) error { + <-release + _, err := opts.Writer.Write([]byte("FULL TRACK")) + return err + } + + // Occupy every warm slot (slots are acquired synchronously). + for track := 0; track < subtitleCacheWarmSlots; track++ { + c.WarmInBackground(supExtractOpts(source, track), extract) + } + // One more: dropped without reserving the track's in-flight slot. + overflow := subtitleCacheWarmSlots + c.WarmInBackground(supExtractOpts(source, overflow), extract) + if fill := c.BeginFill(source, overflow); fill == nil { + t.Fatal("dropped warm must not hold the in-flight slot") + } else { + fill.Discard() + } + + close(release) + for track := 0; track < subtitleCacheWarmSlots; track++ { + waitForCacheEntry(t, c, source, track) + } + if _, _, ok := c.Lookup(source, overflow); ok { + t.Fatal("dropped warm must not populate the cache") + } + + // With slots free again, the overflow track's warm goes through. + c.WarmInBackground(supExtractOpts(source, overflow), extract) + waitForCacheEntry(t, c, source, overflow) +} + +// A warm that races an already-in-flight client fill must skip (BeginFill +// coalescing) and release its warm slot for other tracks. +func TestWarmInBackgroundSkipsInFlightFill(t *testing.T) { + c, source := newTestCache(t) + + clientFill := c.BeginFill(source, 0) + if clientFill == nil { + t.Fatal("BeginFill returned nil") + } + warmed := make(chan struct{}, 1) + c.WarmInBackground(supExtractOpts(source, 0), func(_ context.Context, opts StreamExtractOpts) error { + warmed <- struct{}{} + _, err := opts.Writer.Write([]byte("WARM")) + return err + }) + + // The skipped warm must have released its slot synchronously: all + // subtitleCacheWarmSlots slots are still available. + for track := 1; track <= subtitleCacheWarmSlots; track++ { + c.WarmInBackground(supExtractOpts(source, track), func(_ context.Context, opts StreamExtractOpts) error { + _, err := opts.Writer.Write([]byte("FULL TRACK")) + return err + }) + } + for track := 1; track <= subtitleCacheWarmSlots; track++ { + waitForCacheEntry(t, c, source, track) + } + + select { + case <-warmed: + t.Fatal("warm for an in-flight track must not run") + default: + } + clientFill.Discard() +} + +func TestServeSUPExtractDiscardsOnExtractError(t *testing.T) { + c, source := newTestCache(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/sub.sup", nil) + wantErr := errors.New("ffmpeg exploded") + err := c.ServeSUPExtract(rec, req, supExtractOpts(source, 0), func(_ context.Context, opts StreamExtractOpts) error { + _, _ = opts.Writer.Write([]byte("PARTIAL")) + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("err = %v", err) + } + if _, _, ok := c.Lookup(source, 0); ok { + t.Fatal("partial extract must not be cached") + } + if n := countCacheFiles(t, c); n != 0 { + t.Fatalf("failed extract left %d files", n) + } +} + +func TestServeSUPExtractNilCacheStreams(t *testing.T) { + var c *SubtitleCache + extract := func(_ context.Context, opts StreamExtractOpts) error { + _, err := opts.Writer.Write([]byte("UNCACHED")) + return err + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/sub.sup", nil) + if err := c.ServeSUPExtract(rec, req, supExtractOpts("/nonexistent.mkv", 0), extract); err != nil { + t.Fatal(err) + } + if rec.Body.String() != "UNCACHED" { + t.Fatalf("body = %q", rec.Body.String()) + } + + // Windowed requests on a nil cache stream too (no lookup, no warm). + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/sub.sup?windowed=1&position=10", nil) + if err := c.ServeSUPExtract(rec, req, windowedSupOpts("/nonexistent.mkv", 0, 10, 3600), extract); err != nil { + t.Fatal(err) + } + if rec.Body.String() != "UNCACHED" { + t.Fatalf("windowed body = %q", rec.Body.String()) + } +} + +// entryPath computes the committed entry path for source+track. +func entryPath(t *testing.T, c *SubtitleCache, source string, track int) string { + t.Helper() + src, err := os.Stat(source) + if err != nil { + t.Fatal(err) + } + return filepath.Join(c.dir(), subtitleCacheKey(source, track, src.ModTime(), src.Size())) +} + +// countCacheEntries counts committed .sup entries in the cache dir. +func countCacheEntries(t *testing.T, c *SubtitleCache) int { + t.Helper() + return countMatching(t, c, func(name string) bool { + return strings.HasSuffix(name, ".sup") && !strings.Contains(name, ".part-") + }) +} + +// countCacheFiles counts every file in the cache dir, temp files included. +func countCacheFiles(t *testing.T, c *SubtitleCache) int { + t.Helper() + return countMatching(t, c, func(string) bool { return true }) +} + +func countMatching(t *testing.T, c *SubtitleCache, match func(string) bool) int { + t.Helper() + entries, err := os.ReadDir(c.dir()) + if os.IsNotExist(err) { + return 0 + } + if err != nil { + t.Fatal(err) + } + n := 0 + for _, e := range entries { + if match(e.Name()) { + n++ + } + } + return n +} diff --git a/internal/playback/subtitle_stream.go b/internal/playback/subtitle_stream.go index 5af32c5d..2f95a13f 100644 --- a/internal/playback/subtitle_stream.go +++ b/internal/playback/subtitle_stream.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "os/exec" "strconv" "strings" @@ -37,6 +38,26 @@ type StreamExtractOpts struct { // while keeping memory and in-flight state finite; the client // requests subsequent windows as playback approaches the tail. DurationSeconds float64 + // AllowWindow lets SeekSeconds/DurationSeconds apply to PGS extracts. + // By default PGS is never windowed because clients fetch the .sup + // stream exactly once and consume it whole; a client that explicitly + // opts in (via ?windowed=1) re-requests fresh windows itself as + // playback moves outside coverage. ASS ignores this flag — its + // [Script Info] header exists only at stream offset 0, so a seeked + // extract would be structurally broken. + AllowWindow bool + // InputIsExtractedSup marks InputPath as a cached full-track .sup + // elementary stream (a previous full extract, produced with -copyts so + // its timestamps are absolute source PTS) rather than the original + // media container. The input format is forced with `-f sup` — the + // headerless stream is probeable via its "PG" magic, but an explicit + // format is robust against probe-size edge cases — and the stream + // mapping is forced to `0:s:0`: a .sup holds exactly one stream, so + // TrackIndex (which names the ordinal in the *original* container) no + // longer applies. Seeking such an input with -copyts re-emits the same + // absolute timestamps, so windowed output is byte-compatible with a + // window cut from the original file. + InputIsExtractedSup bool // FFmpegPath overrides the ffmpeg binary lookup. FFmpegPath string // Writer receives ffmpeg's stdout bytes as they arrive. When it @@ -121,12 +142,14 @@ func streamExtractArgs(opts StreamExtractOpts) []string { // Input seek (before -i) is the fast variant: ffmpeg jumps near the // requested position before demuxing. ASS can't use it because the // output needs the [Script Info] header which only sits at offset 0. - // PGS can't either: the client (libpgs) fetches the .sup stream - // exactly once and consumes it whole, so the output must cover the - // complete track from offset 0 with original timestamps — windowing - // would silently drop every cue outside the window. The same logic - // excludes both from the -t duration cap below. - windowable := !IsASS(opts.SourceCodec) && !IsPGS(opts.SourceCodec) + // PGS defaults to non-windowed too: a client that fetches the .sup + // stream exactly once and consumes it whole needs the complete track + // from offset 0 — windowing would silently drop every cue outside + // the window. Clients that manage their own sliding window opt in + // via AllowWindow; -copyts below keeps the windowed output on + // absolute source timestamps so cues stay in sync. The same logic + // governs the -t duration cap below. + windowable := !IsASS(opts.SourceCodec) && (!IsPGS(opts.SourceCodec) || opts.AllowWindow) seekApplied := opts.SeekSeconds > 0 && windowable if seekApplied { args = append(args, "-ss", strconv.FormatFloat(opts.SeekSeconds, 'f', 3, 64)) @@ -141,9 +164,16 @@ func streamExtractArgs(opts StreamExtractOpts) []string { args = append(args, "-t", strconv.FormatFloat(opts.DurationSeconds, 'f', 3, 64)) } + // A cached .sup input has no container magic worth probing and exactly + // one stream: force the demuxer and remap to the sole stream ordinal. + trackIndex := opts.TrackIndex + if opts.InputIsExtractedSup { + args = append(args, "-f", "sup") + trackIndex = 0 + } args = append(args, "-i", opts.InputPath, - "-map", fmt.Sprintf("0:s:%d", opts.TrackIndex), + "-map", fmt.Sprintf("0:s:%d", trackIndex), "-c:s", outCodec, ) @@ -162,6 +192,34 @@ func streamExtractArgs(opts StreamExtractOpts) []string { ) } +// PGSWindowRequest reports whether a subtitle request explicitly opts in +// to windowed PGS extraction (?windowed=1) and, if so, the seek position +// and window duration to use. Only explicit query params count — there is +// deliberately no session-position fallback, because a client that did +// not ask for a window expects the complete track from offset 0 and would +// silently lose every cue outside an implicit window. Absent or invalid +// params leave the existing (non-windowed) behavior byte-identical. +// +// Shared by the API stream handler and the standalone proxy so both +// endpoints gate the window identically. +func PGSWindowRequest(q url.Values) (allow bool, seekSeconds, durationSeconds float64) { + if q.Get("windowed") != "1" { + return false, 0, 0 + } + const maxDuration = 3600.0 + if raw := q.Get("position"); raw != "" { + if v, err := strconv.ParseFloat(raw, 64); err == nil && v >= 0 { + seekSeconds = v + } + } + if raw := q.Get("duration"); raw != "" { + if v, err := strconv.ParseFloat(raw, 64); err == nil && v > 0 && v <= maxDuration { + durationSeconds = v + } + } + return true, seekSeconds, durationSeconds +} + // copyAndFlush streams from src to dst in 32KB chunks, calling Flush on // dst after each successful write when dst implements http.Flusher. func copyAndFlush(dst io.Writer, src io.Reader) error { diff --git a/internal/playback/subtitle_stream_test.go b/internal/playback/subtitle_stream_test.go index 85af1e00..26c2bd07 100644 --- a/internal/playback/subtitle_stream_test.go +++ b/internal/playback/subtitle_stream_test.go @@ -1,6 +1,7 @@ package playback import ( + "net/url" "slices" "strings" "testing" @@ -74,8 +75,8 @@ func TestStreamExtractArgs_TextCodecIsWindowed(t *testing.T) { } // ASS and PGS streams are fetched once and consumed whole by their -// client-side renderers, so seek/duration windowing must never apply even -// when the handler passes nonzero values. +// client-side renderers, so by default seek/duration windowing must never +// apply even when the handler passes nonzero values. func TestStreamExtractArgs_WholeTrackCodecsIgnoreWindow(t *testing.T) { for _, codec := range []string{"ass", "hdmv_pgs_subtitle"} { args := streamExtractArgs(StreamExtractOpts{ @@ -98,6 +99,136 @@ func TestStreamExtractArgs_WholeTrackCodecsIgnoreWindow(t *testing.T) { } } +// A client that opts in via AllowWindow gets a seeked, duration-capped PGS +// extract with -copyts preserving absolute source timestamps — the -ss must +// be an input option (before -i) so ffmpeg uses the container index. +func TestStreamExtractArgs_WindowedPGS(t *testing.T) { + args := streamExtractArgs(StreamExtractOpts{ + InputPath: "/media/movie.mkv", + TrackIndex: 1, + SourceCodec: "hdmv_pgs_subtitle", + SeekSeconds: 1200, + DurationSeconds: 3600, + AllowWindow: true, + }) + + joined := strings.Join(args, " ") + if !strings.Contains(joined, "-ss 1200.000") { + t.Fatalf("windowed PGS extract should seek the input: %s", joined) + } + ssIdx := slices.Index(args, "-ss") + inIdx := slices.Index(args, "-i") + if ssIdx < 0 || inIdx < 0 || ssIdx > inIdx { + t.Fatalf("-ss must be an input option (before -i): %s", joined) + } + if !strings.Contains(joined, "-t 3600.000") { + t.Fatalf("windowed PGS extract should cap the read duration: %s", joined) + } + if !strings.Contains(joined, "-copyts") { + t.Fatalf("windowed PGS extract must preserve source timestamps: %s", joined) + } + if !strings.Contains(joined, "-c:s copy") || !strings.Contains(joined, "-f sup pipe:1") { + t.Fatalf("windowed PGS extract should still copy into a sup stream: %s", joined) + } +} + +// A windowed extract whose input is a cached full-track .sup must force the +// sup demuxer (the elementary stream has no container header to probe from +// arbitrary offsets), remap to the file's sole stream regardless of the +// original container's track ordinal, and still seek/window with -copyts so +// the cached stream's absolute timestamps survive into the output. +func TestStreamExtractArgs_ExtractedSupInput(t *testing.T) { + args := streamExtractArgs(StreamExtractOpts{ + InputPath: "/transcode/subtitle-cache/abc-s3-1-2.sup", + TrackIndex: 3, + SourceCodec: "hdmv_pgs_subtitle", + SeekSeconds: 1200, + DurationSeconds: 3600, + AllowWindow: true, + InputIsExtractedSup: true, + }) + + joined := strings.Join(args, " ") + if !strings.Contains(joined, "-f sup -i /transcode/subtitle-cache/abc-s3-1-2.sup") { + t.Fatalf("cached sup input must force the sup demuxer before -i: %s", joined) + } + if !strings.Contains(joined, "-map 0:s:0") { + t.Fatalf("cached sup holds exactly one stream; must map 0:s:0: %s", joined) + } + if strings.Contains(joined, "0:s:3") { + t.Fatalf("original container track ordinal must not leak into sup input mapping: %s", joined) + } + if !strings.Contains(joined, "-ss 1200.000") || !strings.Contains(joined, "-t 3600.000") { + t.Fatalf("cached sup extract must still window the input: %s", joined) + } + ssIdx := slices.Index(args, "-ss") + inIdx := slices.Index(args, "-i") + if ssIdx < 0 || inIdx < 0 || ssIdx > inIdx { + t.Fatalf("-ss must be an input option (before -i): %s", joined) + } + if !strings.Contains(joined, "-copyts") { + t.Fatalf("cached sup extract must preserve absolute timestamps: %s", joined) + } + if !strings.Contains(joined, "-c:s copy") || !strings.Contains(joined, "-f sup pipe:1") { + t.Fatalf("cached sup extract should copy into a sup stream: %s", joined) + } +} + +// AllowWindow must not override the ASS guard — its [Script Info] header +// only exists at stream offset 0, so a seeked extract would be broken. +func TestStreamExtractArgs_ASSIgnoresAllowWindow(t *testing.T) { + args := streamExtractArgs(StreamExtractOpts{ + InputPath: "/media/movie.mkv", + TrackIndex: 0, + SourceCodec: "ass", + SeekSeconds: 120, + DurationSeconds: 600, + AllowWindow: true, + }) + + if slices.Contains(args, "-ss") { + t.Errorf("ass extract must not seek the input even with AllowWindow: %v", args) + } + if slices.Contains(args, "-t") { + t.Errorf("ass extract must not cap the read duration even with AllowWindow: %v", args) + } +} + +// Absent the explicit ?windowed=1 opt-in the request must not window, no +// matter what other params are present — existing clients (Apple, Android, +// jellycompat) send no param and rely on whole-track extraction. +func TestPGSWindowRequest(t *testing.T) { + cases := []struct { + name string + query string + wantAllow bool + wantSeek float64 + wantDuration float64 + }{ + {"no params", "", false, 0, 0}, + {"position without opt-in", "position=120&duration=600", false, 0, 0}, + {"windowed off", "windowed=0&position=120", false, 0, 0}, + {"opt-in with position and duration", "windowed=1&position=120.5&duration=3600", true, 120.5, 3600}, + {"opt-in without position", "windowed=1", true, 0, 0}, + {"opt-in negative position ignored", "windowed=1&position=-5&duration=600", true, 0, 600}, + {"opt-in duration over cap ignored", "windowed=1&position=10&duration=7200", true, 10, 0}, + {"opt-in invalid values ignored", "windowed=1&position=abc&duration=xyz", true, 0, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + q, err := url.ParseQuery(tc.query) + if err != nil { + t.Fatalf("ParseQuery(%q): %v", tc.query, err) + } + allow, seek, duration := PGSWindowRequest(q) + if allow != tc.wantAllow || seek != tc.wantSeek || duration != tc.wantDuration { + t.Errorf("PGSWindowRequest(%q) = (%v, %v, %v), want (%v, %v, %v)", + tc.query, allow, seek, duration, tc.wantAllow, tc.wantSeek, tc.wantDuration) + } + }) + } +} + func TestStreamExtractArgs_PGSProducesSup(t *testing.T) { args := streamExtractArgs(StreamExtractOpts{ InputPath: "/media/movie.mkv", diff --git a/internal/playback/subtitles.go b/internal/playback/subtitles.go index f870e1a0..75a48c8c 100644 --- a/internal/playback/subtitles.go +++ b/internal/playback/subtitles.go @@ -28,8 +28,9 @@ func NeedsBurnIn(subtitleCodec string) bool { // pgsSubtitleCodecs lists PGS (Blu-ray bitmap) subtitle codec names. Unlike // other bitmap codecs, PGS can be extracted losslessly to a .sup elementary -// stream and rendered client-side (libpgs in the web player), so burn-in is -// not the only delivery option. DVD/DVB bitmap subs still require burn-in. +// stream for capable native clients, so burn-in is not the only delivery +// option. The web player burns in all bitmap codecs; DVD/DVB bitmap subs also +// require burn-in for native clients that cannot render them directly. var pgsSubtitleCodecs = map[string]bool{ "pgs": true, "hdmv_pgs_subtitle": true, diff --git a/internal/playback/transcode.go b/internal/playback/transcode.go index 68208555..07515fdd 100644 --- a/internal/playback/transcode.go +++ b/internal/playback/transcode.go @@ -43,13 +43,18 @@ type TranscodeOpts struct { HWDevice string // e.g., /dev/dri/renderD128 (default if empty) SubtitleTrackIndex int // -1 = no subtitles SubtitleBurnIn bool - AudioTrackIndex int // -1 = default (first track), >= 0 = specific track - TargetBitrateKbps int // max video bitrate in kbps; 0 = CRF-only (no cap) - TotalDuration float64 // total media duration in seconds (for VOD manifest) - FastStart bool // use superfast preset for faster first-segment production - NodeType string - ExecutionMode string - FFmpegLogSink FFmpegLogSink + // SubtitleCodec is the probed codec of the burn-in track (e.g. "subrip", + // "hdmv_pgs_subtitle"). Bitmap codecs (PGS/DVD/DVB) select the overlay + // filter_complex pipeline; text codecs use the libass subtitles filter. + // Empty preserves the legacy text path for callers minted before the field. + SubtitleCodec string + AudioTrackIndex int // -1 = default (first track), >= 0 = specific track + TargetBitrateKbps int // max video bitrate in kbps; 0 = CRF-only (no cap) + TotalDuration float64 // total media duration in seconds (for VOD manifest) + FastStart bool // use superfast preset for faster first-segment production + NodeType string + ExecutionMode string + FFmpegLogSink FFmpegLogSink } // TranscodeSession manages a running ffmpeg HLS transcode process. @@ -279,7 +284,7 @@ func buildFFmpegArgs(opts TranscodeOpts) []string { args = append(args, "-i", opts.InputPath) args = append(args, "-map_metadata", "-1") args = append(args, "-map_chapters", "-1") - args = appendStreamSelectionArgs(args, opts.AudioTrackIndex) + args = appendStreamSelectionArgs(args, opts) args = appendTimestampNormalizationArgs(args, opts) // Video codec and encoding settings. @@ -366,9 +371,29 @@ func resolveEffectiveTranscodeHWAccel(opts TranscodeOpts) string { return hwAccel } +// bitmapBurnInActive reports whether this transcode composites a bitmap +// subtitle track (PGS/VOBSUB/DVB) into the video via the overlay +// filter_complex pipeline. Bitmap burn-in requires a video encode: copy-video +// sessions never activate it (the API layer forces an encoding recipe before +// starting a burn-in transcode, this is a defensive backstop). +func bitmapBurnInActive(opts TranscodeOpts) bool { + return opts.SubtitleBurnIn && + opts.SubtitleTrackIndex >= 0 && + NeedsBurnIn(opts.SubtitleCodec) && + !strings.EqualFold(opts.TargetCodecVideo, "copy") +} + // appendStreamSelectionArgs limits output to primary video/audio streams. -func appendStreamSelectionArgs(args []string, audioTrackIndex int) []string { - args = append(args, "-map", "0:v:0") +// When bitmap burn-in is active the video output comes from the overlay +// filter_complex graph's labeled pad instead of the raw input stream — +// mapping both would emit two video streams into the HLS mux. +func appendStreamSelectionArgs(args []string, opts TranscodeOpts) []string { + audioTrackIndex := opts.AudioTrackIndex + if bitmapBurnInActive(opts) { + args = append(args, "-map", "[vout]") + } else { + args = append(args, "-map", "0:v:0") + } if audioTrackIndex >= 0 { args = append(args, "-map", fmt.Sprintf("0:a:%d?", audioTrackIndex)) } else { @@ -589,6 +614,8 @@ func appendVideoArgs(args []string, opts TranscodeOpts) []string { // in only one of them silently ships wrong cached artifacts). func appendVideoFilterArgs(args []string, opts TranscodeOpts) []string { switch { + case bitmapBurnInActive(opts): + return appendBitmapSubtitleBurnInArgs(args, opts) case opts.SubtitleBurnIn && opts.SubtitleTrackIndex >= 0: return appendSubtitleBurnInArgs(args, opts) case opts.HWAccel == "qsv": @@ -635,7 +662,53 @@ func appendAudioArgs(args []string, opts TranscodeOpts) []string { return args } -// appendSubtitleBurnInArgs adds subtitle burn-in filter arguments. +// appendBitmapSubtitleBurnInArgs adds burn-in arguments for BITMAP subtitle +// codecs (PGS/VOBSUB/DVB). libass's subtitles= filter cannot render bitmap +// tracks, so the decoded subtitle stream is composited onto the video with +// overlay in a -filter_complex graph (the "Plex route"). The graph's output +// pad [vout] replaces the raw video stream in stream mapping (see +// appendStreamSelectionArgs), so -vf must never be emitted alongside this. +// +// Overlay runs at the source's native resolution FIRST and any target scaling +// happens after, so bitmap subtitle geometry is never distorted by a +// pre-scaling mismatch between the video and subtitle planes. +// eof_action=pass keeps the video flowing untouched once the subtitle stream +// ends instead of freezing the last overlay frame on screen. +// +// Hardware pipelines mirror appendSubtitleBurnInArgs: frames are downloaded +// to CPU memory for the overlay, then re-uploaded for the hardware encoder. +func appendBitmapSubtitleBurnInArgs(args []string, opts TranscodeOpts) []string { + // [0:s:N] indexes subtitle streams only, matching the si=N semantics of + // the text path — SubtitleTrackIndex is the embedded subtitle ordinal. + cpuFilters := fmt.Sprintf("[0:s:%d]overlay=eof_action=pass", opts.SubtitleTrackIndex) + if scale := resolutionToScale(opts.TargetResolution); scale != "" { + cpuFilters += "," + scale + } + + var graph string + switch opts.HWAccel { + case "qsv": + // VAAPI→QSV pipeline: download decoded frames to CPU, overlay, convert + // to nv12, upload back to VAAPI, then map to QSV for the encoder. + graph = "[0:v:0]hwdownload,format=yuv420p[vmain];[vmain]" + cpuFilters + + ",format=nv12,hwupload,hwmap=derive_device=qsv,format=qsv[vout]" + case "vaapi": + graph = "[0:v:0]hwdownload,format=yuv420p[vmain];[vmain]" + cpuFilters + + ",format=nv12,hwupload[vout]" + case "nvenc": + graph = "[0:v:0]hwdownload,format=yuv420p[vmain];[vmain]" + cpuFilters + + ",format=nv12,hwupload_cuda[vout]" + default: + // CPU encoding: overlay directly on decoded frames. + graph = "[0:v:0]" + cpuFilters + "[vout]" + } + + return append(args, "-filter_complex", graph) +} + +// appendSubtitleBurnInArgs adds subtitle burn-in filter arguments for TEXT +// subtitle codecs (SRT/ASS/…) via the libass-based subtitles= filter; bitmap +// codecs take the overlay path in appendBitmapSubtitleBurnInArgs. // For CPU encoding, the filter chain is: [scale,]subtitles. // For QSV/VAAPI, frames must be downloaded from hardware, processed on CPU, // then re-uploaded: hwdownload → format=yuv420p → [scale,] subtitles → hwupload → hwmap. diff --git a/internal/playback/transcode_args_test.go b/internal/playback/transcode_args_test.go index dd3846d8..b72f82aa 100644 --- a/internal/playback/transcode_args_test.go +++ b/internal/playback/transcode_args_test.go @@ -211,6 +211,181 @@ func TestBuildFFmpegArgs_MPEG4Part2DisablesHardwareDecode(t *testing.T) { } } +func TestBuildFFmpegArgs_BitmapBurnInCPUUsesOverlayFilterComplex(t *testing.T) { + args := buildFFmpegArgs(TranscodeOpts{ + InputPath: "/media/movie.mkv", + OutputDir: "/tmp/out", + SessionID: "session-pgs", + SourceVideoCodec: "h264", + TargetCodecVideo: "h264", + TargetCodecAudio: "aac", + SegmentDuration: 2, + HWAccel: "none", + TargetResolution: "1080p", + SubtitleTrackIndex: 2, + SubtitleBurnIn: true, + SubtitleCodec: "hdmv_pgs_subtitle", + }) + + joined := strings.Join(args, " ") + // Overlay runs at native resolution first, then scales. + want := "-filter_complex [0:v:0][0:s:2]overlay=eof_action=pass,scale=-2:1080[vout]" + if !strings.Contains(joined, want) { + t.Fatalf("bitmap burn-in should use overlay filter_complex %q: %s", want, joined) + } + // The graph output replaces the raw video stream mapping. + if !strings.Contains(joined, "-map [vout]") { + t.Fatalf("bitmap burn-in should map the filter graph output: %s", joined) + } + if strings.Contains(joined, "-map 0:v:0") { + t.Fatalf("bitmap burn-in must not also map the raw video stream: %s", joined) + } + // -vf and -filter_complex on the same video stream is an ffmpeg error. + if strings.Contains(joined, "-vf ") { + t.Fatalf("bitmap burn-in must not emit -vf alongside -filter_complex: %s", joined) + } + if strings.Contains(joined, "subtitles=") { + t.Fatalf("bitmap burn-in must not use the libass subtitles filter: %s", joined) + } + if !strings.Contains(joined, "-c:v libx264") { + t.Fatalf("bitmap burn-in requires a video encode: %s", joined) + } +} + +func TestBuildFFmpegArgs_BitmapBurnInNoScaleKeepsNativeResolution(t *testing.T) { + args := buildFFmpegArgs(TranscodeOpts{ + InputPath: "/media/movie.mkv", + OutputDir: "/tmp/out", + SessionID: "session-pgs-native", + TargetCodecVideo: "h264", + TargetCodecAudio: "aac", + SegmentDuration: 2, + HWAccel: "none", + SubtitleTrackIndex: 0, + SubtitleBurnIn: true, + SubtitleCodec: "dvd_subtitle", + }) + + joined := strings.Join(args, " ") + if !strings.Contains(joined, "-filter_complex [0:v:0][0:s:0]overlay=eof_action=pass[vout]") { + t.Fatalf("native-resolution bitmap burn-in should overlay without scaling: %s", joined) + } +} + +func TestBuildFFmpegArgs_BitmapBurnInVAAPIRoundTripsThroughCPU(t *testing.T) { + args := buildFFmpegArgs(TranscodeOpts{ + InputPath: "/media/movie.mkv", + OutputDir: "/tmp/out", + SessionID: "session-pgs-vaapi", + SourceVideoCodec: "h264", + TargetCodecVideo: "h264", + TargetCodecAudio: "aac", + SegmentDuration: 2, + HWAccel: "vaapi", + TargetResolution: "720p", + SubtitleTrackIndex: 1, + SubtitleBurnIn: true, + SubtitleCodec: "hdmv_pgs_subtitle", + }) + + joined := strings.Join(args, " ") + want := "-filter_complex [0:v:0]hwdownload,format=yuv420p[vmain];[vmain][0:s:1]overlay=eof_action=pass,scale=-2:720,format=nv12,hwupload[vout]" + if !strings.Contains(joined, want) { + t.Fatalf("vaapi bitmap burn-in should hwdownload → overlay → hwupload %q: %s", want, joined) + } + if !strings.Contains(joined, "-map [vout]") { + t.Fatalf("vaapi bitmap burn-in should map the filter graph output: %s", joined) + } + if strings.Contains(joined, "-vf ") { + t.Fatalf("vaapi bitmap burn-in must not emit -vf: %s", joined) + } + if !strings.Contains(joined, "-c:v h264_vaapi") { + t.Fatalf("vaapi bitmap burn-in should keep the hardware encoder: %s", joined) + } +} + +func TestBuildFFmpegArgs_TextBurnInStillUsesSubtitlesFilter(t *testing.T) { + args := buildFFmpegArgs(TranscodeOpts{ + InputPath: "/media/movie.mkv", + OutputDir: "/tmp/out", + SessionID: "session-srt", + TargetCodecVideo: "h264", + TargetCodecAudio: "aac", + SegmentDuration: 2, + HWAccel: "none", + TargetResolution: "1080p", + SubtitleTrackIndex: 1, + SubtitleBurnIn: true, + SubtitleCodec: "subrip", + }) + + joined := strings.Join(args, " ") + if !strings.Contains(joined, "-vf scale=-2:1080,subtitles='/media/movie.mkv':si=1") { + t.Fatalf("text burn-in should keep the libass subtitles -vf path: %s", joined) + } + if strings.Contains(joined, "-filter_complex") { + t.Fatalf("text burn-in must not switch to filter_complex: %s", joined) + } + if !strings.Contains(joined, "-map 0:v:0") { + t.Fatalf("text burn-in should keep the raw video stream mapping: %s", joined) + } +} + +func TestBuildFFmpegArgs_LegacyBurnInWithoutCodecKeepsTextPath(t *testing.T) { + // Recipe cards / tokens minted before SubtitleCodec existed decode with an + // empty codec; they must reconstruct the exact same (text) command line. + args := buildFFmpegArgs(TranscodeOpts{ + InputPath: "/media/movie.mkv", + OutputDir: "/tmp/out", + SessionID: "session-legacy", + TargetCodecVideo: "h264", + TargetCodecAudio: "aac", + SegmentDuration: 2, + HWAccel: "none", + SubtitleTrackIndex: 0, + SubtitleBurnIn: true, + }) + + joined := strings.Join(args, " ") + if !strings.Contains(joined, "subtitles='/media/movie.mkv':si=0") { + t.Fatalf("legacy burn-in without codec should keep the subtitles filter: %s", joined) + } + if strings.Contains(joined, "-filter_complex") { + t.Fatalf("legacy burn-in without codec must not use filter_complex: %s", joined) + } +} + +func TestBuildFFmpegArgs_BitmapBurnInWithCopyVideoIsInert(t *testing.T) { + // The API layer forces an encode before starting a burn-in transcode; if a + // copy recipe slips through anyway the builder must stay a valid copy + // command (no filter graph, raw stream mapping) rather than emit filters + // against an unencoded stream. + args := buildFFmpegArgs(TranscodeOpts{ + InputPath: "/media/movie.mkv", + OutputDir: "/tmp/out", + SessionID: "session-copy-burnin", + TargetCodecVideo: "copy", + TargetCodecAudio: "aac", + SegmentDuration: 2, + SubtitleTrackIndex: 0, + SubtitleBurnIn: true, + SubtitleCodec: "hdmv_pgs_subtitle", + }) + + joined := strings.Join(args, " ") + if !strings.Contains(joined, "-c:v copy") { + t.Fatalf("copy recipe should stay codec copy: %s", joined) + } + // Note: "-filter_complex_threads" is a legitimate copy-mode arg; only the + // filter graph option itself must be absent. + if strings.Contains(joined, "-filter_complex ") || strings.Contains(joined, "overlay") { + t.Fatalf("copy recipe must not emit a filter graph: %s", joined) + } + if !strings.Contains(joined, "-map 0:v:0") { + t.Fatalf("copy recipe should map the raw video stream: %s", joined) + } +} + func TestResolveEffectiveTranscodeHWAccel(t *testing.T) { t.Parallel() diff --git a/internal/proxy/server.go b/internal/proxy/server.go index ec7f6524..46801c67 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -25,6 +25,9 @@ type Server struct { tracker *nodesessions.Tracker httpClient *http.Client egress *egressMeter + // subCache stores full-track PGS (.sup) extracts under the transcode dir + // so repeat selections skip the whole-file ffmpeg demux. + subCache *playback.SubtitleCache } // NewServer creates a new proxy server backed by a config watcher and session @@ -37,6 +40,9 @@ func NewServer(watcher *nodeconfig.Watcher, tracker *nodesessions.Tracker) *Serv // bounded by the transport's response-header timeout instead. httpClient: &http.Client{Transport: newStreamTransport()}, egress: newEgressMeter(), + subCache: playback.NewSubtitleCache(func() string { + return watcher.Config().Playback.TranscodeDir + }), } } @@ -222,21 +228,26 @@ func (s *Server) handleSubtitle(w http.ResponseWriter, r *http.Request) { } // When the URL requests SUP format (e.g. /subtitles/{token}/2.sup), - // stream the PGS track as a raw .sup elementary stream for client-side + // serve the PGS track as a raw .sup elementary stream for client-side // bitmap rendering (libpgs). Unlike the buffered text paths below, this - // streams ffmpeg output directly: the track can be large and the client - // renders progressively as data arrives. + // serves the cached full-track extract when present, and otherwise + // streams ffmpeg output directly (the client renders progressively as + // data arrives) while teeing it into the cache for the next request. + // Clients that manage their own sliding window opt in with ?windowed=1 + // (+ ?position=/?duration=), mirroring the API stream handler; windowed + // requests extract only the requested slice — from the cached full + // track when one exists (warming it in the background when not). if requestedFormat == "sup" { - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusOK) - err := playback.StreamExtractSubtitle(r.Context(), playback.StreamExtractOpts{ - InputPath: claims.MediaPath, - TrackIndex: trackIndex, - SourceCodec: "hdmv_pgs_subtitle", // .sup URLs are only generated for PGS tracks - FFmpegPath: cfg.Playback.FFmpegPath, - Writer: w, - }) + allowWindow, seek, duration := playback.PGSWindowRequest(r.URL.Query()) + err := s.subCache.ServeSUPExtract(w, r, playback.StreamExtractOpts{ + InputPath: claims.MediaPath, + TrackIndex: trackIndex, + SourceCodec: "hdmv_pgs_subtitle", // .sup URLs are only generated for PGS tracks + SeekSeconds: seek, + DurationSeconds: duration, + AllowWindow: allowWindow, + FFmpegPath: cfg.Playback.FFmpegPath, + }, playback.StreamExtractSubtitle) if err != nil && r.Context().Err() == nil { // Headers already committed — log and let the client see a // truncated response. diff --git a/internal/streamtoken/token.go b/internal/streamtoken/token.go index 1d5d4881..f6b78964 100644 --- a/internal/streamtoken/token.go +++ b/internal/streamtoken/token.go @@ -48,6 +48,7 @@ type Claims struct { StartSegmentNumber int `json:"ssn,omitempty"` SubtitleTrackIndex int `json:"sti,omitempty"` SubtitleBurnIn bool `json:"sbi,omitempty"` + SubtitleCodec string `json:"sbc,omitempty"` TargetBitrateKbps int `json:"tbr,omitempty"` TotalDuration float64 `json:"dur,omitempty"` FastStart bool `json:"fs,omitempty"` diff --git a/internal/transcodenode/server.go b/internal/transcodenode/server.go index 6285b77a..9719775f 100644 --- a/internal/transcodenode/server.go +++ b/internal/transcodenode/server.go @@ -41,6 +41,7 @@ type TranscodeStartRequest struct { AudioTrackIndex int `json:"audio_track_index"` SubtitleTrackIndex int `json:"subtitle_track_index"` SubtitleBurnIn bool `json:"subtitle_burn_in"` + SubtitleCodec string `json:"subtitle_codec,omitempty"` TotalDuration float64 `json:"total_duration"` } @@ -309,6 +310,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, + SubtitleCodec: req.SubtitleCodec, TotalDuration: req.TotalDuration, FastStart: true, NodeType: "transcode", diff --git a/web/package.json b/web/package.json index 69dcd75b..b5e9362a 100644 --- a/web/package.json +++ b/web/package.json @@ -38,7 +38,6 @@ "framer-motion": "^12.38.0", "hls.js": "^1.6.15", "jassub": "^2.5.6", - "libpgs": "^0.8.1", "lucide-react": "^0.576.0", "node-unrar-js": "^2.0.2", "pdfjs-dist": "^5.7.284", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index e47779d1..62c13ffb 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -85,9 +85,6 @@ importers: jassub: specifier: ^2.5.6 version: 2.5.6 - libpgs: - specifier: ^0.8.1 - version: 0.8.1 lucide-react: specifier: ^0.576.0 version: 0.576.0(react@19.2.4) @@ -2342,9 +2339,6 @@ packages: lfa-ponyfill@1.1.0: resolution: {integrity: sha512-YS3/DmyDdywWwoEu1ZacAudqkJ4q7WtKE9+bWlaSuEoVrXva7ChIJHMJYs19zyVc1H198pzqAreQU0r/+YNeew==} - libpgs@0.8.1: - resolution: {integrity: sha512-Z8WCvHRYr37QjU8b2WqiJpCvnoojbYPtoE4FktwdI+m+b1QkNIKqrYQrlPKG1xvYyQWgV1j+16xjFOhun4nBUA==} - lightningcss-android-arm64@1.31.1: resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} @@ -5225,8 +5219,6 @@ snapshots: lfa-ponyfill@1.1.0: {} - libpgs@0.8.1: {} - lightningcss-android-arm64@1.31.1: optional: true diff --git a/web/src/hooks/queries/subtitles.ts b/web/src/hooks/queries/subtitles.ts index b56f6594..373a089b 100644 --- a/web/src/hooks/queries/subtitles.ts +++ b/web/src/hooks/queries/subtitles.ts @@ -2,6 +2,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { api } from "@/api/client"; +import type { PrePlaySubtitleSelection } from "@/player/types"; +import { derivePersistedSubtitleMode } from "@/player/utils/subtitleMode"; import type { DownloadedSubtitle, SubtitleDownloadRequest, @@ -11,7 +13,7 @@ import type { SubtitleUploadRequest, } from "@/api/types"; -import { subtitleKeys } from "./keys"; +import { itemKeys, subtitleKeys } from "./keys"; interface DownloadSubtitleResponse { subtitle: DownloadedSubtitle; @@ -109,6 +111,79 @@ export function useDownloadedSubtitles(mediaFileId: number | undefined) { }); } +// Subtitle preferences feed the effective defaults on item details; a +// series-keyed preference feeds every episode's detail, so invalidate broadly. +function invalidateItemDetails(queryClient: ReturnType): Promise { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: itemKeys.details() }), + queryClient.invalidateQueries({ queryKey: ["catalog", "items"] }), + ]); +} + +/** + * Clears the persisted subtitle override (saved when a track is manually + * selected during playback) so profile-level auto selection applies again. + * Keyed by the movie's content ID or the episode's series ID. + */ +export function useDeleteSubtitlePreference() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (prefId: string) => api(`/subtitle-prefs/${prefId}`, { method: "DELETE" }), + onSuccess: () => invalidateItemDetails(queryClient), + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to reset subtitle preference"); + }, + }); +} + +interface SetSubtitlePreferenceInput { + /** Movie content ID or episode series ID (preferences are series-scoped). */ + prefId: string; + /** The chosen track, or null to persist "subtitles off". */ + selection: PrePlaySubtitleSelection | null; + /** Preserve the effective forced-subtitle behavior in the replaced row. */ + showForcedSubtitles?: boolean; +} + +/** + * Persists a pre-play subtitle choice as the item's override — the same + * "always play this track" (or "off") preference a manual in-player + * selection saves — so the choice sticks across visits and sessions. + */ +export function useSetSubtitlePreference() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ prefId, selection, showForcedSubtitles }: SetSubtitlePreferenceInput) => + api(`/subtitle-prefs/${prefId}`, { + method: "PUT", + body: JSON.stringify({ + subtitle_language: selection?.language ?? "", + subtitle_track_index: selection?.track_index ?? -1, + subtitle_mode: derivePersistedSubtitleMode( + selection ? (selection.track_index ?? -1) : null, + ), + track_signature: selection + ? { + source: selection.source, + language: selection.language, + codec: selection.codec, + label: selection.label, + forced: selection.forced, + hearing_impaired: selection.hearing_impaired, + } + : null, + show_forced_subtitles: showForcedSubtitles, + }), + }), + onSuccess: () => invalidateItemDetails(queryClient), + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to save subtitle preference"); + }, + }); +} + export function useDownloadSubtitle() { const queryClient = useQueryClient(); diff --git a/web/src/lib/subtitleAppearance.test.ts b/web/src/lib/subtitleAppearance.test.ts new file mode 100644 index 00000000..2bd312ad --- /dev/null +++ b/web/src/lib/subtitleAppearance.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { + computeSubtitleFontScale, + computeSubtitleFontSize, + computeSubtitlePositionStyle, + computeSubtitleStyles, + DEFAULT_SUBTITLE_APPEARANCE, + SUBTITLE_REFERENCE_HEIGHT, +} from "./subtitleAppearance"; + +describe("computeSubtitleFontScale", () => { + it("returns 1 at the 16:9 reference height", () => { + expect(computeSubtitleFontScale(1280, SUBTITLE_REFERENCE_HEIGHT, 16 / 9)).toBe(1); + }); + + it("scales proportionally with the rendered video height", () => { + // 16:9 video filling a 1920x1080 player renders at 1080 tall — 1.5x the + // 720 reference. + expect(computeSubtitleFontScale(1920, 1080, 16 / 9)).toBeCloseTo(1.5); + // Same video in a half-size window scales down proportionally. + expect(computeSubtitleFontScale(960, 540, 16 / 9)).toBeCloseTo(0.75); + }); + + it("tracks the letterboxed video, not the player, for narrow windows", () => { + // A 16:9 video in a tall 1000x2000 player renders 1000 wide → 562.5 tall. + expect(computeSubtitleFontScale(1000, 2000, 16 / 9)).toBeCloseTo(562.5 / 720); + }); + + it("uses the 16:9 reference frame for wider-than-16:9 content", () => { + // 2.35:1 content filling a 1920-wide player: reference height stays + // 1920 * 9/16 = 1080, matching how position offsets are anchored. + expect(computeSubtitleFontScale(1920, 1080, 2.35)).toBeCloseTo(1.5); + }); + + it("falls back to 1 before measurements are available", () => { + expect(computeSubtitleFontScale(0, 0, 16 / 9)).toBe(1); + expect(computeSubtitleFontScale(1920, 1080, 0)).toBe(1); + expect(computeSubtitleFontScale(1920, 1080, Number.NaN)).toBe(1); + }); +}); + +describe("computeSubtitlePositionStyle", () => { + it("anchors Bottom to the player window", () => { + // The video occupies only 562.5px in this tall player, but Bottom remains + // 7% from the player window edge instead of moving up to the video frame. + expect(computeSubtitlePositionStyle("bottom", 1000, 2000, 16 / 9)).toEqual({ + bottom: "140px", + }); + }); + + it("anchors Lower Third to the rendered 16:9 video frame", () => { + // The centered 16:9 frame is 562.5px tall, with 718.75px below it. The + // lower-third inset adds 12% of that frame height: 718.75 + 67.5 = 786.25. + expect(computeSubtitlePositionStyle("lower-third", 1000, 2000, 16 / 9)).toEqual({ + bottom: "786.25px", + }); + }); +}); + +describe("computeSubtitleFontSize", () => { + it("returns the base size at scale 1", () => { + expect(computeSubtitleFontSize("large")).toBe("32px"); + expect(computeSubtitleFontSize("small", 1)).toBe("20px"); + }); + + it("scales the base size", () => { + expect(computeSubtitleFontSize("large", 1.5)).toBe("48px"); + expect(computeSubtitleFontSize("xxlarge", 0.5)).toBe("24px"); + }); + + it("clamps to a legible minimum in tiny windows", () => { + expect(computeSubtitleFontSize("small", 0.1)).toBe("12px"); + }); +}); + +describe("computeSubtitleStyles", () => { + it("applies the font scale to the cue font size", () => { + const unscaled = computeSubtitleStyles(DEFAULT_SUBTITLE_APPEARANCE); + const scaled = computeSubtitleStyles(DEFAULT_SUBTITLE_APPEARANCE, 2); + expect(unscaled.cueStyle.fontSize).toBe("32px"); + expect(scaled.cueStyle.fontSize).toBe("64px"); + }); +}); diff --git a/web/src/lib/subtitleAppearance.ts b/web/src/lib/subtitleAppearance.ts index 346a4d30..47ade50f 100644 --- a/web/src/lib/subtitleAppearance.ts +++ b/web/src/lib/subtitleAppearance.ts @@ -137,14 +137,31 @@ export function parseSubtitleAppearance(json: string | null): SubtitleAppearance // ─── Style Computation ────────────────────────────────────────────────────── -const FONT_SIZE_MAP: Record = { - small: "1.25rem", - medium: "1.6rem", - large: "2rem", - xlarge: "2.5rem", - xxlarge: "3rem", +// Base cue font sizes in px at the 16:9 reference frame height below. The +// player scales these proportionally with the rendered video so subtitles +// keep the same relative size as the window grows or shrinks. +const FONT_SIZE_MAP: Record = { + small: 20, + medium: 26, + large: 32, + xlarge: 40, + xxlarge: 48, }; +/** Reference frame height (px) at which FONT_SIZE_MAP values apply as-is. */ +export const SUBTITLE_REFERENCE_HEIGHT = 720; + +/** Floor so cues stay legible in very small windows. */ +const MIN_SUBTITLE_FONT_PX = 12; + +export function computeSubtitleFontSize( + fontSize: SubtitleAppearance["fontSize"], + fontScale = 1, +): string { + const px = Math.max(MIN_SUBTITLE_FONT_PX, Math.round(FONT_SIZE_MAP[fontSize] * fontScale)); + return `${px}px`; +} + function hexToRgb(hex: string): { r: number; g: number; b: number } { const clean = hex.replace("#", ""); return { @@ -191,12 +208,12 @@ export interface SubtitleStyles { cueStyle: CSSProperties; } -export function computeSubtitleStyles(settings: SubtitleAppearance): SubtitleStyles { +export function computeSubtitleStyles(settings: SubtitleAppearance, fontScale = 1): SubtitleStyles { const containerStyle: CSSProperties = computePositionStyle(settings.position); const cueStyle: CSSProperties = {}; // Font - cueStyle.fontSize = FONT_SIZE_MAP[settings.fontSize]; + cueStyle.fontSize = computeSubtitleFontSize(settings.fontSize, fontScale); cueStyle.fontFamily = settings.fontFamily; cueStyle.color = settings.fontColor; @@ -217,10 +234,11 @@ export function computeSubtitleStyles(settings: SubtitleAppearance): SubtitleSty // ─── Position (aspect-aware) ──────────────────────────────────────────────── -// Offsets as a fraction of the 16:9 reference frame height. +// Position offsets as a fraction of their anchor height. "Bottom" uses the +// player window; "Lower Third" and "Top" use the 16:9 video reference frame. const POSITION_OFFSETS: Record = { bottom: 0.07, - "lower-third": 0.18, + "lower-third": 0.12, top: 0.07, }; @@ -230,25 +248,23 @@ const POSITION_OFFSETS: Record = { */ function computePositionStyle(position: SubtitleAppearance["position"]): CSSProperties { if (position === "top") return { top: "8%", bottom: "auto" }; - if (position === "lower-third") return { bottom: "18%" }; + if (position === "lower-third") return { bottom: "12%" }; return { bottom: "7%" }; } /** - * Aspect-aware positioning. Anchors subtitles relative to a 16:9 reference - * frame centered on the actually-rendered video area (object-fit: contain). - * This keeps "Lower Third" and "Bottom" visually consistent regardless of - * whether content is 16:9, 4:3, or 2.35:1 — wider content's subs may land - * in the letterbox, which is the intended behavior. + * Height (px) of a 16:9 reference frame centered on the actually-rendered + * video area (object-fit: contain), or null before measurements are known. + * The frame matches the shorter dimension of the video so it never contracts + * inside it; for wider-than-16:9 content it extends into the letterbox. */ -export function computeSubtitlePositionStyle( - position: SubtitleAppearance["position"], +function resolveSubtitleReferenceHeight( playerWidth: number, playerHeight: number, videoAspect: number, -): CSSProperties { +): number | null { if (!Number.isFinite(videoAspect) || videoAspect <= 0 || playerWidth <= 0 || playerHeight <= 0) { - return computePositionStyle(position); + return null; } // Rendered video dimensions inside the player (object-fit: contain). @@ -256,10 +272,45 @@ export function computeSubtitlePositionStyle( const videoHeight = playerAspect > videoAspect ? playerHeight : playerWidth / videoAspect; const videoWidth = playerAspect > videoAspect ? playerHeight * videoAspect : playerWidth; - // 16:9 reference frame: match the shorter dimension of the video so the - // frame never contracts inside it. For wider-than-16:9 content, this - // extends the reference into the letterbox above/below the video. - const refHeight = videoAspect >= 16 / 9 ? videoWidth * (9 / 16) : videoHeight; + return videoAspect >= 16 / 9 ? videoWidth * (9 / 16) : videoHeight; +} + +/** + * Font scale factor for the rendered video size: 1 at the 720px reference + * height, growing/shrinking proportionally with the window so subtitles keep + * the same size relative to the video. Falls back to 1 until measured. + */ +export function computeSubtitleFontScale( + playerWidth: number, + playerHeight: number, + videoAspect: number, +): number { + const refHeight = resolveSubtitleReferenceHeight(playerWidth, playerHeight, videoAspect); + return refHeight === null ? 1 : refHeight / SUBTITLE_REFERENCE_HEIGHT; +} + +/** + * Aspect-aware positioning. "Bottom" is anchored to the player window so it + * can use the available letterbox space. "Lower Third" and "Top" are anchored + * to a 16:9 reference frame centered on the actually-rendered video area + * (object-fit: contain), keeping those positions attached to the video frame + * regardless of whether content is 16:9, 4:3, or 2.35:1. + */ +export function computeSubtitlePositionStyle( + position: SubtitleAppearance["position"], + playerWidth: number, + playerHeight: number, + videoAspect: number, +): CSSProperties { + if (position === "bottom") { + if (playerHeight <= 0) return computePositionStyle(position); + return { bottom: `${POSITION_OFFSETS.bottom * playerHeight}px` }; + } + + const refHeight = resolveSubtitleReferenceHeight(playerWidth, playerHeight, videoAspect); + if (refHeight === null) { + return computePositionStyle(position); + } // Reference frame is centered on the video, which is itself centered in // the player container — so the reference is centered in the player too. diff --git a/web/src/pages/ItemDetail/EpisodeContent.test.tsx b/web/src/pages/ItemDetail/EpisodeContent.test.tsx index a1e5cf29..6bf4cc22 100644 --- a/web/src/pages/ItemDetail/EpisodeContent.test.tsx +++ b/web/src/pages/ItemDetail/EpisodeContent.test.tsx @@ -79,6 +79,11 @@ vi.mock("@/hooks/queries/ratings", () => ({ useDeleteRating: mocks.useDeleteRating, })); +vi.mock("@/hooks/queries/subtitles", () => ({ + useDeleteSubtitlePreference: () => ({ mutate: vi.fn() }), + useSetSubtitlePreference: () => ({ mutate: vi.fn() }), +})); + vi.mock("@/components/CastCarousel", () => ({ default: () =>
, })); diff --git a/web/src/pages/ItemDetail/EpisodeContent.tsx b/web/src/pages/ItemDetail/EpisodeContent.tsx index 0eb1e0b5..87289998 100644 --- a/web/src/pages/ItemDetail/EpisodeContent.tsx +++ b/web/src/pages/ItemDetail/EpisodeContent.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from "react-router"; import type { FileVersion, ItemDetail } from "@/api/types"; import type { PlayerSubtitleTrackSignature, PrePlaySubtitleSelection } from "@/player/types"; import { useSeasonDetail, useSeasonEpisodes } from "@/hooks/queries/episodes"; +import { useDeleteSubtitlePreference, useSetSubtitlePreference } from "@/hooks/queries/subtitles"; import { useAmbientColor } from "@/hooks/useAmbientColor"; import { useAuth } from "@/hooks/useAuth"; import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; @@ -69,6 +70,8 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e const [mediaInfoFileId, setMediaInfoFileId] = useState(null); const refreshMetadataMutation = useRefreshItemMetadata(); const redetectIntroMutation = useRedetectEpisodeIntro(); + const deleteSubtitlePreference = useDeleteSubtitlePreference(); + const setSubtitlePreference = useSetSubtitlePreference(); // Version selection state — drives the Play button and inline stream popovers. const sortedVersions = useMemo(() => sortByResolution(item.versions ?? []), [item.versions]); @@ -146,16 +149,39 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e const handleSelectSubtitle = (selection: PrePlaySubtitleSelection) => { setSubtitleSelectionMode("explicit"); setExplicitSubtitleSelection(selection); + // Persist as the series' override so the choice sticks across visits, + // exactly like a manual in-player selection (preferences are + // series-scoped). + if (item.series_id) { + setSubtitlePreference.mutate({ + prefId: item.series_id, + selection, + showForcedSubtitles: item.effective_show_forced_subtitles, + }); + } }; const handleResetSubtitleSelection = () => { setSubtitleSelectionMode("auto"); setExplicitSubtitleSelection(null); + // "Auto" also clears the persisted override saved by a manual in-player + // selection. Subtitle preferences are series-scoped, so this restores + // profile-level auto selection for the whole series. + if (item.series_id) { + deleteSubtitlePreference.mutate(item.series_id); + } }; const handleSelectSubtitleOff = () => { setSubtitleSelectionMode("off"); setExplicitSubtitleSelection(null); + if (item.series_id) { + setSubtitlePreference.mutate({ + prefId: item.series_id, + selection: null, + showForcedSubtitles: item.effective_show_forced_subtitles, + }); + } }; const preferredSubtitleTrackSignature: PlayerSubtitleTrackSignature | null = item.effective_subtitle_track_signature diff --git a/web/src/pages/ItemDetail/MovieContent.test.tsx b/web/src/pages/ItemDetail/MovieContent.test.tsx index 26d551f7..65757fa9 100644 --- a/web/src/pages/ItemDetail/MovieContent.test.tsx +++ b/web/src/pages/ItemDetail/MovieContent.test.tsx @@ -62,6 +62,11 @@ vi.mock("@/hooks/queries/recommendations", () => ({ useSimilarItems: mocks.useSimilarItems, })); +vi.mock("@/hooks/queries/subtitles", () => ({ + useDeleteSubtitlePreference: () => ({ mutate: vi.fn() }), + useSetSubtitlePreference: () => ({ mutate: vi.fn() }), +})); + vi.mock("@/hooks/useAuth", () => ({ useAuth: mocks.useAuth, useOptionalAuth: mocks.useAuth, diff --git a/web/src/pages/ItemDetail/MovieContent.tsx b/web/src/pages/ItemDetail/MovieContent.tsx index 7bcd5802..34cae8d2 100644 --- a/web/src/pages/ItemDetail/MovieContent.tsx +++ b/web/src/pages/ItemDetail/MovieContent.tsx @@ -7,6 +7,7 @@ import { useToggleWatchlist } from "@/hooks/queries/watchlist"; import { useRefreshItemMetadata, useWatchedStateMutation } from "@/hooks/queries/items"; import { useSetRating, useDeleteRating } from "@/hooks/queries/ratings"; import { useSimilarItems } from "@/hooks/queries/recommendations"; +import { useDeleteSubtitlePreference, useSetSubtitlePreference } from "@/hooks/queries/subtitles"; import { useAuth } from "@/hooks/useAuth"; import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; import { useAmbientColor } from "@/hooks/useAmbientColor"; @@ -67,6 +68,8 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov const watchedMutation = useWatchedStateMutation(item); const setRatingMutation = useSetRating(item.content_id); const deleteRatingMutation = useDeleteRating(item.content_id); + const deleteSubtitlePreference = useDeleteSubtitlePreference(); + const setSubtitlePreference = useSetSubtitlePreference(); const [editOpen, setEditOpen] = useState(false); const [matchOpen, setMatchOpen] = useState(false); const [splitOpen, setSplitOpen] = useState(false); @@ -150,16 +153,31 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov const handleSelectSubtitle = (selection: PrePlaySubtitleSelection) => { setSubtitleSelectionMode("explicit"); setExplicitSubtitleSelection(selection); + // Persist as this movie's override so the choice sticks across visits, + // exactly like a manual in-player selection. + setSubtitlePreference.mutate({ + prefId: item.content_id, + selection, + showForcedSubtitles: item.effective_show_forced_subtitles, + }); }; const handleSelectSubtitleOff = () => { setSubtitleSelectionMode("off"); setExplicitSubtitleSelection(null); + setSubtitlePreference.mutate({ + prefId: item.content_id, + selection: null, + showForcedSubtitles: item.effective_show_forced_subtitles, + }); }; const handleResetSubtitleSelection = () => { setSubtitleSelectionMode("auto"); setExplicitSubtitleSelection(null); + // "Auto" also clears the persisted override saved by a manual in-player + // selection, so profile-level auto selection applies to this movie again. + deleteSubtitlePreference.mutate(item.content_id); }; const primaryAction = resolveLeafPrimaryAction(item, "Play"); diff --git a/web/src/pages/ItemDetail/components/AudioTracksPopover.tsx b/web/src/pages/ItemDetail/components/AudioTracksPopover.tsx index 78078bd6..949bab84 100644 --- a/web/src/pages/ItemDetail/components/AudioTracksPopover.tsx +++ b/web/src/pages/ItemDetail/components/AudioTracksPopover.tsx @@ -34,7 +34,7 @@ function AudioOptionRow({ const content = ( <>
-
+
{title} {badges}
@@ -97,7 +97,7 @@ export default function AudioTracksPopover({ > Audio - + {isInteractive ? selectionMode === "auto" ? `Auto: ${autoSummary}` @@ -120,9 +120,14 @@ export default function AudioTracksPopover({ {tracks.map((track, index) => { const codec = track.codec ? mapAudioLabel(track.codec) : ""; const channels = formatChannels(track.channels); - const title = audioTitle(track); const language = getLanguageName(track.language ?? ""); - const meta = [language && language !== title ? language : "", compactAudioMeta(track)] + const fallbackTitle = audioTitle(track); + const title = language || fallbackTitle; + const embeddedTitle = track.title?.trim() || track.embedded_title?.trim() || ""; + const meta = [ + embeddedTitle && embeddedTitle !== title ? embeddedTitle : "", + compactAudioMeta(track), + ] .filter(Boolean) .join(" \u00B7 "); diff --git a/web/src/pages/ItemDetail/components/SubtitlesPopover.tsx b/web/src/pages/ItemDetail/components/SubtitlesPopover.tsx index 4d3e2527..a32ed713 100644 --- a/web/src/pages/ItemDetail/components/SubtitlesPopover.tsx +++ b/web/src/pages/ItemDetail/components/SubtitlesPopover.tsx @@ -13,12 +13,15 @@ import type { SubtitleMode, } from "@/player/types"; import { getLanguageName } from "@/player/utils/languageNames"; +import { getSubtitleFormatLabel, isSubtitleFormatLabel } from "@/player/utils/subtitleCodecs"; import { buildPrePlaySubtitleCandidates, - formatSubtitleCandidateSummary, + formatSubtitlePillSummary, + inferSubtitleFlagsFromTitle, resolveSelectedAudioLanguage, resolveAutoSubtitleSelection, subtitleSelectionEquals, + type PrePlaySubtitleCandidate, } from "./prePlaySelection"; interface SubtitlesPopoverProps { @@ -82,7 +85,7 @@ function SelectionRow({ const content = ( <>
-
+
{title} {badges}
@@ -128,29 +131,37 @@ function SubtitleSection({
{title}
- {rows.map((row) => ( - onSelectSubtitle(row.selection) : undefined} - badges={ - <> - {row.codec && ( - - {row.codec} - - )} - - - } - /> - ))} + {rows.map((row) => { + const titleFlags = inferSubtitleFlagsFromTitle(row.title); + const description = + row.title && !titleFlags.flagOnly && !isSubtitleFormatLabel(row.title, row.codec) + ? row.title + : row.releaseName; + + return ( + onSelectSubtitle(row.selection) : undefined} + badges={ + <> + {row.codec && ( + + {getSubtitleFormatLabel(row.codec) || row.codec.toUpperCase()} + + )} + + + } + /> + ); + })}
); @@ -160,13 +171,25 @@ function formatExplicitSelectionSummary( selection: PrePlaySubtitleSelection | null | undefined, ): string { if (!selection) return "Off"; - return formatSubtitleCandidateSummary({ + return formatSubtitlePillSummary({ + label: selection.label, languageLabel: getLanguageName(selection.language ?? "") || selection.language || "Unknown", + codec: selection.codec, forced: selection.forced, hearingImpaired: selection.hearing_impaired, }); } +function formatCandidatePillSummary(candidate: PrePlaySubtitleCandidate): string { + return formatSubtitlePillSummary({ + label: candidate.selection.label, + languageLabel: candidate.languageLabel, + codec: candidate.codec, + forced: candidate.forced, + hearingImpaired: candidate.hearingImpaired, + }); +} + export default function SubtitlesPopover({ version, selectionMode = "auto", @@ -182,7 +205,11 @@ export default function SubtitlesPopover({ onResetSelection, }: SubtitlesPopoverProps) { const [open, setOpen] = useState(false); - const downloadedQuery = useDownloadedSubtitles(open ? version?.file_id : undefined); + // Downloaded subtitles normally load lazily on open, but when the saved + // preference points at one, the closed trigger's Auto summary needs them + // to reflect the override. + const needsDownloaded = open || preferredSubtitleTrackSignature?.source === "downloaded"; + const downloadedQuery = useDownloadedSubtitles(needsDownloaded ? version?.file_id : undefined); const isInteractive = Boolean(onSelectSubtitle || onSelectSubtitleOff || onResetSelection); const candidates = useMemo( @@ -217,13 +244,27 @@ export default function SubtitlesPopover({ if (!version) return null; + // A stored track signature means a manual override is saved for this item; + // present the resolved track as the selection rather than an "Auto" guess. + const overrideCandidate = + selectionMode === "auto" && preferredSubtitleTrackSignature ? autoCandidate : null; + const activeSummary = selectionMode === "auto" - ? `Auto: ${autoCandidate?.summary ?? "Off"}` + ? overrideCandidate + ? formatCandidatePillSummary(overrideCandidate) + : `Auto: ${autoCandidate ? formatCandidatePillSummary(autoCandidate) : "Off"}` : selectionMode === "off" ? "Off" : formatExplicitSelectionSummary(explicitSelection); + const activeListSelection = + !isInteractive || selectionMode === "off" + ? null + : selectionMode === "explicit" + ? explicitSelection + : (overrideCandidate?.selection ?? null); + return ( @@ -233,7 +274,7 @@ export default function SubtitlesPopover({ > Subs - + {isInteractive ? activeSummary : candidates.all.length} @@ -244,9 +285,13 @@ export default function SubtitlesPopover({ {isInteractive && ( <> {downloadedQuery.isLoading ? ( @@ -281,9 +322,7 @@ export default function SubtitlesPopover({ )} diff --git a/web/src/pages/ItemDetail/components/VersionDropdown.tsx b/web/src/pages/ItemDetail/components/VersionDropdown.tsx index e361e0c7..52463d87 100644 --- a/web/src/pages/ItemDetail/components/VersionDropdown.tsx +++ b/web/src/pages/ItemDetail/components/VersionDropdown.tsx @@ -67,7 +67,7 @@ export default function VersionDropdown({ > Edition - + {selectedEdition.label} @@ -116,7 +116,7 @@ export default function VersionDropdown({ > Version - + {activeVersion ? buildVersionTriggerSummary(activeVersion) : ""} diff --git a/web/src/pages/ItemDetail/components/prePlaySelection.test.ts b/web/src/pages/ItemDetail/components/prePlaySelection.test.ts new file mode 100644 index 00000000..506cb261 --- /dev/null +++ b/web/src/pages/ItemDetail/components/prePlaySelection.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { + formatAudioTrackSummary, + formatSubtitleCandidateSummary, + formatSubtitlePillSummary, + inferSubtitleFlagsFromTitle, +} from "./prePlaySelection"; + +describe("formatAudioTrackSummary", () => { + it("uses language and compact format metadata instead of a container title", () => { + expect( + formatAudioTrackSummary({ + language: "en", + codec: "eac3", + channels: 6, + title: "ATSC A/52B (AC-3, E-AC-3)", + }), + ).toBe("English · EAC3 · 5.1"); + }); +}); + +describe("formatSubtitleCandidateSummary", () => { + it("leaves forced and accessibility state to the row badges", () => { + expect( + formatSubtitleCandidateSummary({ + languageLabel: "English", + forced: true, + hearingImpaired: false, + }), + ).toBe("English"); + }); +}); + +describe("inferSubtitleFlagsFromTitle", () => { + it("recognizes flag-only embedded titles", () => { + expect(inferSubtitleFlagsFromTitle("Forced")).toEqual({ + forced: true, + hearingImpaired: false, + flagOnly: true, + }); + expect(inferSubtitleFlagsFromTitle("SDH")).toEqual({ + forced: false, + hearingImpaired: true, + flagOnly: true, + }); + }); + + it("preserves meaningful regional titles as detail text", () => { + expect(inferSubtitleFlagsFromTitle("Latin American").flagOnly).toBe(false); + }); +}); + +describe("formatSubtitlePillSummary", () => { + it("uses language and a friendly format when the track title repeats the codec", () => { + expect( + formatSubtitlePillSummary({ + label: "SUBRIP", + languageLabel: "English", + codec: "subrip", + }), + ).toBe("English · SRT"); + }); + + it("keeps accessibility markers in the compact summary", () => { + expect( + formatSubtitlePillSummary({ + label: "SDH", + languageLabel: "English", + codec: "subrip", + hearingImpaired: true, + }), + ).toBe("English (SDH) · SRT"); + }); + + it("keeps forced markers in the compact summary", () => { + expect( + formatSubtitlePillSummary({ + label: "Forced", + languageLabel: "English", + codec: "subrip", + forced: true, + }), + ).toBe("English (Forced) · SRT"); + }); +}); diff --git a/web/src/pages/ItemDetail/components/prePlaySelection.ts b/web/src/pages/ItemDetail/components/prePlaySelection.ts index 3ea0750f..7612c660 100644 --- a/web/src/pages/ItemDetail/components/prePlaySelection.ts +++ b/web/src/pages/ItemDetail/components/prePlaySelection.ts @@ -14,6 +14,7 @@ import { resolveVersionAudioLanguage } from "@/player/utils/effectiveAudioLangua import { getLanguageName } from "@/player/utils/languageNames"; import { normalizeSubtitleMode } from "@/player/utils/subtitleMode"; import { resolveSubtitleAutoSelect } from "@/player/utils/subtitleSort"; +import { getSubtitleFormatLabel } from "@/player/utils/subtitleCodecs"; import { formatChannels, mapAudioLabel } from "@/lib/mediaFormat"; import { buildVersionSubtitleInventory, @@ -51,19 +52,45 @@ function normalizeDownloadedLabel(subtitle: DownloadedSubtitle): string { return releaseName || provider || getLanguageName(subtitle.language?.trim() || "unknown"); } -function subtitleSummaryParts( - row: Pick, -): string[] { - const parts = [row.languageLabel]; - if (row.hearingImpaired) parts.push("HI"); - if (row.forced) parts.push("Forced"); - return parts.filter(Boolean); -} - export function formatSubtitleCandidateSummary( row: Pick, ): string { - return subtitleSummaryParts(row).join(" "); + return row.languageLabel; +} + +export function inferSubtitleFlagsFromTitle(title: string | undefined): { + forced: boolean; + hearingImpaired: boolean; + flagOnly: boolean; +} { + const normalized = title?.trim().toLowerCase() ?? ""; + const forced = normalized === "forced"; + const hearingImpaired = ["sdh", "cc", "hi", "hearing impaired"].includes(normalized); + return { forced, hearingImpaired, flagOnly: forced || hearingImpaired }; +} + +export interface SubtitlePillSummarySource { + label?: string; + languageLabel?: string; + codec?: string; + forced?: boolean; + hearingImpaired?: boolean; +} + +/** + * Single-line summary for the closed selector pill: language, optional + * (SDH)/(Forced) markers, and a human-readable subtitle format. Track titles + * stay in the open selector, where meaningful details have room to display. + */ +export function formatSubtitlePillSummary(source: SubtitlePillSummarySource): string { + const name = source.languageLabel?.trim() || source.label?.trim() || "Unknown"; + const parts = [name]; + if (source.hearingImpaired && !/\b(?:sdh|cc|hi)\b/i.test(name)) parts.push("(SDH)"); + if (source.forced && !/forced/i.test(name)) parts.push("(Forced)"); + const text = parts.join(" "); + const codec = source.codec?.trim(); + const format = getSubtitleFormatLabel(codec) || codec?.toUpperCase(); + return format ? `${text} · ${format}` : text; } export function getAutoAudioTrackIndex(version: FileVersion | null | undefined): number { @@ -120,15 +147,10 @@ export function formatAudioTrackSummary(track: VersionAudioTrack | undefined): s return "Unknown"; } - const title = track.title?.trim() || track.embedded_title?.trim(); - if (title) { - return title; - } - const language = getLanguageName(track.language ?? "") || "Unknown"; const codec = track.codec ? mapAudioLabel(track.codec) : ""; const channels = formatChannels(track.channels); - return [language, codec, channels].filter(Boolean).join(" "); + return [language, codec, channels].filter(Boolean).join(" · "); } export function toSubtitleTrackSignature( @@ -186,6 +208,7 @@ export function buildPrePlaySubtitleCandidates( label, forced: row.forced, hearing_impaired: row.hearingImpaired, + track_index: row.index, }, summary: formatSubtitleCandidateSummary(row), }; diff --git a/web/src/player/components/SubtitleMenu.tsx b/web/src/player/components/SubtitleMenu.tsx index 03d77a64..e2a35e14 100644 --- a/web/src/player/components/SubtitleMenu.tsx +++ b/web/src/player/components/SubtitleMenu.tsx @@ -9,7 +9,7 @@ import { SubtitleAppearancePanel } from "./SubtitleAppearancePanel"; import { playerFetch } from "../player-fetch"; import { getLanguageName } from "../utils/languageNames"; import { sortSubtitlesBySource } from "../utils/subtitleSort"; -import { getSubtitleFormatLabel } from "../utils/subtitleCodecs"; +import { getSubtitleFormatLabel, isSubtitleFormatLabel } from "../utils/subtitleCodecs"; import { isTranslatableSource } from "./subtitleTranslateRequest"; interface SubtitleMenuProps { @@ -212,7 +212,10 @@ export function SubtitleMenu({ const sourceLabel = SOURCE_LABELS[track.source ?? "embedded"] ?? "Embedded"; const formatLabel = getSubtitleFormatLabel(track.codec); const hasDetail = - track.label && track.label !== track.language && track.label !== languageName; + track.label && + track.label !== track.language && + track.label !== languageName && + !isSubtitleFormatLabel(track.label, track.codec); const itemIdx = ++menuItemIndex; return ( diff --git a/web/src/player/components/VideoPlayer.tsx b/web/src/player/components/VideoPlayer.tsx index 957d625c..2955dd76 100644 --- a/web/src/player/components/VideoPlayer.tsx +++ b/web/src/player/components/VideoPlayer.tsx @@ -14,9 +14,10 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useRemuxSeeking } from "../hooks/useRemuxSeeking"; import { useSubtitleTracks } from "../hooks/useSubtitleTracks"; import { useASSSubtitles } from "../hooks/useASSSubtitles"; -import { usePGSSubtitles } from "../hooks/usePGSSubtitles"; +import { isBitmapCodec } from "../utils/subtitleCodecs"; import { useSubtitleAppearance } from "../hooks/useSubtitleAppearance"; -import { useSubtitlePositionStyle } from "../hooks/useSubtitlePositionStyle"; +import { useSubtitleLayout } from "../hooks/useSubtitleLayout"; +import { computeSubtitleFontSize } from "@/lib/subtitleAppearance"; import { useNextEpisode } from "../hooks/useNextEpisode"; import { MARKER_KINDS, useMarkerEditor } from "../hooks/useMarkerEditor"; import { COMPATIBILITY_QUALITY_ID, useTranscodeQuality } from "../hooks/useTranscodeQuality"; @@ -220,6 +221,7 @@ export function VideoPlayer({ const mediaRecoveryAttemptsRef = useRef(0); const lastRecoveryRef = useRef(0); const streamOriginRef = useRef(0); + const subtitleFetchAnchorRef = useRef(initialPosition); const backendDurationRef = useRef(propDuration ?? 0); const autoEnterPictureInPictureAttemptedRef = useRef(false); const autoSkippedIntroKeyRef = useRef(null); @@ -354,6 +356,26 @@ export function VideoPlayer({ const activeQualityId = transcodeQuality.activeQualityId; const switchQuality = transcodeQuality.switchQuality; const isPlayerReady = effectiveStreamUrl !== ""; + + // Any stream restart (transcode restart on seek, quality/audio switch, + // turning off bitmap burn-in) reloads the