feat(playback): improve web subtitles and track selection (#362)
* fix(player): keep text subtitles in sync across copy-mode restarts and sparse cue windows - Rebase already-loaded cues in place when streamOriginSeconds changes (copy-mode session restart) instead of leaving them offset by the delta. - Stop inferring end-of-input from where a window's cues stop; only the known media duration marks EOF, so a dialogue gap no longer silently ends prefetching for the rest of playback. - Anchor the first window fetch to the intended start position (resume target or pending seek) while the element still reports currentTime=0, and reset coverage on forward seeks past the fetched window. * fix(playback): align encoded transcode start with the declared segment boundary A mid-segment start (resume, seek restart, audio switch) spawned ffmpeg at the raw seek position while labeling its first segment with the grid number, whose synthetic-manifest start is up to one segment earlier. hls.js aligns the first fragment's content to that declared position, shifting the whole session's timeline late by seek mod segment_duration (0-2s): subtitles trail dialogue by a constant per-session offset and progress/resume positions drift by the same amount. Snap the ffmpeg start position down to the segment boundary for encoded sessions so declared and produced timelines match exactly; the player still seeks to the precise requested position. Copy-mode sessions serve ffmpeg's real manifest and keep the raw seek. * fix(api): forward http.Flusher through response-writer middleware wrappers Streamed subtitle extracts (and any progressive response) flush per chunk via an http.Flusher assertion, but none of the status-capturing middleware wrappers implemented Flush, so the assertion failed and cues sat in Go's response buffer until ffmpeg finished. On large remuxes where a 600s window takes 20s+ to demux, captions appeared only when the whole window completed instead of within the first seconds. Give every wrapper a Flush() (satisfies plain assertions, including chi's Compress) and Unwrap() (satisfies http.ResponseController). The jellycompat image-proxy tag rewriter flushes only in passthrough mode since it buffers JSON bodies for rewriting. Regression test asserts the API chain forwards Flush end to end. * feat(player): PGS subtitles honor size, position, and background settings Port the tvOS/iOS bitmap-cue styling to the web player. libpgs now decodes in worker mode but draws to a hidden source canvas on the main thread; a compositor detects cue regions from the frame's alpha channel and re-places them on a visible overlay canvas per the shared subtitle appearance settings: size scale (with the 0.85 authored-size compensation), vertical position preset (dialogue-band cues only — floating signs keep authored placement, matching the Apple implementation), and the background box. Font family, text color, and outline are baked into the source pixels and remain inapplicable. * fix(player): anchor PGS position presets to the text overlay's reference frame The initial port used silo-apple's 30/1080 bottom margin and video-relative lower-third/top anchors; the web text overlay anchors to a 16:9 reference frame with 7%/18% offsets that extends into the letterbox for wide content. Use the same anchors so PGS dialogue lands exactly where SRT text does. * feat(player): size PGS cues to match the text subtitle line height Replace the authored-size ladder (0.85 × font-size ratio) with per-cue text-line matching: the region detector reports the tallest text line inside each cue, and the compositor scales the cue so one line of bitmap text renders at the same pixel height as the SRT overlay's font at the current preset. Authored size differences between discs no longer leak through; upscaling is capped at 2.5× to keep small bitmaps from going blurry. * feat(subtitles): opt-in windowed PGS extraction to cut mid-file load latency PGS extracts always demuxed the source from byte 0, so starting a large remux mid-file meant minutes before the first bitmap cue. The web player now opts in to windowed extraction (?windowed=1&position=&duration=) and re-points libpgs at a fresh window on seeks and near coverage end; ffmpeg input-side -ss with -copyts keeps absolute source timestamps. Without the explicit opt-in the endpoint behaves byte-identically, so Apple/Android and other single-fetch consumers are unaffected. ASS remains unconditionally non-windowed (its header only exists at offset 0). * feat(playback): cache extracted PGS subtitle tracks Every selection of an embedded PGS track re-ran a full ffmpeg extract that demuxes the entire source file from byte 0 — minutes for a large remux — and responses were Cache-Control: no-store, so repeat selections, re-watches, and multiple viewers all paid full price. Add a disk cache for full-track .sup extracts under <transcode_dir>/subtitle-cache, created lazily: - Keyed by source path hash + subtitle stream ordinal + source mtime+size (encoded in the filename), so a replaced source file implicitly invalidates its entries; the source is stat'ed on every lookup. - Cache miss: ffmpeg stdout is teed to the response (first viewer still streams progressively, first-byte latency unchanged) and into a temp file that is fsynced and atomically renamed into the cache on clean ffmpeg exit. Any error — ffmpeg failure, client disconnect, tee write failure, or the source changing mid-extract — discards the temp file, so a partial entry is never served. - Cache hit: served via http.ServeContent (Range support, Content-Length, Last-Modified from the source mtime) with a revalidatable Cache-Control instead of no-store. - Concurrent requests for the same in-flight track run their own uncached extract (mutex + in-flight key set) rather than blocking on another client's connection. - Scan-on-commit LRU eviction under a 2 GiB cap (recency tracked by bumping entry mtime on hit; atime is unreliable under relatime), plus sweep of crash-orphaned .part temp files. - Windowed PGS requests (?windowed=) bypass the cache in both directions: their output covers only a slice of the track. Both the integrated API handler and the standalone proxy subtitle path share the same playback.SubtitleCache.ServeSUPExtract helper. VTT (already windowed and fast) and ASS (small) stay uncached. No API surface change. AI-use disclosure: implemented with Claude Code. * fix(playback): check Close error returns in subtitle cache paths Silence errcheck on the cache-hit defer and the test's simulated disk-full Close. AI-use disclosure: implemented with Claude Code. * feat(playback): warm PGS cache in background and window from cached track Windowed PGS requests bypassed the cache entirely, so every window fetch re-demuxed the multi-GB original file. Now a windowed miss kicks off a detached background warm (full-track extract into the cache, at most 2 concurrent server-wide, coalesced with client-driven fills), and once the entry exists windowed extracts read the 15-80MB cached .sup instead — seeks and re-enables become near-instant after the first load. Verified empirically that ffmpeg preserves absolute PTS when windowing a sup input. * feat(player): hold playback while PGS subtitle cues load When a PGS track is enabled (or a seek lands outside the fetched window), extraction takes seconds and dialogue could play unsubtitled. The player now pauses until the renderer's parsed data covers the playhead — tracked via libpgs' parsed-timestamp watermark, the exact predicate it renders by — showing a "Loading subtitles…" indicator after 500ms. User play/pause always wins over the hold, a 20s safety timeout prevents stranding playback, and background prefetch never pauses. If future libpgs versions reshape the observed internals the hook degrades to the old play-through behavior. * perf(player): shrink uncached PGS window to 600s Draining a windowed extract from the source reads the full interleaved container across the window (~1GB per 100s of remux on measured hardware); a 3600s window cost ~12GB of reads per fetch while cold. Once the server cache is warm a window costs milliseconds regardless of size, so smaller windows only add trivially cheap re-fetches. * feat(subtitles): burn in PGS/bitmap subtitles for the web player The web player rendered PGS client-side via libpgs, which required extracting the .sup track — a cold ffmpeg demux that took seconds even windowed, since c:s copy still reads the whole interleaved container up to the playhead. Every other server (Plex, Jellyfin default, Emby) burns image subtitles into the video instead, and that is the only path with no per-seek extraction cost. Selecting a bitmap subtitle (PGS/DVD/DVB) now restarts the transcode with subtitle_burn_in at the current aligned position, reusing the same restart machinery as an audio/quality switch so the segment-boundary timeline alignment holds. The server composites the decoded subtitle onto the video with an overlay filter_complex graph (libass's subtitles= filter is text-only); overlay runs at native resolution before any target scaling, and hardware pipelines round-trip through CPU like the text path. Burn-in forces a video encode, so copy-video recipes are upgraded to h264 both client- and server-side. Text subtitles keep the instant, styled, client-side path. The .sup streaming endpoints, cache, and windowing are retained for the Apple client, which renders PGS natively. The now-dead web PGS stack (usePGSSubtitles, pgsPlacement, libpgs dep) is removed. Tradeoff: bitmap subtitles no longer honor web appearance settings (baked into the video) and toggling one restarts the transcode (~1-2s buffering), matching Plex behavior. * fix(player): rebuild text subtitle track when turning off PGS burn-in Selecting an SRT track that turned off bitmap burn-in restarted the transcode, and the client TextTrack built in that same moment was orphaned when the <video> element reloaded, so the subtitles never rendered (and a seek could not recover the dead track). Rebuild the text track once the new stream settles, gated on the burn-in-off transition so quality/audio switches and copy-mode seek restarts keep their subtitles without a needless re-extract. * fix(player): render web subtitles behind the control HUD The text subtitle overlay sat at z-20, above the controls layer (z-10), so cues painted over the bottom HUD and cluttered the control bar. Drop it to z-[5] — above the video, below the controls — so the HUD paints over the cues while it is visible. When controls are hidden the whole controls layer is opacity-0, so cues remain fully visible. * feat(player): lift web subtitles above the control bar while it's visible Rather than hiding bottom-anchored cues behind the HUD, raise them just above the control bar (measured height + a small gap) whenever the bar is visible in the foreground player, then settle them back when it hides. The bar is a roughly fixed pixel height while the cue offset scales with the player, so the bar is measured via ResizeObserver rather than hardcoded. Top-anchored cues never collide with the bottom HUD, so they stay put. z-[5] is retained as a safety so any residual overlap tucks behind the bar. * fix(player): coalesce same-tick transcode restarts into one dispatch Starting playback with a persisted bitmap subtitle fired transcode/start twice within milliseconds: the auto-start effect dispatched before subtitle auto-selection restored the burn-in, whose effect then forced a second start. The first request was already on the wire (no abort signal was passed to fetch), so the server spawned an ffmpeg only to kill it for the second start — visible in production as an ffmpeg exit error ~1ms after every such session start, and slowing time to first frame. Defer the network dispatch by one macrotask so back-to-back restart calls in a tick collapse into a single request carrying the final parameters; state updates stay synchronous. Pass the abort signal into playerFetch so a superseded in-flight request is actually cancelled, and drop any deferred dispatch on unmount so a stray transcode/start cannot land after the session's exit DELETE. * fix(catalog): resolve effective subtitle defaults for movie item details Movie pre-play subtitle selectors were missing the effective defaults (including per-item overrides saved from a previous play) that episodes and watch payloads already resolve. Extract applyToItemDetail/ applyToWatchDetail helpers and apply defaults for movies in buildMediaItemDetail. The SubtitlesPopover now also eagerly loads downloaded subtitles when the saved preference points at one so the closed trigger's Auto summary reflects the override. * feat(player): scale subtitle font size with the rendered video Replace fixed rem font sizes with px values defined at a 720px 16:9 reference height, scaled proportionally with the actually-rendered video (object-fit: contain) so subtitles keep the same relative size as the window grows or shrinks, with a 12px legibility floor. Rename useSubtitlePositionStyle to useSubtitleLayout, returning both the position style and the font scale, and add unit tests for the appearance helpers. * fix(player): satisfy strict index checks in transcode quality test * feat(player): let the pre-play Auto option clear the saved subtitle override A manual in-player subtitle selection persists as an 'always' override for that movie/series, but nothing in the UI could undo it — auto selection stayed pinned to the chosen track forever. Choosing 'Auto' in the pre-play subtitles popover now also deletes the stored preference (movie content ID / episode series ID) and invalidates item details so profile-level auto selection applies again. * feat(player): persist pre-play subtitle selections as the item override Choosing a track (or Off) in the pre-play subtitles popover only lived in component state: it applied to that playback session but vanished on returning to the detail page. Persist it through PUT /subtitle-prefs — the same 'always'/'off' override a manual in-player selection saves — keyed by movie content ID or episode series ID, and invalidate item details so the effective defaults reflect it immediately. * feat(ui): show the saved subtitle override and richer pre-play pill summaries A stored per-item override displayed as 'Auto: <language>', hiding both that an override exists and which track it is. The pre-play subtitle pill now shows the resolved track directly (name with (SDH)/(Forced) markers plus format, skipping markers the name already carries), the matching list row gets the checkmark instead of Auto, and the Auto row reads 'Reset to profile defaults'. Subtitle, audio, edition, and version pill summaries also truncate much later (max-w-44/sm:max-w-64). * fix(player): recover text subtitles from stream reloads and failed window fetches Three failure modes could silently freeze or stop web text subtitles: - A stream restart (seek-triggered transcode restart, quality/audio switch) reloads the <video> element and can orphan the programmatic TextTrack — cuechange stops firing and the last cue freezes on screen. Only the PGS-burn-in-off transition rebuilt the track. Now every settled stream URL change bumps the generation, and the rebuild carries loaded cues (converted back to source time) and window coverage over so it costs no refetch. - The sliding-window fetcher committed windowEnd before the fetch ran, so a failed or hung window counted as covered and was never retried — subtitles silently stopped for up to 10 minutes. Coverage now commits only after the window streams in fully; failures leave the range uncovered and retry after a 5s backoff. - A hung extraction (one fetch in flight at a time, no deadline) blocked every future window for the session. Reads now arm a 30s stall timer that aborts a response which stops delivering chunks; slow-but- progressing streams keep resetting it. Diagnosed from a session where ffmpeg took 69s to stream one subtitle window and a transcode restart landed mid-fetch, freezing the active cue. * fix(playback): keep subtitle selections stable across file changes * fix(web): clarify subtitle labels and positioning * fix(player): hide HUD when pointer leaves * fix(web): tidy subtitle track badges * fix(playback): remap audio tracks across file versions * fix(web): tidy audio track labels * docs(playback): clarify bitmap subtitle appearance * fix(playback): preserve selection state on restart * fix(http): preserve response state across flushes * fix(web): preserve pending quality for burn-in * fix(playback): preserve subtitle inventory identity --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Quick
parent
9cfd01e9c2
commit
c1c110e3d2
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-24
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+24
-13
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-8
@@ -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
|
||||
|
||||
|
||||
@@ -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<typeof useQueryClient>): Promise<unknown> {
|
||||
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<void>(`/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<void>(`/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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -137,14 +137,31 @@ export function parseSubtitleAppearance(json: string | null): SubtitleAppearance
|
||||
|
||||
// ─── Style Computation ──────────────────────────────────────────────────────
|
||||
|
||||
const FONT_SIZE_MAP: Record<SubtitleAppearance["fontSize"], string> = {
|
||||
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<SubtitleAppearance["fontSize"], number> = {
|
||||
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<SubtitleAppearance["position"], number> = {
|
||||
bottom: 0.07,
|
||||
"lower-third": 0.18,
|
||||
"lower-third": 0.12,
|
||||
top: 0.07,
|
||||
};
|
||||
|
||||
@@ -230,25 +248,23 @@ const POSITION_OFFSETS: Record<SubtitleAppearance["position"], number> = {
|
||||
*/
|
||||
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.
|
||||
|
||||
@@ -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: () => <div />,
|
||||
}));
|
||||
|
||||
@@ -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<number | null>(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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -34,7 +34,7 @@ function AudioOptionRow({
|
||||
const content = (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{badges}
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ export default function AudioTracksPopover({
|
||||
>
|
||||
<Volume2 className="size-3.5" />
|
||||
Audio
|
||||
<span className="text-muted-foreground max-w-24 truncate text-[11px] font-normal sm:max-w-28">
|
||||
<span className="text-muted-foreground max-w-44 truncate text-[11px] font-normal sm:max-w-64">
|
||||
{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 ");
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{badges}
|
||||
</div>
|
||||
@@ -128,29 +131,37 @@ function SubtitleSection({
|
||||
<div>
|
||||
<div className="text-muted-foreground/60 mb-1 px-3 text-[11px] font-medium">{title}</div>
|
||||
<div className="space-y-0.5">
|
||||
{rows.map((row) => (
|
||||
<SelectionRow
|
||||
key={row.key}
|
||||
active={subtitleSelectionEquals(activeSelection, row.selection)}
|
||||
title={row.summary}
|
||||
description={row.title || row.releaseName}
|
||||
onSelect={onSelectSubtitle ? () => onSelectSubtitle(row.selection) : undefined}
|
||||
badges={
|
||||
<>
|
||||
{row.codec && (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] uppercase">
|
||||
{row.codec}
|
||||
</Badge>
|
||||
)}
|
||||
<SubtitleFlagBadges
|
||||
forced={row.forced}
|
||||
hearingImpaired={row.hearingImpaired}
|
||||
isDefault={row.default}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{rows.map((row) => {
|
||||
const titleFlags = inferSubtitleFlagsFromTitle(row.title);
|
||||
const description =
|
||||
row.title && !titleFlags.flagOnly && !isSubtitleFormatLabel(row.title, row.codec)
|
||||
? row.title
|
||||
: row.releaseName;
|
||||
|
||||
return (
|
||||
<SelectionRow
|
||||
key={row.key}
|
||||
active={subtitleSelectionEquals(activeSelection, row.selection)}
|
||||
title={row.summary}
|
||||
description={description}
|
||||
onSelect={onSelectSubtitle ? () => onSelectSubtitle(row.selection) : undefined}
|
||||
badges={
|
||||
<>
|
||||
{row.codec && (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] uppercase">
|
||||
{getSubtitleFormatLabel(row.codec) || row.codec.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
<SubtitleFlagBadges
|
||||
forced={row.forced || titleFlags.forced}
|
||||
hearingImpaired={row.hearingImpaired || titleFlags.hearingImpaired}
|
||||
isDefault={row.default}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -233,7 +274,7 @@ export default function SubtitlesPopover({
|
||||
>
|
||||
<Captions className="size-3.5" />
|
||||
Subs
|
||||
<span className="text-muted-foreground max-w-20 truncate text-[11px] font-normal sm:max-w-24">
|
||||
<span className="text-muted-foreground max-w-44 truncate text-[11px] font-normal sm:max-w-64">
|
||||
{isInteractive ? activeSummary : candidates.all.length}
|
||||
</span>
|
||||
<ChevronDown className="text-muted-foreground size-3" />
|
||||
@@ -244,9 +285,13 @@ export default function SubtitlesPopover({
|
||||
{isInteractive && (
|
||||
<>
|
||||
<SelectionRow
|
||||
active={selectionMode === "auto"}
|
||||
active={selectionMode === "auto" && !overrideCandidate}
|
||||
title="Auto"
|
||||
description={autoCandidate?.summary ?? "Off"}
|
||||
description={
|
||||
overrideCandidate
|
||||
? "Reset to profile defaults"
|
||||
: (autoCandidate?.summary ?? "Off")
|
||||
}
|
||||
onSelect={onResetSelection}
|
||||
/>
|
||||
<SelectionRow
|
||||
@@ -259,17 +304,13 @@ export default function SubtitlesPopover({
|
||||
<SubtitleSection
|
||||
title="Embedded"
|
||||
rows={candidates.embedded}
|
||||
activeSelection={
|
||||
isInteractive && selectionMode === "explicit" ? explicitSelection : null
|
||||
}
|
||||
activeSelection={activeListSelection}
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
/>
|
||||
<SubtitleSection
|
||||
title="External"
|
||||
rows={candidates.external}
|
||||
activeSelection={
|
||||
isInteractive && selectionMode === "explicit" ? explicitSelection : null
|
||||
}
|
||||
activeSelection={activeListSelection}
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
/>
|
||||
{downloadedQuery.isLoading ? (
|
||||
@@ -281,9 +322,7 @@ export default function SubtitlesPopover({
|
||||
<SubtitleSection
|
||||
title="Downloaded"
|
||||
rows={candidates.downloaded}
|
||||
activeSelection={
|
||||
isInteractive && selectionMode === "explicit" ? explicitSelection : null
|
||||
}
|
||||
activeSelection={activeListSelection}
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function VersionDropdown({
|
||||
>
|
||||
<Layers3 className="size-3.5" />
|
||||
Edition
|
||||
<span className="text-muted-foreground max-w-24 truncate text-[11px] font-normal sm:max-w-28">
|
||||
<span className="text-muted-foreground max-w-44 truncate text-[11px] font-normal sm:max-w-64">
|
||||
{selectedEdition.label}
|
||||
</span>
|
||||
<ChevronDown className="text-muted-foreground size-3" />
|
||||
@@ -116,7 +116,7 @@ export default function VersionDropdown({
|
||||
>
|
||||
<Disc3 className="size-3.5" />
|
||||
Version
|
||||
<span className="text-muted-foreground max-w-24 truncate text-[11px] font-normal sm:max-w-32">
|
||||
<span className="text-muted-foreground max-w-44 truncate text-[11px] font-normal sm:max-w-64">
|
||||
{activeVersion ? buildVersionTriggerSummary(activeVersion) : ""}
|
||||
</span>
|
||||
<ChevronDown className="text-muted-foreground size-3" />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<VersionSubtitleInventoryRow, "languageLabel" | "forced" | "hearingImpaired">,
|
||||
): 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<VersionSubtitleInventoryRow, "languageLabel" | "forced" | "hearingImpaired">,
|
||||
): 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),
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<string | null>(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 <video> element, which can orphan
|
||||
// a programmatic TextTrack — cuechange stops firing and the last cue
|
||||
// freezes on screen. Bump a generation on every settled stream change so
|
||||
// useSubtitleTracks rebuilds its track against the new element; the rebuild
|
||||
// carries loaded cues and window coverage over, so it costs no refetch.
|
||||
const [subtitleStreamGeneration, setSubtitleStreamGeneration] = useState(0);
|
||||
const lastSubtitleStreamUrlRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!effectiveStreamUrl) return;
|
||||
const changed =
|
||||
lastSubtitleStreamUrlRef.current !== null &&
|
||||
lastSubtitleStreamUrlRef.current !== effectiveStreamUrl;
|
||||
lastSubtitleStreamUrlRef.current = effectiveStreamUrl;
|
||||
if (changed) {
|
||||
setSubtitleStreamGeneration((generation) => generation + 1);
|
||||
}
|
||||
}, [effectiveStreamUrl]);
|
||||
|
||||
const isFirefoxBrowser =
|
||||
typeof navigator !== "undefined" &&
|
||||
/firefox/i.test(navigator.userAgent) &&
|
||||
@@ -429,8 +451,15 @@ export function VideoPlayer({
|
||||
displayedPlaybackState.playMethod === "remux";
|
||||
|
||||
// Keep the player-local clock mapped onto the canonical media timeline.
|
||||
streamOriginRef.current =
|
||||
const streamOriginSeconds =
|
||||
effectivePlayMethod === "transcode" ? transcodeQuality.streamOriginSeconds : 0;
|
||||
streamOriginRef.current = streamOriginSeconds;
|
||||
|
||||
// Media-time position playback is heading to, for consumers that need a
|
||||
// position before the element has media loaded (when currentTime still
|
||||
// reads 0): an in-flight seek target, else the session's start position.
|
||||
subtitleFetchAnchorRef.current =
|
||||
pendingSeekTime ?? toMediaTime(effectiveInitialPosition, streamOriginSeconds);
|
||||
|
||||
useEffect(() => {
|
||||
if (backendDuration > 0) {
|
||||
@@ -1524,28 +1553,39 @@ export function VideoPlayer({
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearControlsTimer = useCallback(() => {
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetControlsTimer = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
clearControlsTimer();
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (videoRef.current && !videoRef.current.paused) {
|
||||
setControlsVisible(false);
|
||||
}
|
||||
hideTimerRef.current = null;
|
||||
}, 3000);
|
||||
}, []);
|
||||
}, [clearControlsTimer]);
|
||||
|
||||
const hideControlsOnMouseLeave = useCallback(() => {
|
||||
clearControlsTimer();
|
||||
setControlsVisible(false);
|
||||
}, [clearControlsTimer]);
|
||||
|
||||
// Show controls when paused, start hide timer when playing.
|
||||
useEffect(() => {
|
||||
if (!playing) {
|
||||
setControlsVisible(true);
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
clearControlsTimer();
|
||||
} else {
|
||||
resetControlsTimer();
|
||||
}
|
||||
return () => {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
};
|
||||
}, [playing, resetControlsTimer]);
|
||||
return clearControlsTimer;
|
||||
}, [clearControlsTimer, playing, resetControlsTimer]);
|
||||
|
||||
// -- Marker editing --
|
||||
const currentMarkers = useMemo<MarkerDraft>(
|
||||
@@ -1587,11 +1627,61 @@ export function VideoPlayer({
|
||||
|
||||
// -- Subtitle appearance --
|
||||
const { settings: subtitleSettings, containerStyle, cueStyle } = useSubtitleAppearance();
|
||||
const subtitlePositionStyle = useSubtitlePositionStyle(
|
||||
const { positionStyle: subtitlePositionStyle, fontScale: subtitleFontScale } = useSubtitleLayout(
|
||||
containerRef,
|
||||
videoRef,
|
||||
subtitleSettings.position,
|
||||
);
|
||||
// Scale cue text with the rendered video so subtitles stay proportionally
|
||||
// the same size as the window grows or shrinks.
|
||||
const scaledCueStyle = useMemo(
|
||||
() => ({
|
||||
...cueStyle,
|
||||
fontSize: computeSubtitleFontSize(subtitleSettings.fontSize, subtitleFontScale),
|
||||
}),
|
||||
[cueStyle, subtitleSettings.fontSize, subtitleFontScale],
|
||||
);
|
||||
|
||||
// Measure the bottom control bar so bottom-anchored subtitles can lift just
|
||||
// above it while it's visible (YouTube-style) instead of hiding behind it.
|
||||
// The base offset scales with the player, but the bar is a roughly fixed
|
||||
// pixel height, so measure rather than hardcode. The .player-hud element is
|
||||
// laid out even while the controls are faded out, so its height is readable
|
||||
// regardless of visibility.
|
||||
const [controlBarHeight, setControlBarHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || isDetached) return;
|
||||
const hud = container.querySelector<HTMLElement>(".player-hud");
|
||||
if (!hud) return;
|
||||
const update = () => setControlBarHeight(hud.offsetHeight);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(hud);
|
||||
return () => ro.disconnect();
|
||||
}, [isDetached, isPlayerReady, displayMode]);
|
||||
|
||||
// Bottom-anchored cues rise to clear the control bar (plus a small gap) only
|
||||
// while the bar is up and only in the main foreground player; top-anchored
|
||||
// cues never collide with the bottom HUD, so they don't move.
|
||||
const SUBTITLE_HUD_GAP = 12;
|
||||
const baseSubtitleBottomPx = (() => {
|
||||
const raw = subtitlePositionStyle.bottom;
|
||||
if (typeof raw === "number") return Number.isFinite(raw) ? raw : 0;
|
||||
if (typeof raw === "string" && raw.endsWith("px")) {
|
||||
const parsed = parseFloat(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
const subtitlesLifted =
|
||||
displayMode === "foreground" &&
|
||||
controlsVisible &&
|
||||
subtitleSettings.position !== "top" &&
|
||||
controlBarHeight > 0;
|
||||
const subtitleLiftPx = subtitlesLifted
|
||||
? Math.max(0, controlBarHeight + SUBTITLE_HUD_GAP - baseSubtitleBottomPx)
|
||||
: 0;
|
||||
|
||||
// -- Subtitle cue matching --
|
||||
// Returns active cue texts for custom rendering instead of native TextTrack
|
||||
@@ -1600,10 +1690,13 @@ export function VideoPlayer({
|
||||
videoRef,
|
||||
effectiveSubtitleTracks,
|
||||
activeSubtitleIndex,
|
||||
streamOriginRef,
|
||||
streamOriginSeconds,
|
||||
subtitleDelayMs,
|
||||
durationRef,
|
||||
subtitleFetchAnchorRef,
|
||||
liveCues,
|
||||
liveTranslation?.trackKey ?? null,
|
||||
subtitleStreamGeneration,
|
||||
);
|
||||
|
||||
// -- ASS/SSA subtitle rendering via JASSUB (client-side libass) --
|
||||
@@ -1612,19 +1705,62 @@ export function VideoPlayer({
|
||||
subtitleUrls,
|
||||
activeSubtitleIndex,
|
||||
isDetached,
|
||||
transcodeQuality.streamOriginSeconds,
|
||||
streamOriginSeconds,
|
||||
subtitleDelayMs,
|
||||
);
|
||||
|
||||
// -- PGS (Blu-ray bitmap) subtitle rendering via libpgs --
|
||||
const { isActive: isPGSActive } = usePGSSubtitles(
|
||||
videoRef,
|
||||
subtitleUrls,
|
||||
activeSubtitleIndex,
|
||||
isDetached,
|
||||
transcodeQuality.streamOriginSeconds,
|
||||
subtitleDelayMs,
|
||||
// -- Bitmap (PGS/DVD/DVB) subtitle burn-in --
|
||||
// Bitmap tracks are composited into the video server-side (the "Plex
|
||||
// route"): selecting one restarts the transcode with subtitle_burn_in at
|
||||
// the current aligned position, reusing the same restart machinery as a
|
||||
// quality/audio switch (including its loading state). No client-side
|
||||
// renderer runs for these tracks — useSubtitleTracks skips bitmap codecs.
|
||||
const externalSubtitleCount = useMemo(
|
||||
() => subtitleUrls.filter((track) => track.source === "external").length,
|
||||
[subtitleUrls],
|
||||
);
|
||||
const activeSubtitleTrack =
|
||||
activeSubtitleIndex !== null
|
||||
? (effectiveSubtitleTracks.find((track) => track.index === activeSubtitleIndex) ?? null)
|
||||
: null;
|
||||
// ffmpeg's burn-in filters index subtitle streams only, so translate the
|
||||
// session-wide track index (external tracks list first) into the embedded
|
||||
// subtitle ordinal.
|
||||
const burnInSubtitleOrdinal =
|
||||
activeSubtitleTrack &&
|
||||
activeSubtitleTrack.source === "embedded" &&
|
||||
isBitmapCodec(activeSubtitleTrack.codec)
|
||||
? activeSubtitleTrack.index - externalSubtitleCount
|
||||
: null;
|
||||
const burnInSubtitleMediaFileId =
|
||||
burnInSubtitleOrdinal != null ? activeSubtitleTrack?.media_file_id : undefined;
|
||||
const setSubtitleBurnIn = transcodeQuality.setSubtitleBurnIn;
|
||||
useEffect(() => {
|
||||
// Until the element has media loaded (an auto-selected bitmap preference
|
||||
// at session start, or a stream restart), currentTime still reads 0
|
||||
// rather than the resume/seek target — use the intended position instead,
|
||||
// exactly like the subtitle window fetcher does.
|
||||
const video = videoRef.current;
|
||||
const position =
|
||||
video && video.readyState > 0
|
||||
? currentTimeRef.current
|
||||
: (subtitleFetchAnchorRef.current ?? 0);
|
||||
setSubtitleBurnIn(burnInSubtitleOrdinal, position, burnInSubtitleMediaFileId);
|
||||
}, [burnInSubtitleMediaFileId, burnInSubtitleOrdinal, setSubtitleBurnIn]);
|
||||
|
||||
// A failed burn-in restart rolls the transcode hook back to no bitmap
|
||||
// subtitle. Mirror that rollback in the visible selection so the UI never
|
||||
// claims an unavailable track is active, and selecting it again performs a
|
||||
// real retry instead of being suppressed as an unchanged selection.
|
||||
useEffect(() => {
|
||||
if (
|
||||
burnInSubtitleOrdinal != null &&
|
||||
transcodeQuality.burnInSubtitleIndex == null &&
|
||||
transcodeQuality.error
|
||||
) {
|
||||
setActiveSubtitleIndex(null);
|
||||
}
|
||||
}, [burnInSubtitleOrdinal, transcodeQuality.burnInSubtitleIndex, transcodeQuality.error]);
|
||||
|
||||
// -- Auto-select subtitle track based on mode --
|
||||
useEffect(() => {
|
||||
@@ -2075,6 +2211,8 @@ export function VideoPlayer({
|
||||
}
|
||||
style={displayMode === "postroll" ? { width: miniPlayerWidth } : undefined}
|
||||
onClick={isPostrollVisible ? handleMiniPlayerClick : undefined}
|
||||
onMouseEnter={isDetached ? undefined : resetControlsTimer}
|
||||
onMouseLeave={isDetached ? undefined : hideControlsOnMouseLeave}
|
||||
onMouseMove={isDetached ? undefined : resetControlsTimer}
|
||||
>
|
||||
{/* Postroll resize handle (bottom-left corner) */}
|
||||
@@ -2266,17 +2404,28 @@ export function VideoPlayer({
|
||||
style={!isPlayerReady ? { visibility: "hidden" } : undefined}
|
||||
/>
|
||||
|
||||
{/* Subtitle overlay — suppressed when JASSUB (ASS) or libpgs (PGS) is rendering */}
|
||||
{!isDetached && !isASSActive && !isPGSActive && activeCueTexts.length > 0 && (
|
||||
{/* Subtitle overlay — suppressed when JASSUB (ASS) is rendering; bitmap
|
||||
tracks are burned into the video server-side and never reach here.
|
||||
While the control bar is up, bottom-anchored cues rise just above it
|
||||
(subtitleLiftPx) so they never overlap the HUD; they settle back when
|
||||
it hides. z-[5] keeps cues below the controls layer (z-10) as a
|
||||
safety, so any residual overlap tucks behind the bar rather than
|
||||
painting on top of it. */}
|
||||
{!isDetached && !isASSActive && activeCueTexts.length > 0 && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 z-20 flex flex-col items-center gap-1"
|
||||
style={{ ...containerStyle, ...subtitlePositionStyle }}
|
||||
className="pointer-events-none absolute inset-x-0 z-[5] flex flex-col items-center gap-1"
|
||||
style={{
|
||||
...containerStyle,
|
||||
...subtitlePositionStyle,
|
||||
transform: `translateY(-${subtitleLiftPx}px)`,
|
||||
transition: "transform 200ms ease",
|
||||
}}
|
||||
>
|
||||
{activeCueTexts.map((text, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block rounded px-3 py-1 text-center leading-snug"
|
||||
style={{ ...cueStyle, whiteSpace: "pre-line" }}
|
||||
style={{ ...scaledCueStyle, whiteSpace: "pre-line" }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import type { RefObject } from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePGSSubtitles } from "./usePGSSubtitles";
|
||||
import type { PlayerSubtitleInfo } from "../types";
|
||||
|
||||
// Capture every constructed renderer so tests can assert options and
|
||||
// lifecycle (libpgs fetches subUrl inside its worker — no fetch mocking).
|
||||
const constructorOpts: Array<Record<string, unknown>> = [];
|
||||
const disposeSpies: Array<ReturnType<typeof vi.fn>> = [];
|
||||
const renderers: MockPgsRenderer[] = [];
|
||||
|
||||
class MockPgsRenderer {
|
||||
timeOffset: number;
|
||||
dispose = vi.fn();
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
constructorOpts.push(opts);
|
||||
this.timeOffset = (opts.timeOffset as number) ?? 0;
|
||||
disposeSpies.push(this.dispose);
|
||||
renderers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("libpgs", () => ({ PgsRenderer: MockPgsRenderer }));
|
||||
vi.mock("libpgs/dist/libpgs.worker.js?url", () => ({ default: "/assets/libpgs.worker.js" }));
|
||||
|
||||
function makeVideoRef(): RefObject<HTMLVideoElement | null> {
|
||||
return { current: document.createElement("video") };
|
||||
}
|
||||
|
||||
const pgsTrack: PlayerSubtitleInfo = {
|
||||
index: 3,
|
||||
language: "eng",
|
||||
codec: "hdmv_pgs_subtitle",
|
||||
label: "English (PGS)",
|
||||
source: "embedded",
|
||||
url: "/api/v1/stream/x/subtitles/3.sup?token=abc",
|
||||
};
|
||||
|
||||
const otherPgsTrack: PlayerSubtitleInfo = {
|
||||
index: 4,
|
||||
language: "ger",
|
||||
codec: "pgs",
|
||||
label: "German (PGS)",
|
||||
source: "embedded",
|
||||
url: "/api/v1/stream/x/subtitles/4.sup?token=abc",
|
||||
};
|
||||
|
||||
const srtTrack: PlayerSubtitleInfo = {
|
||||
index: 1,
|
||||
language: "eng",
|
||||
codec: "subrip",
|
||||
label: "English",
|
||||
source: "embedded",
|
||||
url: "/api/v1/stream/x/subtitles/1.vtt?token=abc",
|
||||
};
|
||||
|
||||
const tracks = [srtTrack, pgsTrack, otherPgsTrack];
|
||||
|
||||
beforeEach(() => {
|
||||
constructorOpts.length = 0;
|
||||
disposeSpies.length = 0;
|
||||
renderers.length = 0;
|
||||
});
|
||||
|
||||
describe("usePGSSubtitles", () => {
|
||||
it("creates a renderer wired to the video and .sup URL for a PGS track", async () => {
|
||||
const videoRef = makeVideoRef();
|
||||
const { result } = renderHook(() => usePGSSubtitles(videoRef, tracks, 3, false, 0, 0));
|
||||
|
||||
await waitFor(() => expect(constructorOpts).toHaveLength(1));
|
||||
|
||||
const opts = constructorOpts[0]!;
|
||||
expect(opts.video).toBe(videoRef.current);
|
||||
expect(opts.subUrl).toBe(pgsTrack.url);
|
||||
expect(opts.workerUrl).toBe("/assets/libpgs.worker.js");
|
||||
expect(opts.aspectRatio).toBe("contain");
|
||||
expect(result.current.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing for text and ASS tracks", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ index }) => usePGSSubtitles(makeVideoRef(), tracks, index, false, 0, 0),
|
||||
{ initialProps: { index: 1 as number | null } },
|
||||
);
|
||||
|
||||
expect(result.current.isActive).toBe(false);
|
||||
|
||||
rerender({ index: null });
|
||||
await Promise.resolve();
|
||||
expect(constructorOpts).toHaveLength(0);
|
||||
expect(result.current.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("applies stream origin and subtracts user delay from the time offset", async () => {
|
||||
// Positive delay shows subtitles later; libpgs looks up the display set
|
||||
// at currentTime + timeOffset, so later means a smaller offset.
|
||||
renderHook(() => usePGSSubtitles(makeVideoRef(), tracks, 3, false, 30, 2000));
|
||||
|
||||
await waitFor(() => expect(constructorOpts).toHaveLength(1));
|
||||
expect(constructorOpts[0]!.timeOffset).toBe(28);
|
||||
});
|
||||
|
||||
it("updates the time offset in place without recreating the renderer", async () => {
|
||||
const videoRef = makeVideoRef();
|
||||
const { rerender } = renderHook(
|
||||
({ delay }) => usePGSSubtitles(videoRef, tracks, 3, false, 0, delay),
|
||||
{ initialProps: { delay: 0 } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(renderers).toHaveLength(1));
|
||||
|
||||
rerender({ delay: 1500 });
|
||||
await waitFor(() => expect(renderers[0]!.timeOffset).toBe(-1.5));
|
||||
expect(renderers).toHaveLength(1);
|
||||
expect(disposeSpies[0]!).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disposes the renderer when switching to a non-PGS track", async () => {
|
||||
const videoRef = makeVideoRef();
|
||||
const { result, rerender } = renderHook(
|
||||
({ index }) => usePGSSubtitles(videoRef, tracks, index, false, 0, 0),
|
||||
{ initialProps: { index: 3 } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(renderers).toHaveLength(1));
|
||||
|
||||
rerender({ index: 1 });
|
||||
await waitFor(() => expect(disposeSpies[0]!).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("recreates the renderer when switching between PGS tracks", async () => {
|
||||
const videoRef = makeVideoRef();
|
||||
const { rerender } = renderHook(
|
||||
({ index }) => usePGSSubtitles(videoRef, tracks, index, false, 0, 0),
|
||||
{ initialProps: { index: 3 } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(renderers).toHaveLength(1));
|
||||
|
||||
rerender({ index: 4 });
|
||||
await waitFor(() => expect(renderers).toHaveLength(2));
|
||||
expect(disposeSpies[0]!).toHaveBeenCalledTimes(1);
|
||||
expect(constructorOpts[1]!.subUrl).toBe(otherPgsTrack.url);
|
||||
});
|
||||
|
||||
it("disposes the renderer while detached and reports inactive", async () => {
|
||||
const videoRef = makeVideoRef();
|
||||
const { result, rerender } = renderHook(
|
||||
({ detached }) => usePGSSubtitles(videoRef, tracks, 3, detached, 0, 0),
|
||||
{ initialProps: { detached: false } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(renderers).toHaveLength(1));
|
||||
|
||||
rerender({ detached: true });
|
||||
await waitFor(() => expect(disposeSpies[0]!).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("disposes the renderer on unmount", async () => {
|
||||
const { unmount } = renderHook(() => usePGSSubtitles(makeVideoRef(), tracks, 3, false, 0, 0));
|
||||
|
||||
await waitFor(() => expect(renderers).toHaveLength(1));
|
||||
|
||||
unmount();
|
||||
expect(disposeSpies[0]!).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { PgsRenderer } from "libpgs";
|
||||
import libpgsWorkerUrl from "libpgs/dist/libpgs.worker.js?url";
|
||||
import type { PlayerSubtitleInfo } from "../types";
|
||||
import { isPGSCodec } from "../utils/subtitleCodecs";
|
||||
|
||||
/**
|
||||
* Manages client-side PGS (Blu-ray bitmap) subtitle rendering via libpgs.
|
||||
*
|
||||
* When a PGS-codec subtitle track is active, this hook lazy-loads libpgs and
|
||||
* creates a PgsRenderer attached to the video element. The renderer's worker
|
||||
* fetches the .sup stream itself (the URL already carries auth via ?token=),
|
||||
* decodes display sets progressively as bytes arrive, and draws them onto a
|
||||
* canvas it positions over the video. When a non-PGS track is selected (or
|
||||
* subtitles are turned off), the renderer is disposed.
|
||||
*
|
||||
* Coordination mirrors useASSSubtitles: the `isActive` return value tells the
|
||||
* player to suppress the VTT text overlay while bitmap rendering is active.
|
||||
*/
|
||||
export function usePGSSubtitles(
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>,
|
||||
subtitleUrls: PlayerSubtitleInfo[],
|
||||
activeSubtitleIndex: number | null,
|
||||
isDetached: boolean,
|
||||
streamOriginSeconds: number,
|
||||
subtitleDelayMs: number,
|
||||
): { isActive: boolean } {
|
||||
const rendererRef = useRef<PgsRenderer | null>(null);
|
||||
const libpgsImportRef = useRef<Promise<typeof import("libpgs")> | null>(null);
|
||||
// libpgs renders the display set whose timestamp matches
|
||||
// `video.currentTime + timeOffset`, and .sup timestamps are in source media
|
||||
// time. With HLS PTS rebasing, currentTime + streamOrigin = media time, so
|
||||
// the origin adds. A positive user delay should show subtitles *later*
|
||||
// (VTTCue semantics: cues shift forward), which here means looking up an
|
||||
// *earlier* display set at any instant — so the delay subtracts.
|
||||
const effectiveOffset = streamOriginSeconds - subtitleDelayMs / 1000;
|
||||
const offsetRef = useRef(effectiveOffset);
|
||||
offsetRef.current = effectiveOffset;
|
||||
|
||||
// Resolve the active subtitle track.
|
||||
const activeSub =
|
||||
activeSubtitleIndex !== null
|
||||
? (subtitleUrls.find((s) => s.index === activeSubtitleIndex) ?? null)
|
||||
: null;
|
||||
|
||||
const isPGS = activeSub !== null && isPGSCodec(activeSub.codec);
|
||||
const activeUrl = isPGS ? activeSub.url : null;
|
||||
|
||||
// Main effect: create/destroy the renderer based on the active track.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
|
||||
if (!activeUrl || !video || isDetached) {
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.dispose();
|
||||
rendererRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function initRenderer() {
|
||||
if (!video || cancelled) return;
|
||||
|
||||
// Lazy-load the libpgs module (only once).
|
||||
if (!libpgsImportRef.current) {
|
||||
libpgsImportRef.current = import("libpgs");
|
||||
}
|
||||
const { PgsRenderer: PgsRendererClass } = await libpgsImportRef.current;
|
||||
if (cancelled) return;
|
||||
|
||||
const renderer = new PgsRendererClass({
|
||||
video,
|
||||
subUrl: activeUrl!,
|
||||
workerUrl: libpgsWorkerUrl,
|
||||
timeOffset: offsetRef.current,
|
||||
// Must match the video element's object-fit so subtitle positions
|
||||
// stay anchored to the video content box, not the element box.
|
||||
aspectRatio: "contain",
|
||||
});
|
||||
|
||||
// Guard against the effect being cleaned up while the constructor ran.
|
||||
if (cancelled) {
|
||||
renderer.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
rendererRef.current = renderer;
|
||||
}
|
||||
|
||||
void initRenderer();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// dispose() removes the canvas and terminates the worker, which also
|
||||
// abandons its in-flight .sup fetch — no separate AbortController.
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.dispose();
|
||||
rendererRef.current = null;
|
||||
}
|
||||
};
|
||||
// videoRef is a stable ref object. effectiveOffset is read from
|
||||
// offsetRef inside the async function to always get the latest value.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeUrl, isDetached]);
|
||||
|
||||
// Update the renderer's time offset when the media timeline remaps or the
|
||||
// user nudges subtitle sync — without recreating the renderer.
|
||||
useEffect(() => {
|
||||
const renderer = rendererRef.current;
|
||||
if (!renderer || !activeUrl) return;
|
||||
renderer.timeOffset = effectiveOffset;
|
||||
}, [effectiveOffset, activeUrl]);
|
||||
|
||||
// Cleanup on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.dispose();
|
||||
rendererRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { isActive: isPGS && !isDetached };
|
||||
}
|
||||
@@ -23,6 +23,7 @@ describe("buildStartPlaybackRequestPayload", () => {
|
||||
file_id: 42,
|
||||
profile_id: "profile-1",
|
||||
start_position: 0,
|
||||
supports_bitmap_subtitle_burn_in: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface StartPlaybackRequestPayload {
|
||||
containers: string[];
|
||||
max_resolution: string;
|
||||
hdr: boolean;
|
||||
supports_bitmap_subtitle_burn_in: boolean;
|
||||
}
|
||||
|
||||
export function buildStartPlaybackRequestPayload({
|
||||
@@ -101,6 +102,7 @@ export function buildStartPlaybackRequestPayload({
|
||||
containers,
|
||||
max_resolution: maxResolution,
|
||||
hdr,
|
||||
supports_bitmap_subtitle_burn_in: true,
|
||||
};
|
||||
|
||||
if (forceInitialPosition || position > 0) {
|
||||
@@ -491,6 +493,7 @@ export function usePlaybackSession(
|
||||
const token = config.getAccessToken();
|
||||
const newTracks: PlayerSubtitleInfo[] = downloaded.map((dl, i) => ({
|
||||
index: baseIndex + i,
|
||||
media_file_id: mediaFile,
|
||||
id: dl.id,
|
||||
language: dl.language,
|
||||
codec: dl.format,
|
||||
|
||||
+23
-8
@@ -1,19 +1,26 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type RefObject } from "react";
|
||||
import { computeSubtitlePositionStyle } from "@/lib/subtitleAppearance";
|
||||
import { computeSubtitleFontScale, computeSubtitlePositionStyle } from "@/lib/subtitleAppearance";
|
||||
import type { SubtitleAppearance } from "@/lib/subtitleAppearance";
|
||||
|
||||
export interface SubtitleLayout {
|
||||
positionStyle: CSSProperties;
|
||||
/** Multiplier for cue font size so text scales with the rendered video. */
|
||||
fontScale: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the player container size and the video's intrinsic aspect ratio,
|
||||
* then produces a position CSS style that anchors subtitles to a 16:9
|
||||
* reference frame centered on the actually-rendered video area (object-fit:
|
||||
* contain) rather than the player window. Falls back to container-relative
|
||||
* percentages until measurements are available.
|
||||
* then produces a position CSS style and a font scale. The Bottom position is
|
||||
* anchored to the player window, while Lower Third and Top are anchored to a
|
||||
* 16:9 reference frame centered on the actually-rendered video area
|
||||
* (object-fit: contain). Falls back to container-relative percentages and
|
||||
* scale 1 until measurements are available.
|
||||
*/
|
||||
export function useSubtitlePositionStyle(
|
||||
export function useSubtitleLayout(
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
videoRef: RefObject<HTMLVideoElement | null>,
|
||||
position: SubtitleAppearance["position"],
|
||||
): CSSProperties {
|
||||
): SubtitleLayout {
|
||||
const [playerSize, setPlayerSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
|
||||
const [videoAspect, setVideoAspect] = useState(0);
|
||||
|
||||
@@ -45,7 +52,15 @@ export function useSubtitlePositionStyle(
|
||||
}, [videoRef]);
|
||||
|
||||
return useMemo(
|
||||
() => computeSubtitlePositionStyle(position, playerSize.w, playerSize.h, videoAspect),
|
||||
() => ({
|
||||
positionStyle: computeSubtitlePositionStyle(
|
||||
position,
|
||||
playerSize.w,
|
||||
playerSize.h,
|
||||
videoAspect,
|
||||
),
|
||||
fontScale: computeSubtitleFontScale(playerSize.w, playerSize.h, videoAspect),
|
||||
}),
|
||||
[position, playerSize.w, playerSize.h, videoAspect],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import type { RefObject } from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSubtitleTracks } from "./useSubtitleTracks";
|
||||
import type { PlayerSubtitleInfo } from "../types";
|
||||
|
||||
// jsdom implements neither addTextTrack nor VTTCue; provide minimal fakes
|
||||
// that keep the shapes the hook relies on (cues list, add/removeCue,
|
||||
// cuechange listeners).
|
||||
class FakeVTTCue {
|
||||
constructor(
|
||||
public startTime: number,
|
||||
public endTime: number,
|
||||
public text: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
class FakeTextTrack {
|
||||
mode = "hidden";
|
||||
cues: FakeVTTCue[] = [];
|
||||
activeCues: FakeVTTCue[] = [];
|
||||
addEventListener = vi.fn();
|
||||
removeEventListener = vi.fn();
|
||||
addCue(cue: FakeVTTCue) {
|
||||
this.cues.push(cue);
|
||||
}
|
||||
removeCue(cue: FakeVTTCue) {
|
||||
const i = this.cues.indexOf(cue);
|
||||
if (i >= 0) this.cues.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const createdTracks: FakeTextTrack[] = [];
|
||||
|
||||
// jsdom media elements always report readyState 0 (nothing ever loads);
|
||||
// default to HAVE_METADATA so tests exercise the normal "clock is
|
||||
// trustworthy" path unless a test opts into the unloaded state.
|
||||
function makeVideoRef(readyState = 1): RefObject<HTMLVideoElement | null> {
|
||||
const video = document.createElement("video");
|
||||
(video as unknown as { addTextTrack: () => FakeTextTrack }).addTextTrack = () => {
|
||||
const track = new FakeTextTrack();
|
||||
createdTracks.push(track);
|
||||
return track;
|
||||
};
|
||||
Object.defineProperty(video, "readyState", { value: readyState, configurable: true });
|
||||
return { current: video };
|
||||
}
|
||||
|
||||
/** One-shot streamed response yielding the whole VTT body in a single chunk. */
|
||||
function vttResponse(body: string) {
|
||||
const encoder = new TextEncoder();
|
||||
let sent = false;
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
if (sent) return { done: true, value: undefined };
|
||||
sent = true;
|
||||
return { done: false, value: encoder.encode(body) };
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const srtTrack: PlayerSubtitleInfo = {
|
||||
index: 1,
|
||||
language: "eng",
|
||||
codec: "subrip",
|
||||
label: "English",
|
||||
source: "embedded",
|
||||
url: "/api/v1/stream/x/subtitles/1?token=abc",
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
createdTracks.length = 0;
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubGlobal("VTTCue", FakeVTTCue);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderTracks(initial: {
|
||||
origin: number;
|
||||
durationRef: RefObject<number>;
|
||||
anchorRef?: RefObject<number>;
|
||||
readyState?: number;
|
||||
}) {
|
||||
const videoRef = makeVideoRef(initial.readyState);
|
||||
const defaultAnchor = { current: 0 };
|
||||
const hook = renderHook(
|
||||
({ origin, durationRef, anchorRef }) =>
|
||||
useSubtitleTracks(
|
||||
videoRef,
|
||||
[srtTrack],
|
||||
1,
|
||||
origin,
|
||||
0,
|
||||
durationRef,
|
||||
anchorRef ?? defaultAnchor,
|
||||
),
|
||||
{ initialProps: initial },
|
||||
);
|
||||
return { videoRef, ...hook };
|
||||
}
|
||||
|
||||
describe("useSubtitleTracks", () => {
|
||||
it("adds cues rebased by the stream origin", async () => {
|
||||
fetchMock.mockResolvedValue(vttResponse("WEBVTT\n\n00:01:40.000 --> 00:01:44.000\nhello\n\n"));
|
||||
|
||||
renderTracks({ origin: 60, durationRef: { current: 7200 } });
|
||||
|
||||
await waitFor(() => expect(createdTracks[0]?.cues).toHaveLength(1));
|
||||
const cue = createdTracks[0]!.cues[0]!;
|
||||
expect(cue.startTime).toBe(40);
|
||||
expect(cue.endTime).toBe(44);
|
||||
expect(cue.text).toBe("hello");
|
||||
});
|
||||
|
||||
it("shifts existing cues in place when the stream origin changes, without refetching", async () => {
|
||||
fetchMock.mockResolvedValue(vttResponse("WEBVTT\n\n00:01:40.000 --> 00:01:44.000\nhello\n\n"));
|
||||
|
||||
const { rerender } = renderTracks({ origin: 0, durationRef: { current: 7200 } });
|
||||
|
||||
await waitFor(() => expect(createdTracks[0]?.cues).toHaveLength(1));
|
||||
expect(createdTracks[0]!.cues[0]!.startTime).toBe(100);
|
||||
const fetchCount = fetchMock.mock.calls.length;
|
||||
|
||||
// Copy-mode session restart at a later position: origin jumps forward.
|
||||
rerender({ origin: 60, durationRef: { current: 7200 } });
|
||||
|
||||
await waitFor(() => expect(createdTracks[0]!.cues[0]!.startTime).toBe(40));
|
||||
expect(createdTracks[0]!.cues[0]!.endTime).toBe(44);
|
||||
// Same track, same cues, no extra network round trip.
|
||||
expect(createdTracks).toHaveLength(1);
|
||||
expect(fetchMock.mock.calls.length).toBe(fetchCount);
|
||||
});
|
||||
|
||||
it("keeps prefetching past a window whose cues stop before the window end", async () => {
|
||||
// First window's only cue ends at 12s — far short of the 600s window.
|
||||
// That must NOT be treated as end-of-input (the file is 2h long).
|
||||
fetchMock.mockResolvedValue(vttResponse("WEBVTT\n\n00:00:10.000 --> 00:00:12.000\nearly\n\n"));
|
||||
|
||||
const { videoRef } = renderTracks({ origin: 0, durationRef: { current: 7200 } });
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(createdTracks[0]?.cues).toHaveLength(1));
|
||||
|
||||
// Play into the prefetch lead of the first window ([0, 600]).
|
||||
videoRef.current!.currentTime = 580;
|
||||
videoRef.current!.dispatchEvent(new Event("timeupdate"));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
expect(String(fetchMock.mock.calls[1]![0])).toContain("position=595");
|
||||
});
|
||||
|
||||
it("stops prefetching once the window reaches the media duration", async () => {
|
||||
fetchMock.mockResolvedValue(vttResponse("WEBVTT\n\n00:00:10.000 --> 00:00:12.000\nearly\n\n"));
|
||||
|
||||
// Whole file fits inside the first 600s window.
|
||||
const { videoRef } = renderTracks({ origin: 0, durationRef: { current: 500 } });
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(createdTracks[0]?.cues).toHaveLength(1));
|
||||
|
||||
videoRef.current!.currentTime = 580;
|
||||
videoRef.current!.dispatchEvent(new Event("timeupdate"));
|
||||
|
||||
// Give any (incorrect) follow-up fetch a chance to fire.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("anchors the initial fetch to the intended start position while no media is loaded", async () => {
|
||||
fetchMock.mockResolvedValue(vttResponse("WEBVTT\n\n23:20.000 --> 23:22.000\nresumed\n\n"));
|
||||
|
||||
// Resume at 1400s: the element hasn't loaded media yet (readyState 0,
|
||||
// currentTime 0), so the fetch must target the resume position, not 0.
|
||||
renderTracks({
|
||||
origin: 0,
|
||||
durationRef: { current: 2808 },
|
||||
anchorRef: { current: 1400 },
|
||||
readyState: 0,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(String(fetchMock.mock.calls[0]![0])).toContain("position=1398");
|
||||
});
|
||||
|
||||
it("resets and refetches on a forward seek past the covered window", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(vttResponse("WEBVTT\n\n00:00:10.000 --> 00:00:12.000\nearly\n\n"))
|
||||
.mockResolvedValueOnce(vttResponse("WEBVTT\n\n23:20.000 --> 23:22.000\nlate\n\n"));
|
||||
|
||||
const { videoRef } = renderTracks({ origin: 0, durationRef: { current: 7200 } });
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(createdTracks[0]?.cues).toHaveLength(1));
|
||||
|
||||
// Jump far past the first window ([0, 600]); the gap in between was
|
||||
// never fetched, so coverage must reset around the new position.
|
||||
videoRef.current!.currentTime = 1400;
|
||||
videoRef.current!.dispatchEvent(new Event("seeked"));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
expect(String(fetchMock.mock.calls[1]![0])).toContain("position=1398");
|
||||
// Reset dropped the old window's cues and loaded the new window's.
|
||||
await waitFor(() => expect(createdTracks[0]!.cues.map((c) => c.text)).toEqual(["late"]));
|
||||
});
|
||||
|
||||
it("rebuilds the track when the stream restarts (generation bump)", async () => {
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(vttResponse("WEBVTT\n\n00:00:10.000 --> 00:00:12.000\nhi\n\n")),
|
||||
);
|
||||
|
||||
const videoRef = makeVideoRef(1);
|
||||
const anchorRef = { current: 0 };
|
||||
const durationRef = { current: 7200 };
|
||||
const { rerender } = renderHook(
|
||||
({ generation }) =>
|
||||
useSubtitleTracks(
|
||||
videoRef,
|
||||
[srtTrack],
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
durationRef,
|
||||
anchorRef,
|
||||
undefined,
|
||||
null,
|
||||
generation,
|
||||
),
|
||||
{ initialProps: { generation: 0 } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(createdTracks).toHaveLength(1));
|
||||
await waitFor(() => expect(createdTracks[0]!.cues).toHaveLength(1));
|
||||
|
||||
// A transcode restart (seek, quality/audio switch, burn-in toggle)
|
||||
// reloads the <video> element and can orphan the track; a generation bump
|
||||
// must rebuild it against the new stream so the text subtitles still
|
||||
// render — carrying the loaded cues and coverage over instead of
|
||||
// refetching the window.
|
||||
rerender({ generation: 1 });
|
||||
|
||||
await waitFor(() => expect(createdTracks).toHaveLength(2));
|
||||
expect(createdTracks[1]!.cues).toHaveLength(1);
|
||||
expect(createdTracks[1]!.cues[0]!.text).toBe("hi");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries a failed window fetch after a backoff instead of marking it covered", async () => {
|
||||
let now = 1_000_000;
|
||||
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new Error("extraction died"))
|
||||
.mockResolvedValueOnce(
|
||||
vttResponse("WEBVTT\n\n00:00:10.000 --> 00:00:12.000\nrecovered\n\n"),
|
||||
);
|
||||
|
||||
const { videoRef } = renderTracks({ origin: 0, durationRef: { current: 7200 } });
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Inside the backoff window: no retry yet.
|
||||
videoRef.current!.currentTime = 5;
|
||||
videoRef.current!.dispatchEvent(new Event("timeupdate"));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Past the backoff: the uncovered range is retried and recovers.
|
||||
now += 6_000;
|
||||
videoRef.current!.dispatchEvent(new Event("timeupdate"));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(createdTracks[0]!.cues.map((c) => c.text)).toEqual(["recovered"]));
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat a failed window as covered when prefetching later", async () => {
|
||||
let now = 1_000_000;
|
||||
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(vttResponse("WEBVTT\n\n00:00:10.000 --> 00:00:12.000\nfirst\n\n"))
|
||||
.mockRejectedValueOnce(new Error("prefetch died"))
|
||||
.mockResolvedValueOnce(vttResponse("WEBVTT\n\n10:05.000 --> 10:07.000\nsecond\n\n"));
|
||||
|
||||
const { videoRef } = renderTracks({ origin: 0, durationRef: { current: 7200 } });
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(createdTracks[0]?.cues).toHaveLength(1));
|
||||
|
||||
// Enter the prefetch lead of window [0, 600] — this prefetch fails.
|
||||
videoRef.current!.currentTime = 580;
|
||||
videoRef.current!.dispatchEvent(new Event("timeupdate"));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
// After the backoff the same still-uncovered range is fetched again;
|
||||
// before the fix the failed window counted as covered and subtitles
|
||||
// silently stopped for its whole span.
|
||||
now += 6_000;
|
||||
videoRef.current!.dispatchEvent(new Event("timeupdate"));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
|
||||
expect(String(fetchMock.mock.calls[2]![0])).toContain("position=595");
|
||||
await waitFor(() =>
|
||||
expect(createdTracks[0]!.cues.map((c) => c.text)).toEqual(["first", "second"]),
|
||||
);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { parseVTT, type ParsedCue } from "../utils/parseVTT";
|
||||
import type { PlayerSubtitleInfo } from "../types";
|
||||
import { isASSCodec, isPGSCodec } from "../utils/subtitleCodecs";
|
||||
import { isASSCodec, isBitmapCodec } from "../utils/subtitleCodecs";
|
||||
import { toMediaTime } from "../utils/mediaTimeline";
|
||||
|
||||
// Each subtitle fetch covers this many source-time seconds. Matches the
|
||||
@@ -17,6 +17,30 @@ const WINDOW_OVERLAP = 5;
|
||||
// Pull back this many seconds from the current position when starting
|
||||
// a fresh fetch, so a quick scrub back still lands inside the window.
|
||||
const SEEK_BACKOFF = 2;
|
||||
// Abort a window fetch when the response goes this long without delivering
|
||||
// a chunk. Extraction streams cues progressively, so a healthy-but-slow
|
||||
// ffmpeg keeps resetting the clock; only a genuinely hung one trips it.
|
||||
// Without this, one hung fetch blocks every future window for the session.
|
||||
const FETCH_STALL_TIMEOUT_MS = 30_000;
|
||||
// Wait this long after a failed window fetch before retrying, so a
|
||||
// persistently failing extraction doesn't turn timeupdate into a fetch storm.
|
||||
const FETCH_RETRY_BACKOFF_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Cues (in source time) and window coverage snapshotted from a track that is
|
||||
* being torn down, so a rebuild against a reloaded <video> element (stream
|
||||
* restart) can restore them instead of refetching — window extraction costs
|
||||
* a multi-second ffmpeg run per request.
|
||||
*/
|
||||
interface SubtitleTrackCarryover {
|
||||
url: string | null;
|
||||
cues: ParsedCue[];
|
||||
seen: Set<string>;
|
||||
coverageStart: number;
|
||||
windowEnd: number;
|
||||
atEOF: boolean;
|
||||
hasFetched: boolean;
|
||||
}
|
||||
|
||||
/** Strip VTT formatting tags, keeping only the text content. */
|
||||
function stripVTTTags(text: string): string {
|
||||
@@ -74,20 +98,52 @@ function appendPosition(url: string, position: number): string {
|
||||
* `subtitleDelayMs` lets the user nudge sync without a refetch: new cues
|
||||
* are added with the current delay baked in, and existing cues are shifted
|
||||
* in place whenever the value changes. Positive = show subtitles later.
|
||||
* `streamOriginSeconds` changes (a copy-mode session restarting at a new
|
||||
* position) are handled the same way: existing cues shift in place onto
|
||||
* the new timeline instead of tearing down the track.
|
||||
*/
|
||||
export function useSubtitleTracks(
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>,
|
||||
subtitleUrls: PlayerSubtitleInfo[],
|
||||
activeSubtitleIndex: number | null,
|
||||
streamOriginRef: React.RefObject<number>,
|
||||
streamOriginSeconds: number,
|
||||
subtitleDelayMs: number,
|
||||
// Known media duration in source-time seconds (0 when unknown). Used to
|
||||
// decide when a fetch window has reached end-of-input.
|
||||
durationRef: React.RefObject<number>,
|
||||
// Media-time position playback is heading to (pending seek target, or the
|
||||
// session's start position). While the element has no media loaded yet
|
||||
// (session start or a stream restart), `video.currentTime` still reads 0
|
||||
// rather than the resume/seek target — fetching the window at 0 wastes an
|
||||
// ffmpeg run and, because only one fetch is in flight at a time, blocks
|
||||
// the correct window until it completes.
|
||||
fetchAnchorRef: React.RefObject<number>,
|
||||
liveCues?: ParsedCue[] | null,
|
||||
// Identifies the current live translation job. Changing it (a new job) rebuilds
|
||||
// the track and resets the dedup set so cues from a prior run never linger.
|
||||
liveTrackKey?: string | null,
|
||||
// Bumped whenever the underlying media stream is (re)started — a quality,
|
||||
// audio, or subtitle-burn-in switch tears down the <video> element and
|
||||
// reloads it. A programmatic TextTrack built moments before that reload is
|
||||
// orphaned by the browser (it survives quality switches only because it was
|
||||
// built against an already-stable stream). Changing this rebuilds the track
|
||||
// against the settled new stream, so selecting a text track in the same
|
||||
// action that restarts the transcode (turning off bitmap burn-in) still
|
||||
// renders. The initial stream does not bump it, leaving session start on the
|
||||
// existing activeUrl-driven build.
|
||||
streamGeneration = 0,
|
||||
): string[] {
|
||||
const [activeCueTexts, setActiveCueTexts] = useState<string[]>([]);
|
||||
|
||||
// Latest stream origin, readable from stable callbacks (maybeFetch) without
|
||||
// retriggering the main effect.
|
||||
const streamOriginRef = useRef(streamOriginSeconds);
|
||||
streamOriginRef.current = streamOriginSeconds;
|
||||
// Origin currently baked into the track's VTTCue times. Cue-add paths read
|
||||
// this (not the live origin) so the track never mixes bases; the rebase
|
||||
// effect below is the only place it advances, shifting existing cues along.
|
||||
const appliedOriginRef = useRef(streamOriginSeconds);
|
||||
|
||||
const activeSub =
|
||||
activeSubtitleIndex !== null
|
||||
? (subtitleUrls.find((s) => s.index === activeSubtitleIndex) ?? null)
|
||||
@@ -111,6 +167,10 @@ export function useSubtitleTracks(
|
||||
// live-cue effect add only the new tail each batch instead of rescanning the
|
||||
// whole (growing) array. Reset on every track rebuild.
|
||||
const processedLiveCuesRef = useRef(0);
|
||||
// Snapshot of the previous track's cues and coverage, written by the main
|
||||
// effect's cleanup and consumed by the next build when the subtitle URL is
|
||||
// unchanged (a rebuild forced by a stream reload rather than a track switch).
|
||||
const carryoverRef = useRef<SubtitleTrackCarryover | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@@ -120,8 +180,9 @@ export function useSubtitleTracks(
|
||||
setActiveCueTexts([]);
|
||||
|
||||
// Skip entirely for ASS/SSA (JASSUB renders those via useASSSubtitles)
|
||||
// and PGS (libpgs renders those via usePGSSubtitles).
|
||||
if (isASSCodec(activeCodec) || isPGSCodec(activeCodec)) {
|
||||
// and bitmap codecs (PGS/DVD/DVB are burned into the video server-side;
|
||||
// rendering text cues for them would double up on screen).
|
||||
if (isASSCodec(activeCodec) || isBitmapCodec(activeCodec)) {
|
||||
return;
|
||||
}
|
||||
// Need either a URL to stream from or a live cue source.
|
||||
@@ -138,15 +199,41 @@ export function useSubtitleTracks(
|
||||
trackRef.current = track;
|
||||
seenCueKeysRef.current = new Set();
|
||||
processedLiveCuesRef.current = 0;
|
||||
// Fresh track has no cues, so it trivially carries the current origin.
|
||||
appliedOriginRef.current = streamOriginRef.current;
|
||||
|
||||
// Restore cues and coverage carried over from the previous track when the
|
||||
// URL is unchanged — i.e. this rebuild replaces a track orphaned by a
|
||||
// stream reload, not a track switch. Cue times were snapshotted in source
|
||||
// time and are re-derived against the current origin/delay here, so an
|
||||
// origin change across the reload lands correctly. The carried dedup set
|
||||
// is installed as-is (its keys are source-time based and stay valid).
|
||||
const carried = carryoverRef.current;
|
||||
carryoverRef.current = null;
|
||||
const restored = carried && carried.url === activeUrl && !activeIsLive ? carried : null;
|
||||
if (restored) {
|
||||
const origin = appliedOriginRef.current;
|
||||
const delaySec = appliedDelayMsRef.current / 1000;
|
||||
for (const cue of restored.cues) {
|
||||
const startTime = Math.max(0, cue.start - origin + delaySec);
|
||||
const endTime = cue.end - origin + delaySec;
|
||||
if (endTime <= 0) continue;
|
||||
track.addCue(new VTTCue(startTime, endTime, cue.text));
|
||||
}
|
||||
seenCueKeysRef.current = restored.seen;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let hasFetched = false;
|
||||
let hasFetched = restored?.hasFetched ?? false;
|
||||
|
||||
// Sliding-window coverage state. See fetchWindow for semantics.
|
||||
let coverageStart = 0;
|
||||
let windowEnd = 0;
|
||||
let atEOF = false;
|
||||
let coverageStart = restored?.coverageStart ?? 0;
|
||||
let windowEnd = restored?.windowEnd ?? 0;
|
||||
let atEOF = restored?.atEOF ?? false;
|
||||
let inflight: AbortController | null = null;
|
||||
// Set on a failed (errored or stalled) window fetch; maybeFetch waits out
|
||||
// a short backoff before retrying the uncovered range.
|
||||
let lastFetchFailureAt = 0;
|
||||
|
||||
function handleCueChange() {
|
||||
const active = track.activeCues;
|
||||
@@ -175,9 +262,13 @@ export function useSubtitleTracks(
|
||||
// Cue timestamps come from ffmpeg in source-PTS. For copy-mode HLS
|
||||
// the player timeline is rebased to start at `streamOriginSeconds`,
|
||||
// so subtract it. For regular transcodes origin is 0 and the
|
||||
// subtraction is a no-op. Any active user-facing sync delay gets
|
||||
// baked in here so new cues line up with existing ones.
|
||||
const origin = streamOriginRef.current ?? 0;
|
||||
// subtraction is a no-op. Use the origin already baked into the
|
||||
// track (not the live one) so cues added while an origin change is
|
||||
// still propagating stay consistent with the existing cues — the
|
||||
// rebase effect shifts everything to the new origin in one pass.
|
||||
// Any active user-facing sync delay gets baked in here so new cues
|
||||
// line up with existing ones.
|
||||
const origin = appliedOriginRef.current;
|
||||
const delaySec = appliedDelayMsRef.current / 1000;
|
||||
addCuesToTrack(track, newCues, origin, delaySec, seenCueKeysRef.current);
|
||||
}
|
||||
@@ -195,14 +286,22 @@ export function useSubtitleTracks(
|
||||
if (resetExisting) {
|
||||
clearCues();
|
||||
coverageStart = seekStart;
|
||||
windowEnd = requestedEnd;
|
||||
windowEnd = seekStart;
|
||||
atEOF = false;
|
||||
} else {
|
||||
windowEnd = Math.max(windowEnd, requestedEnd);
|
||||
}
|
||||
|
||||
// Abort the fetch if the response stops delivering chunks. Re-armed on
|
||||
// every read so a slow-but-progressing extraction is never cut off.
|
||||
let stallTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const armStallTimer = () => {
|
||||
if (stallTimer !== null) clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => controller.abort(), FETCH_STALL_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const url = appendPosition(activeUrl, seekStart);
|
||||
let succeeded = false;
|
||||
try {
|
||||
armStallTimer();
|
||||
const resp = await fetch(url, { signal: controller.signal });
|
||||
if (!resp.ok || !resp.body) {
|
||||
console.error(`[useSubtitleTracks] Failed to fetch ${url}: ${resp.status}`);
|
||||
@@ -211,12 +310,12 @@ export function useSubtitleTracks(
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let lastCueEnd = 0;
|
||||
|
||||
// Split on the last complete cue boundary (blank line) and parse
|
||||
// the safe prefix, keep the rest. The WebVTT muxer emits cues
|
||||
// terminated by "\n\n".
|
||||
while (!cancelled) {
|
||||
armStallTimer();
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
|
||||
@@ -227,7 +326,6 @@ export function useSubtitleTracks(
|
||||
const cues = parseVTT(safe);
|
||||
if (cues.length > 0) {
|
||||
addParsedCues(cues);
|
||||
lastCueEnd = cues[cues.length - 1]!.end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,25 +335,40 @@ export function useSubtitleTracks(
|
||||
const cues = parseVTT(buf);
|
||||
if (cues.length > 0) {
|
||||
addParsedCues(cues);
|
||||
lastCueEnd = cues[cues.length - 1]!.end;
|
||||
}
|
||||
}
|
||||
|
||||
// If ffmpeg closed well short of the requested end, treat it as
|
||||
// end-of-input and stop prefetching.
|
||||
if (lastCueEnd > 0 && lastCueEnd < requestedEnd - WINDOW_OVERLAP) {
|
||||
atEOF = true;
|
||||
}
|
||||
succeeded = true;
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
console.error("[useSubtitleTracks] Stream error:", err);
|
||||
}
|
||||
} finally {
|
||||
if (stallTimer !== null) clearTimeout(stallTimer);
|
||||
const superseded = inflight !== controller;
|
||||
if (inflight === controller) {
|
||||
inflight = null;
|
||||
}
|
||||
if (!controller.signal.aborted) {
|
||||
if (succeeded && !cancelled) {
|
||||
hasFetched = true;
|
||||
// Commit coverage only after the whole window streamed in. A
|
||||
// failed or stalled fetch must leave the range uncovered, or the
|
||||
// gap would read as fetched and never be retried — subtitles
|
||||
// would silently stop for the rest of the window.
|
||||
windowEnd = Math.max(windowEnd, requestedEnd);
|
||||
// End-of-input only when the window reaches the known media
|
||||
// duration. Don't infer it from where the window's cues stop:
|
||||
// a window that ends inside a dialogue gap looks identical to
|
||||
// end-of-file by that signal, and a false positive here silently
|
||||
// stops subtitles for the rest of playback.
|
||||
const durationSec = durationRef.current ?? 0;
|
||||
if (durationSec > 0 && requestedEnd >= durationSec) {
|
||||
atEOF = true;
|
||||
}
|
||||
} else if (!succeeded && !superseded && !cancelled) {
|
||||
// Genuine failure (error, stall, or non-ok response) rather than a
|
||||
// seek superseding this fetch — back off before retrying.
|
||||
lastFetchFailureAt = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,11 +377,21 @@ export function useSubtitleTracks(
|
||||
// - no cues yet → initial fetch
|
||||
// - current position fell behind coverageStart (backward seek
|
||||
// outside window) → reset and fetch fresh window from here
|
||||
// - position jumped past windowEnd (forward seek outside window) →
|
||||
// reset and fetch fresh window from here, so the skipped-over gap
|
||||
// is never mistaken for covered range
|
||||
// - playback is nearing windowEnd and we haven't hit EOF → queue
|
||||
// the next window, overlapping slightly with the previous
|
||||
function maybeFetch() {
|
||||
if (cancelled || inflight) return;
|
||||
const mediaTime = toMediaTime(videoEl.currentTime, streamOriginRef.current ?? 0);
|
||||
if (Date.now() - lastFetchFailureAt < FETCH_RETRY_BACKOFF_MS) return;
|
||||
// Until the element has media loaded, currentTime reads 0 rather than
|
||||
// the position playback will actually start at (resume target, or a
|
||||
// seek that restarted the stream) — use the intended position instead.
|
||||
const mediaTime =
|
||||
videoEl.readyState > 0
|
||||
? toMediaTime(videoEl.currentTime, streamOriginRef.current ?? 0)
|
||||
: (fetchAnchorRef.current ?? 0);
|
||||
if (!hasFetched) {
|
||||
fetchWindow(Math.max(0, mediaTime - SEEK_BACKOFF), true);
|
||||
return;
|
||||
@@ -278,6 +401,11 @@ export function useSubtitleTracks(
|
||||
fetchWindow(Math.max(0, mediaTime - SEEK_BACKOFF), true);
|
||||
return;
|
||||
}
|
||||
if (mediaTime > windowEnd + 1) {
|
||||
atEOF = false;
|
||||
fetchWindow(Math.max(0, mediaTime - SEEK_BACKOFF), true);
|
||||
return;
|
||||
}
|
||||
if (!atEOF && mediaTime > windowEnd - PREFETCH_LEAD) {
|
||||
const nextStart = Math.max(windowEnd - WINDOW_OVERLAP, mediaTime);
|
||||
fetchWindow(nextStart, false);
|
||||
@@ -306,6 +434,29 @@ export function useSubtitleTracks(
|
||||
videoEl.removeEventListener("seeking", maybeFetch);
|
||||
videoEl.removeEventListener("seeked", maybeFetch);
|
||||
track.removeEventListener("cuechange", handleCueChange);
|
||||
// Snapshot loaded cues (converted back to source time) and coverage so
|
||||
// a rebuild against a reloaded <video> element can restore them without
|
||||
// refetching. Copy the dedup set: clearCues below empties the shared one.
|
||||
{
|
||||
const origin = appliedOriginRef.current;
|
||||
const delaySec = appliedDelayMsRef.current / 1000;
|
||||
carryoverRef.current = {
|
||||
url: activeUrl,
|
||||
cues: Array.from(track.cues ?? []).map((cue) => {
|
||||
const vc = cue as VTTCue;
|
||||
return {
|
||||
start: vc.startTime + origin - delaySec,
|
||||
end: vc.endTime + origin - delaySec,
|
||||
text: vc.text,
|
||||
};
|
||||
}),
|
||||
seen: new Set(seenCueKeysRef.current),
|
||||
coverageStart,
|
||||
windowEnd,
|
||||
atEOF,
|
||||
hasFetched,
|
||||
};
|
||||
}
|
||||
clearCues();
|
||||
// Tracks added via addTextTrack can't be removed from the element;
|
||||
// setting `disabled` makes it inert so a subsequent language change
|
||||
@@ -315,10 +466,32 @@ export function useSubtitleTracks(
|
||||
trackRef.current = null;
|
||||
}
|
||||
};
|
||||
// `subtitleDelayMs` is intentionally excluded — nudging delay must not
|
||||
// tear down and refetch the track. The delay-update effect below shifts
|
||||
// existing cues in place instead.
|
||||
}, [activeUrl, activeCodec, activeLang, activeIsLive, liveTrackKey, streamOriginRef, videoRef]);
|
||||
// `subtitleDelayMs` and `streamOriginSeconds` are intentionally excluded —
|
||||
// nudging delay or remapping the timeline must not tear down and refetch
|
||||
// the track. The update effects below shift existing cues in place instead.
|
||||
// `streamGeneration` IS included: a stream restart reloads the <video>
|
||||
// element and orphans the current track, so it must be rebuilt.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeUrl, activeCodec, activeLang, activeIsLive, liveTrackKey, streamGeneration, videoRef]);
|
||||
|
||||
// Re-base already-loaded cues when the media timeline remaps — e.g. a
|
||||
// copy-mode session restarting at a new position after an out-of-window
|
||||
// seek changes `streamOriginSeconds`. Cue times are player-local
|
||||
// (`source - origin + delay`), so a larger origin moves every cue earlier.
|
||||
// Without this shift, cues loaded under the old origin display offset by
|
||||
// exactly the origin delta after the restart.
|
||||
useEffect(() => {
|
||||
const deltaSec = streamOriginSeconds - appliedOriginRef.current;
|
||||
appliedOriginRef.current = streamOriginSeconds;
|
||||
if (deltaSec === 0) return;
|
||||
const cues = trackRef.current?.cues;
|
||||
if (!cues) return;
|
||||
for (const cue of Array.from(cues)) {
|
||||
const vc = cue as VTTCue;
|
||||
vc.startTime = Math.max(0, vc.startTime - deltaSec);
|
||||
vc.endTime = vc.endTime - deltaSec;
|
||||
}
|
||||
}, [streamOriginSeconds]);
|
||||
|
||||
// Apply delay changes to already-loaded cues without rebuilding the track.
|
||||
// Runs after the main effect, so trackRef is current.
|
||||
@@ -356,7 +529,7 @@ export function useSubtitleTracks(
|
||||
const fresh = liveCues.slice(processedLiveCuesRef.current);
|
||||
if (fresh.length === 0) return;
|
||||
processedLiveCuesRef.current = liveCues.length;
|
||||
const origin = streamOriginRef.current ?? 0;
|
||||
const origin = appliedOriginRef.current;
|
||||
const delaySec = appliedDelayMsRef.current / 1000;
|
||||
addCuesToTrack(track, fresh, origin, delaySec, seenCueKeysRef.current);
|
||||
// While paused, adding a cue over the playhead doesn't reliably fire
|
||||
@@ -370,7 +543,7 @@ export function useSubtitleTracks(
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}, [liveCues, activeIsLive, activeSubtitleIndex, liveTrackKey, streamOriginRef, videoRef]);
|
||||
}, [liveCues, activeIsLive, activeSubtitleIndex, liveTrackKey, videoRef]);
|
||||
|
||||
return activeCueTexts;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useTranscodeQuality } from "./useTranscodeQuality";
|
||||
import { PlayerConfigProvider } from "../context/PlayerConfigContext";
|
||||
import type { PlayerConfig } from "../context/PlayerConfigContext";
|
||||
import type { PlayerFileVersion, TranscodeStartRequest } from "../types";
|
||||
|
||||
const config: PlayerConfig = {
|
||||
apiBaseUrl: "/api/v1",
|
||||
getAccessToken: () => null,
|
||||
getProfileId: () => null,
|
||||
};
|
||||
|
||||
const version: PlayerFileVersion = {
|
||||
file_id: 42,
|
||||
resolution: "1080p",
|
||||
codec_video: "h264",
|
||||
codec_audio: "aac",
|
||||
hdr: false,
|
||||
container: "mkv",
|
||||
file_size: 1_000_000,
|
||||
duration: 7200,
|
||||
bitrate: 8000,
|
||||
};
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <PlayerConfigProvider config={config}>{children}</PlayerConfigProvider>;
|
||||
}
|
||||
|
||||
function transcodeStartResponse() {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
session_id: "sess-1",
|
||||
status: "started",
|
||||
manifest_url: "/playback/transcode/sess-1/master.m3u8",
|
||||
duration_seconds: 7200,
|
||||
player_start_seconds: 0,
|
||||
timeline_offset_seconds: 0,
|
||||
can_seek_anywhere: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function sentBodies(): TranscodeStartRequest[] {
|
||||
return fetchMock.mock.calls.map(([, init]) => JSON.parse((init as RequestInit).body as string));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(() => Promise.resolve(transcodeStartResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderQuality() {
|
||||
return renderHook(
|
||||
() =>
|
||||
useTranscodeQuality({
|
||||
sessionId: "sess-1",
|
||||
selectedVersion: version,
|
||||
versions: [version],
|
||||
playMethod: "remux",
|
||||
initialPosition: 0,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
}
|
||||
|
||||
describe("useTranscodeQuality", () => {
|
||||
it("coalesces same-tick restarts into a single start with the final params", async () => {
|
||||
const { result } = renderQuality();
|
||||
|
||||
// Mount auto-start has fired but its dispatch is macrotask-deferred. A
|
||||
// persisted bitmap subtitle selection lands in the same tick (subtitle
|
||||
// auto-selection on session start) — the two must collapse into ONE
|
||||
// server call carrying the burn-in, instead of spawning an ffmpeg that
|
||||
// is killed milliseconds later by the second start.
|
||||
act(() => {
|
||||
result.current.setSubtitleBurnIn(3, 0, 42);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
// Give a (wrongly) surviving first dispatch a chance to fire.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = sentBodies()[0]!;
|
||||
expect(body.subtitle_burn_in).toBe(true);
|
||||
expect(body.subtitle_track_index).toBe(3);
|
||||
expect(body.subtitle_media_file_id).toBe(42);
|
||||
// Burn-in composites into the frames, so codec copy must be off.
|
||||
expect(body.target_codec_video).toBe("h264");
|
||||
});
|
||||
|
||||
it("preserves a pending quality when burn-in is selected in the same tick", async () => {
|
||||
const { result } = renderQuality();
|
||||
|
||||
act(() => {
|
||||
result.current.switchQuality("720p", 30);
|
||||
result.current.setSubtitleBurnIn(3, 30, 42);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const body = sentBodies()[0]!;
|
||||
expect(body.target_resolution).toBe("720p");
|
||||
expect(body.subtitle_burn_in).toBe(true);
|
||||
expect(body.subtitle_track_index).toBe(3);
|
||||
expect(body.subtitle_media_file_id).toBe(42);
|
||||
});
|
||||
|
||||
it("still dispatches later restarts separately", async () => {
|
||||
const { result } = renderQuality();
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(sentBodies()[0]!.subtitle_burn_in).toBe(false);
|
||||
expect(sentBodies()[0]!.subtitle_media_file_id).toBeUndefined();
|
||||
|
||||
// A user toggle in a later tick is a genuine restart, not coalesced away.
|
||||
act(() => {
|
||||
result.current.setSubtitleBurnIn(3, 120, 42);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
const second = sentBodies()[1]!;
|
||||
expect(second.subtitle_burn_in).toBe(true);
|
||||
expect(second.subtitle_track_index).toBe(3);
|
||||
expect(second.subtitle_media_file_id).toBe(42);
|
||||
expect(second.seek_seconds).toBe(120);
|
||||
});
|
||||
|
||||
it("drops a deferred dispatch when the hook unmounts first", async () => {
|
||||
const { unmount } = renderQuality();
|
||||
|
||||
// Auto-start is deferred; unmounting (exit → session DELETE) must cancel
|
||||
// it so no stray transcode/start resurrects the dead session.
|
||||
unmount();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back a failed burn-in selection so the same track can be retried", async () => {
|
||||
const { result } = renderQuality();
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
fetchMock.mockRejectedValueOnce(new Error("transcode failed"));
|
||||
|
||||
act(() => {
|
||||
result.current.setSubtitleBurnIn(3, 120, 42);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.error).toMatch(/^Couldn't switch to Original/));
|
||||
expect(result.current.burnInSubtitleIndex).toBeNull();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(transcodeStartResponse());
|
||||
act(() => {
|
||||
result.current.setSubtitleBurnIn(3, 120, 42);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
|
||||
expect(sentBodies()[2]!.subtitle_track_index).toBe(3);
|
||||
expect(sentBodies()[2]!.subtitle_burn_in).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,19 @@ interface UseTranscodeQualityResult {
|
||||
qualityOptions: QualityOption[];
|
||||
activeQualityId: string;
|
||||
switchQuality: (qualityId: string, currentPosition: number, forceRestart?: boolean) => void;
|
||||
/**
|
||||
* Burn the given embedded bitmap subtitle track (ffmpeg subtitle-stream
|
||||
* ordinal) into the video, or stop burning when null. Restarts the transcode
|
||||
* at the current position via the same machinery as a quality/audio switch,
|
||||
* so the server's segment-boundary timeline alignment applies unchanged.
|
||||
*/
|
||||
setSubtitleBurnIn: (
|
||||
ffmpegSubtitleIndex: number | null,
|
||||
currentPosition: number,
|
||||
subtitleMediaFileId?: number,
|
||||
) => void;
|
||||
/** Currently burned-in embedded subtitle ordinal, or null. */
|
||||
burnInSubtitleIndex: number | null;
|
||||
transcodeStreamUrl: string | null;
|
||||
playerStartSeconds: number;
|
||||
streamOriginSeconds: number;
|
||||
@@ -177,8 +190,26 @@ export function useTranscodeQuality({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [switchedFileId, setSwitchedFileId] = useState<number | null>(null);
|
||||
const switchAbortRef = useRef<AbortController | null>(null);
|
||||
// Pending (macrotask-deferred) transcode start dispatch. Restart triggers can
|
||||
// stack up in one tick — e.g. on mount the auto-start effect fires, then
|
||||
// subtitle auto-selection restores a burned-in bitmap track and fires again —
|
||||
// and each server call kills and respawns ffmpeg. Deferring the network
|
||||
// dispatch by one macrotask lets the last call in a tick win, so the server
|
||||
// sees a single start with the final parameters.
|
||||
const dispatchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const manifestVersionRef = useRef(0);
|
||||
const autoStartKeyRef = useRef<string | null>(null);
|
||||
// Latest requested quality, updated synchronously before React commits the
|
||||
// corresponding state. Bitmap subtitle restoration can request another
|
||||
// restart in the same tick and must preserve this pending quality.
|
||||
const requestedQualityIdRef = useRef("original");
|
||||
// Embedded subtitle ordinal being burned into the video, or null. Kept in a
|
||||
// ref (mirrored to state for consumers) so every restart path — quality
|
||||
// switch, out-of-window seek, compatibility fallback — reads the current
|
||||
// value without stale-closure races.
|
||||
const [burnInSubtitleIndex, setBurnInSubtitleIndex] = useState<number | null>(null);
|
||||
const burnInRef = useRef<number | null>(null);
|
||||
const burnInMediaFileIdRef = useRef<number | null>(null);
|
||||
|
||||
const effectiveVersion = useMemo(() => {
|
||||
if (switchedFileId != null) {
|
||||
@@ -196,7 +227,12 @@ export function useTranscodeQuality({
|
||||
useEffect(() => {
|
||||
switchAbortRef.current?.abort();
|
||||
switchAbortRef.current = null;
|
||||
if (dispatchTimerRef.current != null) {
|
||||
clearTimeout(dispatchTimerRef.current);
|
||||
dispatchTimerRef.current = null;
|
||||
}
|
||||
autoStartKeyRef.current = null;
|
||||
requestedQualityIdRef.current = "original";
|
||||
setActiveQualityId("original");
|
||||
setTranscodeStreamUrl(null);
|
||||
setPlayerStartSeconds(0);
|
||||
@@ -206,16 +242,37 @@ export function useTranscodeQuality({
|
||||
setIsTranscoding(false);
|
||||
setError(null);
|
||||
setSwitchedFileId(null);
|
||||
burnInRef.current = null;
|
||||
burnInMediaFileIdRef.current = null;
|
||||
setBurnInSubtitleIndex(null);
|
||||
}, [sessionId, selectedVersion?.file_id, playMethod]);
|
||||
|
||||
// On unmount, cancel any in-flight or still-deferred start so a stray
|
||||
// transcode/start can't land after the session's exit DELETE.
|
||||
useEffect(
|
||||
() => () => {
|
||||
switchAbortRef.current?.abort();
|
||||
if (dispatchTimerRef.current != null) {
|
||||
clearTimeout(dispatchTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const startTranscode = useCallback(
|
||||
(qualityId: string, currentPosition: number, forceRestart = false) => {
|
||||
if (!sessionId) return;
|
||||
requestedQualityIdRef.current = qualityId;
|
||||
if (!forceRestart && qualityId === activeQualityId) return;
|
||||
|
||||
// Abort any in-progress switch (POST + polling).
|
||||
// Abort any in-progress switch (POST + polling) and supersede any
|
||||
// dispatch still waiting on its macrotask.
|
||||
switchAbortRef.current?.abort();
|
||||
switchAbortRef.current = null;
|
||||
if (dispatchTimerRef.current != null) {
|
||||
clearTimeout(dispatchTimerRef.current);
|
||||
dispatchTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Clear the guard's file switch when reverting to original quality.
|
||||
// The backend will set a new switched_file_id in the response if needed.
|
||||
@@ -230,7 +287,14 @@ export function useTranscodeQuality({
|
||||
// For remux and transcode, "original" still needs HLS. Remux can keep
|
||||
// video copy; transcode must still encode video because the source codec
|
||||
// was already classified as browser-incompatible.
|
||||
if (qualityId === "original" && playMethod !== "transcode" && playMethod !== "remux") {
|
||||
// A burned-in bitmap subtitle always needs an encoding HLS transcode,
|
||||
// even on a direct-play base — never fall back to the raw file then.
|
||||
if (
|
||||
qualityId === "original" &&
|
||||
playMethod !== "transcode" &&
|
||||
playMethod !== "remux" &&
|
||||
burnInRef.current == null
|
||||
) {
|
||||
setActiveQualityId("original");
|
||||
setTranscodeStreamUrl(null);
|
||||
setPlayerStartSeconds(0);
|
||||
@@ -280,7 +344,20 @@ export function useTranscodeQuality({
|
||||
// from the deleted output directory and throws bufferAppendErrors.
|
||||
setTranscodeStreamUrl(null);
|
||||
|
||||
(async () => {
|
||||
const dispatch = async () => {
|
||||
const requestedBurnInIndex = burnInRef.current;
|
||||
const requestedBurnInMediaFileId = burnInMediaFileIdRef.current;
|
||||
const rollbackFailedBurnIn = () => {
|
||||
if (
|
||||
requestedBurnInIndex != null &&
|
||||
burnInRef.current === requestedBurnInIndex &&
|
||||
burnInMediaFileIdRef.current === requestedBurnInMediaFileId
|
||||
) {
|
||||
burnInRef.current = null;
|
||||
burnInMediaFileIdRef.current = null;
|
||||
setBurnInSubtitleIndex(null);
|
||||
}
|
||||
};
|
||||
try {
|
||||
// When "Original" is selected on a remux base, use codec copy (no
|
||||
// video re-encoding). Transcode bases still encode video because the
|
||||
@@ -289,8 +366,15 @@ export function useTranscodeQuality({
|
||||
// browser can't decode (EAC3, DTS, TrueHD, etc.) and audio transcoding
|
||||
// adds negligible overhead compared to video.
|
||||
const isCompatibilityFallback = option.id === COMPATIBILITY_QUALITY_ID;
|
||||
// Burn-in composites the subtitle into the video frames, which
|
||||
// requires an encode — codec copy is never allowed while a bitmap
|
||||
// subtitle is burned in (the server enforces this too).
|
||||
const burnInIndex = requestedBurnInIndex;
|
||||
const isCopyOriginal =
|
||||
option.isOriginal && !isCompatibilityFallback && playMethod === "remux";
|
||||
option.isOriginal &&
|
||||
!isCompatibilityFallback &&
|
||||
playMethod === "remux" &&
|
||||
burnInIndex == null;
|
||||
const body: TranscodeStartRequest = {
|
||||
session_id: sessionId,
|
||||
seek_seconds: currentPosition,
|
||||
@@ -302,14 +386,16 @@ export function useTranscodeQuality({
|
||||
// especially for remux/copy sessions where a long startup
|
||||
// window can delay first frame for several seconds.
|
||||
segment_duration: 2,
|
||||
subtitle_track_index: -1,
|
||||
subtitle_burn_in: false,
|
||||
subtitle_track_index: burnInIndex ?? -1,
|
||||
subtitle_media_file_id:
|
||||
burnInIndex != null ? (requestedBurnInMediaFileId ?? undefined) : undefined,
|
||||
subtitle_burn_in: burnInIndex != null,
|
||||
};
|
||||
|
||||
const resp = await playerFetch<TranscodeStartResponse>(
|
||||
config,
|
||||
"/playback/transcode/start",
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
{ method: "POST", body: JSON.stringify(body), signal: abortController.signal },
|
||||
);
|
||||
|
||||
if (abortController.signal.aborted) return;
|
||||
@@ -344,6 +430,7 @@ export function useTranscodeQuality({
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
if (abortController.signal.aborted) return;
|
||||
rollbackFailedBurnIn();
|
||||
// 422 = no alternate file version available for 4K transcode protection.
|
||||
if (err instanceof PlayerFetchError && err.status === 422) {
|
||||
setActiveQualityId("original");
|
||||
@@ -371,18 +458,24 @@ export function useTranscodeQuality({
|
||||
setIsTranscoding(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
};
|
||||
dispatchTimerRef.current = setTimeout(() => {
|
||||
dispatchTimerRef.current = null;
|
||||
void dispatch();
|
||||
}, 0);
|
||||
},
|
||||
[sessionId, activeQualityId, qualityOptions, config, effectiveVersion, playMethod],
|
||||
);
|
||||
|
||||
const switchQuality = useCallback(
|
||||
(qualityId: string, currentPosition: number, forceRestart?: boolean) => {
|
||||
requestedQualityIdRef.current = qualityId;
|
||||
// When the user explicitly selects "Original" from the quality menu:
|
||||
// - Direct play base: stop HLS and play the raw file (instant).
|
||||
// - Direct play base: stop HLS and play the raw file (instant), unless a
|
||||
// bitmap subtitle is burned in (burn-in always needs an encode).
|
||||
// - Remux base: use codec copy via HLS.
|
||||
// - Transcode base: restart HLS at source resolution while encoding video.
|
||||
if (qualityId === "original" && playMethod === "direct") {
|
||||
if (qualityId === "original" && playMethod === "direct" && burnInRef.current == null) {
|
||||
switchAbortRef.current?.abort();
|
||||
switchAbortRef.current = null;
|
||||
setActiveQualityId("original");
|
||||
@@ -401,6 +494,29 @@ export function useTranscodeQuality({
|
||||
[startTranscode, playMethod],
|
||||
);
|
||||
|
||||
// Selecting a bitmap (PGS/DVD/DVB) subtitle track burns it into the video
|
||||
// server-side: the transcode restarts with subtitle_burn_in at the current
|
||||
// position, riding the exact restart machinery a quality switch uses so the
|
||||
// segment-boundary timeline alignment fix is preserved. Deselecting (null)
|
||||
// restarts without burn-in — or drops back to the raw file on a direct-play
|
||||
// "original" session.
|
||||
const setSubtitleBurnIn = useCallback(
|
||||
(ffmpegSubtitleIndex: number | null, currentPosition: number, subtitleMediaFileId?: number) => {
|
||||
const normalizedMediaFileId = subtitleMediaFileId ?? null;
|
||||
if (
|
||||
burnInRef.current === ffmpegSubtitleIndex &&
|
||||
burnInMediaFileIdRef.current === normalizedMediaFileId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
burnInRef.current = ffmpegSubtitleIndex;
|
||||
burnInMediaFileIdRef.current = ffmpegSubtitleIndex == null ? null : normalizedMediaFileId;
|
||||
setBurnInSubtitleIndex(ffmpegSubtitleIndex);
|
||||
switchQuality(requestedQualityIdRef.current, currentPosition, true);
|
||||
},
|
||||
[switchQuality],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || (playMethod !== "transcode" && playMethod !== "remux")) {
|
||||
return;
|
||||
@@ -453,6 +569,8 @@ export function useTranscodeQuality({
|
||||
qualityOptions,
|
||||
activeQualityId,
|
||||
switchQuality,
|
||||
setSubtitleBurnIn,
|
||||
burnInSubtitleIndex,
|
||||
transcodeStreamUrl,
|
||||
playerStartSeconds,
|
||||
streamOriginSeconds,
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface PlaybackSessionPlaybackInfo {
|
||||
/** Subtitle track information. */
|
||||
export interface PlayerSubtitleInfo {
|
||||
index: number;
|
||||
/** Media file whose subtitle inventory assigned this track index. */
|
||||
media_file_id?: number;
|
||||
/**
|
||||
* Downloaded-subtitle row id, when this track is a stored downloaded subtitle.
|
||||
* Lets the player match a translation-completed / `subtitle_ready` event
|
||||
@@ -155,6 +157,8 @@ export interface PrePlaySubtitleSelection {
|
||||
hearing_impaired?: boolean;
|
||||
external_subtitle_path?: string;
|
||||
downloaded_subtitle_id?: number;
|
||||
/** Backend track index, carried so the selection can persist as a preference. */
|
||||
track_index?: number;
|
||||
}
|
||||
|
||||
/** A time range (intro start/end or credits start/end). */
|
||||
@@ -323,6 +327,7 @@ export interface TranscodeStartRequest {
|
||||
target_bitrate_kbps: number;
|
||||
segment_duration: number;
|
||||
subtitle_track_index: number;
|
||||
subtitle_media_file_id?: number;
|
||||
subtitle_burn_in: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isASSCodec, isBitmapCodec, isPGSCodec } from "./subtitleCodecs";
|
||||
import { isASSCodec, isBitmapCodec, isPGSCodec, isSubtitleFormatLabel } from "./subtitleCodecs";
|
||||
|
||||
describe("isPGSCodec", () => {
|
||||
it("matches PGS codec names case-insensitively", () => {
|
||||
@@ -39,3 +39,16 @@ describe("isASSCodec", () => {
|
||||
expect(isASSCodec(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSubtitleFormatLabel", () => {
|
||||
it("recognizes raw codec names and friendly aliases", () => {
|
||||
expect(isSubtitleFormatLabel("SUBRIP", "subrip")).toBe(true);
|
||||
expect(isSubtitleFormatLabel("SRT", "subrip")).toBe(true);
|
||||
expect(isSubtitleFormatLabel("PGS", "hdmv_pgs_subtitle")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves meaningful track titles", () => {
|
||||
expect(isSubtitleFormatLabel("Forced", "subrip")).toBe(false);
|
||||
expect(isSubtitleFormatLabel("Latin American", "subrip")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@ export function isASSCodec(codec: string | undefined): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Codecs that indicate PGS (Blu-ray bitmap) subtitles. Rendered client-side
|
||||
* by libpgs from a .sup stream served by the backend.
|
||||
* Codecs that indicate PGS (Blu-ray bitmap) subtitles. Like all bitmap
|
||||
* codecs, PGS is burned into the video server-side when selected.
|
||||
*/
|
||||
const PGS_CODECS = new Set(["pgs", "hdmv_pgs_subtitle"]);
|
||||
|
||||
@@ -55,3 +55,26 @@ export function getSubtitleFormatLabel(codec: string | undefined): string | null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFormatName(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
/** Returns true when a track title merely repeats its codec or display format. */
|
||||
export function isSubtitleFormatLabel(
|
||||
label: string | undefined,
|
||||
codec: string | undefined,
|
||||
): boolean {
|
||||
if (!label || !codec) return false;
|
||||
const normalizedLabel = normalizeFormatName(label);
|
||||
if (!normalizedLabel) return false;
|
||||
|
||||
const formatLabel = getSubtitleFormatLabel(codec);
|
||||
return (
|
||||
normalizedLabel === normalizeFormatName(codec) ||
|
||||
(formatLabel !== null && normalizedLabel === normalizeFormatName(formatLabel))
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user