fix(plex): subtitles — a retried full pass resumes, a stalled track is polled slowly

A full pass that ran out of time kept what it reached, but its retry an hour
later started again at 0, so a file too big to read inside the extraction
timeout never completed. The pass now starts where every embedded track's
coverage from the head ends (less the window pre-roll, so a cue across the
join is read whole). The seek needs -avoid_negative_ts on this read as on a
window, measured on a real file: without it the cue running at the start
shifts the whole output.

A partial track whose full pass had failed never reached a settled state, so
the player polled it every 2 s for the rest of the film. The status now says
whether the track is still growing, and a stalled one is polled every 15 s —
still inside the 60 s lookahead that reads the next window before an edge.
This commit is contained in:
2026-09-19 07:07:53 +02:00
parent e6b76e156a
commit c60359bfed
5 changed files with 126 additions and 5 deletions
+34 -4
View File
@@ -642,7 +642,15 @@ def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict:
probe = edge + 1.0
want_window = not _window_failed(key, probe)
_schedule(rating_key, sub_ord, probe, want_window=want_window, want_full=not full_failed)
return {"state": "partial" if covered else "preparing", "code": None, "gen": gen}
# `growing`: whether the track will fill in on its own. False once the full pass has failed and
# is backed off — a partial track then stays partial until a window is asked for, so the player
# may poll slowly (only to read ahead of an edge) instead of every two seconds for the whole film.
return {
"state": "partial" if covered else "preparing",
"code": None,
"gen": gen,
"growing": not full_failed,
}
def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]:
@@ -822,10 +830,13 @@ def _extract_and_publish(rating_key: str, sub_ord: int, window_start: float | No
for i, s in enumerate(subs)
if s.get("index") is not None and not _is_bitmap(s)
}
start = window_start or 0.0
# A full pass starts where every track's leading coverage ends: a pass that ran out of time
# (`EXTRACT_TIMEOUT_S`) kept what it reached, and restarting from 0 each `FULL_RETRY_S` meant a file
# too big to read in one go never completed. Windows handle an offset start already; so does this.
start = window_start if window_start is not None else _full_pass_start(rating_key, targets)
length = (WINDOW_LEN_S + WINDOW_PRE_S) if window_start is not None else None
end = _FULL_END_S if length is None else start + length
texts, clean = _ffmpeg_extract(str(src), sorted(targets), window_start, length)
texts, clean = _ffmpeg_extract(str(src), sorted(targets), start or None, length)
for emb_ord, vtt in texts.items():
key = cache_key(rating_key, targets[emb_ord])
# A pass that exited non-zero holds only what ffmpeg reached before it died. Claim no more
@@ -846,6 +857,24 @@ def _extract_and_publish(rating_key: str, sub_ord: int, window_start: float | No
_pass_failed(rating_key, [key], window_start, (covered_end, end))
def _full_pass_start(rating_key: str, targets: dict[int, int]) -> float:
"""Where the full pass may begin: the end of the span from 0 that EVERY target track already has
(one read feeds them all, so the least-covered track decides), backed off by `WINDOW_PRE_S` so a
cue running across the join is read whole — `merge_vtt` folds the overlap. 0 when any track has
nothing from the start."""
ends = []
for ord_ in targets.values():
key = cache_key(rating_key, ord_)
ranges, _ = read_meta(key) if _exists(_vtt_path(key)) else ([], False)
lead = [b for a, b in ranges if a <= 0.0]
if not lead:
return 0.0
ends.append(max(lead))
if not ends:
return 0.0
return max(0.0, float(f"{min(ends) - WINDOW_PRE_S:.3f}"))
def _sidecar_vtt(db: Session, stream: dict) -> str:
"""An external subtitle file, fetched through Plex and converted to WebVTT."""
with PlexClient(db) as plex:
@@ -884,9 +913,10 @@ def _ffmpeg_extract(
cmd += ["-map", f"0:s:{emb_ord}"]
if length is not None:
cmd += ["-t", f"{length:.3f}"]
if start:
# The input seek lands on the keyframe at or BEFORE `start`, so a cue already running
# at `start` comes out at a negative time — and by default the muxer then shifts the
# WHOLE output so that cue sits at 0: every cue of the window late by the distance to
# WHOLE output so that cue sits at 0: every cue of the read late by the distance to
# that keyframe (1–5 s measured on a real library file, a whole GOP on a synthetic
# one), and doubled for good once the full pass merged in beside it. Kept negative, the
# times are exact on the zero-based clock (`_vtt_ts_to_s` reads the sign).
@@ -412,6 +412,54 @@ def test_a_full_pass_that_died_is_not_cached_as_the_whole_track(monkeypatch):
assert (after["state"], after["code"]) == ("unavailable", "extract_failed")
def test_a_retried_full_pass_resumes_where_every_track_already_reaches(monkeypatch):
"""A pass that ran out of time keeps what it reached, but the retry an hour later started again
at 0 — so a file too big to read inside `EXTRACT_TIMEOUT_S` never completed. It now starts at
the end of the span every track has from the start (less `WINDOW_PRE_S`, so a cue across the
join is read whole), and finishing from there completes the track."""
calls = _stub_item(monkeypatch, clean=False, cues=("39:58.000", "40:00.000"))
_manual_pool(monkeypatch)
subtitles._extract_and_publish("98614", 3, None)
assert calls[-1] == ([0, 2], None, None), "the first pass reads from the head"
calls = _stub_item(monkeypatch, clean=True, cues=("00:05.000", "00:07.000"))
subtitles._extract_and_publish("98614", 3, None)
assert calls == [([0, 2], 2370.0, None)]
for sub_ord in (1, 3):
assert subtitles.read_meta(subtitles.cache_key("98614", sub_ord))[1] is True
assert "00:39:35.000 --> 00:39:37.000" in (
subtitles.cache_read(subtitles.cache_key("98614", 3)) or ""
), "the resumed read's cues sit at absolute time (2370 + 5 s)"
def test_a_full_pass_starts_at_the_head_while_any_track_lacks_it(monkeypatch):
"""One read feeds every embedded track, so the least-covered one decides: a window elsewhere in
the film, or one track with the head and another without, still means reading from 0."""
calls = _stub_item(monkeypatch)
_manual_pool(monkeypatch)
subtitles._extract_and_publish("98614", 3, 3570.0) # a window mid-film, both tracks
subtitles.publish(subtitles.cache_key("98614", 1), _vtt(), (0.0, 600.0), False)
subtitles._extract_and_publish("98614", 3, None)
assert calls[-1] == ([0, 2], None, None)
def test_the_status_says_whether_the_track_is_still_growing(monkeypatch):
"""`partial` alone kept the player polling every 2 s for the rest of the film once the full
pass had failed and backed off — a state that never reaches `ready` on its own."""
_stub_item(monkeypatch, clean=True, cues=("00:05.000", "00:07.000"))
pool = _manual_pool(monkeypatch)
subtitles._extract_and_publish("98614", 3, 0.0)
assert subtitles.ensure("98614", 3, at=10.0)["growing"] is True
pool.jobs.clear()
_stub_item(monkeypatch, clean=False, cues=("39:58.000", "40:00.000"))
subtitles._extract_and_publish("98614", 3, None)
assert subtitles.ensure("98614", 3, at=100.0) == {
"state": "partial",
"code": None,
"gen": subtitles.generation(subtitles.cache_key("98614", 3)),
"growing": False,
}
def test_polls_that_follow_the_playhead_join_one_window(monkeypatch):
"""The poll sends the LIVE position, so it moves a second per poll. A window keyed on that
position was a new job every second — another read of the file per poll until the first landed."""
@@ -797,6 +845,25 @@ def test_a_window_lines_up_with_the_full_pass_on_a_real_file(tmp_path, monkeypat
assert merged.count("-->") == 30, "a window cue survived beside its full-pass twin"
@needs_ffmpeg
def test_a_full_pass_resumed_mid_file_lines_up_on_a_real_file(tmp_path, monkeypatch):
"""A retried full pass seeks like a window does but reads to the end (no `-t`): the cue already
running at its start must come out on time, not shifted to 0 with everything after it."""
src = _make_mkv(tmp_path)
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path / "cache")
head, clean = subtitles._ffmpeg_extract(str(src), [0], None, 30.0)
assert clean
rest, clean = subtitles._ffmpeg_extract(str(src), [0], 21.3, None)
assert clean
key = subtitles.cache_key("1", 0)
subtitles.publish(key, head[0], (0.0, 30.0), False)
subtitles.publish(key, subtitles.shift_vtt(rest[0], -21.3), (21.3, subtitles._FULL_END_S), True)
merged = subtitles.cache_read(key) or ""
assert "00:00:22.000 --> 00:00:23.500\nline 11" in merged, merged
assert "00:00:58.000 --> 00:00:59.500\nline 29" in merged, merged
assert merged.count("-->") == 30, "a cue across the join was kept twice or lost"
# --- the third deep-review round ----------------------------------------------------------------
+2
View File
@@ -678,6 +678,8 @@ export const plexApi = {
state: "ready" | "partial" | "preparing" | "unavailable" | "failed";
code: string | null;
gen: number;
/** On `partial`/`preparing`: whether the track still fills in on its own. */
growing?: boolean;
}> =>
// `quiet`: the player reports a failed poll inline, so no global error dialog on top of it.
req(
@@ -208,6 +208,22 @@ describe("usePlexTracks — when the status poll asks again, and when it stops",
expect(status).toHaveBeenCalledTimes(1);
});
it("slows down on a partial track that will not grow on its own, and keeps asking", async () => {
// The full pass failed and is backed off for an hour: `partial` never turns `ready` by itself,
// and the 2 s cadence ran for the rest of the film. It still carries the position — reading
// ahead of an edge depends on it — so it slows rather than stops.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "partial", code: null, gen: 4, growing: false });
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(10_000);
expect(status).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(6000);
expect(status).toHaveBeenCalledTimes(2);
});
it("backs off after a failed request, and still asks again so a blip heals on its own", async () => {
// A failed request leaves `data` empty; it used to read as "still preparing" and re-ask every
// second for as long as the player stayed open.
+7 -1
View File
@@ -484,6 +484,11 @@ const SUB_POLL_ERROR_MS = 10_000;
// server keeps for a minute), so no need for the 1 s cadence — but the poll carries the position, and
// moving into a stretch that IS cached should stop saying "couldn't be loaded" within a few seconds.
const SUB_POLL_UNAVAILABLE_MS = 5_000;
// …and on a `partial` track that is no longer `growing` (its full pass failed and is backed off for
// an hour): it will not fill in on its own, so the 2 s cadence ran for the rest of the film. The poll
// still carries the position — it is what reads the next window ahead of an edge, which the server
// starts within LOOKAHEAD_S (60 s) of it — so it slows down rather than stops.
const SUB_POLL_STALLED_MS = 15_000;
const SUB_TRACK_RETRIES = 2;
const SUB_TRACK_RETRY_MS = 2_000;
@@ -684,7 +689,8 @@ export function usePlexTracks(opts: {
const st = q.state.data?.state;
if (st === "ready" || st === "failed") return false;
if (st === "unavailable") return SUB_POLL_UNAVAILABLE_MS;
return st === "partial" ? 2000 : 1000;
if (st === "partial") return q.state.data?.growing === false ? SUB_POLL_STALLED_MS : 2000;
return 1000;
},
staleTime: 0,
retry: false,