fix(plex): subtitles stop blocking the request, and stop re-reading the file

Bug hunt 2026-09, items 8 and 12 — the two things the Plex player was measurably
lying about.

Item 8: picking a subtitle track showed nothing for a long time, and the reported
workaround was to stop/start until it suddenly worked. Measured on prod against a
17.06 GB MKV with two embedded SubRip tracks:

  full pass, one track                154 s
  full pass, both tracks in ONE run   111 s
  -c:s copy (no WebVTT conversion)    143 s
  from 1:00:00 to the end of file      66 s
  a 630 s window at 1:00:00, both       8 s

So the conversion was never the cost: ffmpeg streams every byte of the container
between its seek point and its stop point, and the array delivers ~140 MB/s. The
only lever is reading less. Three changes follow from that:

  * a window around the playhead is extracted and published FIRST (seconds), and
    the full pass completes behind it;
  * every embedded text track rides that same single read — picking Hungarian used
    to cost a full pass and then switching to English cost another one;
  * none of it happens inside a request any more. The endpoint answers 202 while a
    background worker extracts, the player polls /status (carrying its position, so
    the window follows the viewer), and one ffmpeg run finally has a timeout.

-copyts is deliberately absent from a windowed pass: with it ffmpeg ignores -t and
reads the whole container anyway (measured — a [0, 600 s] window still took the
full 145 s), so a window comes back on a zero-based clock and is shifted to
absolute time here. Cached tracks written by older builds have no coverage sidecar
and count as complete, so nothing is re-extracted after the upgrade.

The <track> is no longer mounted before the server has something to put in it; a
"Preparing subtitles: …" notice stands in, and a track that can never be shown says
why instead of leaving the viewer to conclude the picker does nothing.

Item 12: "Bitrate" read a constant 211 kbps on every remux, because it was the
master playlist's BANDWIDTH — which ffmpeg derives from the one stream whose rate
it knows, the -b:a 192k AAC track, plus 10%. It is a measurement now: bytes per
second of MEDIA from hls.js FRAG_LOADED, accumulated per fragment type and summed
(a multi-audio session's video and audio renditions cover the same media seconds,
so pooling them would report half the truth), and an honest dash until the first
fragment lands. The panel also finally names the audio and subtitle track in play.

Verified in the browser against the real Plex library, and every new test
mutation-checked.
This commit is contained in:
2026-09-10 19:31:05 +02:00
parent b8bff504d8
commit ed4ab4e13c
13 changed files with 1158 additions and 66 deletions
+67 -1
View File
@@ -7,7 +7,7 @@ import {
type RefObject,
type WheelEvent as ReactWheelEvent,
} from "react";
import type { QueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
exitFullscreen,
@@ -344,6 +344,18 @@ export function useAutoSkip(opts: {
// stream) or client-side via hls.js (multi-rendition), while subtitle switches just flip the single
// mounted <track>. The shared media refs (video/hls/audio/aoff/abs/multiAudio) are owned by the
// component and passed in so this hook and the media session read/write the same substrate.
/** Why a subtitle track can't be shown, in the three buckets the player has a sentence for. The
* server's codes are finer-grained than that on purpose (they drive the HTTP status too), so the
* fold happens here rather than multiplying near-identical translations. */
export type SubFailReason = "bitmap" | "missing" | "failed";
function subFailReason(code: string | null | undefined): SubFailReason {
if (code === "bitmap") return "bitmap";
if (code === "no_item" || code === "no_track" || code === "no_file" || code === "not_fetchable")
return "missing";
return "failed";
}
export function usePlexTracks(opts: {
id: string;
detail: PlexItemDetail | undefined;
@@ -374,9 +386,19 @@ export function usePlexTracks(opts: {
textSubs: PlexItemDetail["subtitle_streams"];
selectedSub: PlexItemDetail["subtitle_streams"][number] | null;
subTrackOffset: number;
/** The selected track has something to render (a window around the playhead, or the whole thing).
* False means "not yet" — the caller must NOT mount a <track>, it would just sit blank. */
subShowable: boolean;
/** The selected track can never be shown, bucketed for the message the player puts on screen. */
subFail: SubFailReason | null;
/** The cached VTT's generation — it grows as the background extraction widens the track, so it
* belongs in the <track> key/URL or the browser keeps serving the first, short window. */
subGen: number;
onSubTrackError: () => void;
} {
const { id, detail, sessionK, subOffset, subPos, loadSession, patchPrefs } = opts;
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef } = opts;
const qc = useQueryClient();
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
const [audioOrd, setAudioOrd] = useState<number | null>(null);
const [subOrd, setSubOrd] = useState<number | null>(null);
@@ -455,6 +477,46 @@ export function usePlexTracks(opts: {
[patchPrefs, loadSession, aoffRef, absRef],
);
// --- is the selected track actually extractable yet? -------------------------------------------
// Pulling one subtitle track out of a big MKV means ffmpeg streams the whole container: measured on
// prod at 154 s for a 17 GB film (and the same again for the second track). That used to happen
// INSIDE the <track>'s own request, so the browser sat on an empty track with no spinner, no retry
// and no error — the "switch the track, nothing happens, stop/start a few times and eventually it
// works" report (bug hunt 2026-09, item 8). The server now extracts in the background, a window
// around the playhead first, and this poll is what tells us when there is something to mount.
const subStatusQ = useQuery({
queryKey: qk.plexSubtitle(id, subOrd ?? -1),
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, absRef.current ?? 0),
enabled: subOrd != null,
// Keep polling until the track is COMPLETE, not just showable: each poll carries the current
// position, so seeking past the extracted window is what asks for the next one. A settled
// answer — the whole track, or a reason it can never be shown — stops the poll; without that
// stop, a bitmap track would poll for the length of the film AND keep re-arming the extraction
// every time the server's short failure memo expired.
refetchInterval: (q) => {
const st = q.state.data?.state;
if (st === "ready" || st === "failed") return false;
return st === "partial" ? 2000 : 1000;
},
staleTime: 0,
retry: false,
});
const subStatus = subOrd == null ? null : (subStatusQ.data ?? null);
// Mount the <track> for a `partial` track too: a window around the playhead is exactly what the
// viewer needs right now, and the poll widens it underneath them.
const subShowable = subStatus?.state === "ready" || subStatus?.state === "partial";
const subFail: SubFailReason | null =
subStatusQ.isError && subOrd != null
? "failed"
: subStatus?.state === "failed"
? subFailReason(subStatus.code)
: null;
// A <track> that errors anyway (the cached VTT was evicted between the poll and the fetch) goes
// back to polling rather than staying silently blank.
const onSubTrackError = useCallback(() => {
if (subOrd != null) qc.invalidateQueries({ queryKey: qk.plexSubtitle(id, subOrd) });
}, [qc, id, subOrd]);
// Text subtitles offered in the picker; image subs (PGS/VobSub) can't be shown as text.
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
// Render ONLY the selected sub as a single <track> (below) — rendering all of them and flipping
@@ -521,5 +583,9 @@ export function usePlexTracks(opts: {
textSubs,
selectedSub,
subTrackOffset,
subShowable,
subFail,
subGen: subStatus?.gen ?? 0,
onSubTrackError,
};
}