diff --git a/backend/app/plex/lookups.py b/backend/app/plex/lookups.py index a66b83d3..4d2e4987 100644 --- a/backend/app/plex/lookups.py +++ b/backend/app/plex/lookups.py @@ -11,5 +11,10 @@ _M = TypeVar("_M") def by_rating_key(db: Session, model: type[_M], rating_key: str) -> _M | None: """The mirrored row for a Plex rating_key, or None. `model` is any of the rating_key-keyed Plex tables (PlexItem / PlexShow / PlexSeason / PlexCollection); the return type is that model, so call - sites keep their `Model | None` static type.""" + sites keep their `Model | None` static type. + + A key with a NUL in it (`%00` in a path segment) names no row — and Postgres refuses NUL in text, + so asking would be a DataError, i.e. a 500, from every route that looks an item up.""" + if "\x00" in str(rating_key): + return None return db.execute(select(model).where(model.rating_key == str(rating_key))).scalars().first() diff --git a/backend/app/plex/subtitles.py b/backend/app/plex/subtitles.py index bf659372..7948bb7b 100644 --- a/backend/app/plex/subtitles.py +++ b/backend/app/plex/subtitles.py @@ -166,14 +166,17 @@ def cache_read(key: str) -> str | None: return None -def cache_write(key: str, vtt: str) -> None: +def cache_write(key: str, vtt: str) -> bool: + """Atomically replace the cached VTT; False when it could not be written (`publish` must then + not claim the region it was carrying).""" try: CACHE_DIR.mkdir(parents=True, exist_ok=True) tmp = CACHE_DIR / f"{key}.vtt.tmp" tmp.write_text(vtt, encoding="utf-8") tmp.replace(CACHE_DIR / f"{key}.vtt") # atomic publish + return True except OSError: - pass + return False # --- cache identity ----------------------------------------------------------------------------- @@ -222,7 +225,7 @@ def generation(key: str) -> int: window is superseded by a wider one the browser refetches instead of keeping the short VTT.""" try: return _vtt_path(key).stat().st_mtime_ns - except OSError: + except (OSError, ValueError): # ValueError: a NUL in the key — `stat` refuses the path outright return 0 @@ -312,14 +315,22 @@ def publish(key: str, vtt: str, covered: tuple[float, float], complete: bool) -> staging names safe.""" with _PUBLISH_LOCK: old = cache_read(key) + if old is None and _vtt_path(key).exists(): + # There, but unreadable (`cache_read` swallows the OSError). Merging into "" would replace + # a whole track with this one region while its sidecar still claimed all of it. + log.warning("subtitle cache %s unreadable; not publishing into it", key) + return old_ranges, was_complete = read_meta(key) if old is None: - # A VTT with no sidecar reads as a COMPLETE track (see read_meta — how older builds - # cached), and a publish is two renames. So before a key's first VTT lands, its sidecar - # already claims nothing: dying between the two then leaves a window that is extracted - # again, never one taken for the whole track for good. - _write_meta(key, old_ranges, False) - cache_write(key, merge_vtt(old or "", vtt)) + # A sidecar with no VTT behind it claims cues that are not there, so it is not merged + # into. And a VTT with no sidecar reads as a COMPLETE track (see read_meta — how older + # builds cached), while a publish is two renames: so before a key's first VTT lands, its + # sidecar is made to claim nothing. Dying between the two then leaves a window that is + # extracted again, never one taken for the whole track for good. + old_ranges, was_complete = [], False + _write_meta(key, [], False) + if not cache_write(key, merge_vtt(old or "", vtt)): + return # the region never landed, so the sidecar must not claim it _write_meta(key, merge_ranges(old_ranges + [list(covered)]), complete or was_complete) @@ -332,9 +343,16 @@ def publish(key: str, vtt: str, covered: tuple[float, float], complete: bool) -> # any realistic full pass. WINDOW_PRE_S = 30.0 WINDOW_LEN_S = 300.0 +# …but it does not always land: a full pass can be backed off (FULL_RETRY_S) or simply slower than +# the window lasts (a file past ~40 GB, or several passes sharing the array). A window was only asked +# for once the playhead had LEFT coverage, so every edge unmounted the track for a poll plus an +# extraction. Within this distance of a covered stretch's end, the next window is read ahead. +LOOKAHEAD_S = 60.0 # Ceiling on one ffmpeg run so a wedged extraction can't hold a pool thread forever (the old # in-request extraction had no timeout at all). EXTRACT_TIMEOUT_S = 900.0 +# How long a timed-out ffmpeg gets, after a TERM, to close its outputs before it is killed. +_TERM_GRACE_S = 10.0 # A failed track (or window) is remembered briefly so a 1 s status poll doesn't respawn ffmpeg every # second, and forgotten again soon after so fixing the file (or Plex coming back) doesn't need a # restart. @@ -347,14 +365,20 @@ FULL_RETRY_S = 3600.0 # The furthest position the status poll accepts. No film is this long, and an absurd one used to # reach ffmpeg's `-ss` (which rejects anything past its int64 microseconds). MAX_POSITION_S = 100_000.0 -# Two items' full passes plus a window for a third: enough that a seek into an un-extracted region is -# served while a full pass runs, bounded so a burst can't thrash the array. -_POOL = ThreadPoolExecutor(max_workers=3, thread_name_prefix="plex-subs") -# Bounds on what may wait for that pool. Its queue is unbounded and every job is a read of the -# library: a viewer scrubbing through a film queued a window per stop (the one where they stopped ran -# LAST), and a script could queue thousands. Past either bound nothing is queued — the next poll asks -# again, by which time something has finished. -_MAX_INFLIGHT = 12 +# Windows and full passes run on SEPARATE pools, so a window never waits behind a full pass. They +# shared one FIFO pool of three whose comment promised "room for a window" that nothing enforced: +# stepping through three episodes with subtitles on left three full passes (minutes each) holding every +# worker, and a seek window on the third queued behind them. Two of each: bounded, so a burst can't +# thrash the array. +_WIN_POOL = ThreadPoolExecutor(max_workers=2, thread_name_prefix="plex-subs-win") +_FULL_POOL = ThreadPoolExecutor(max_workers=2, thread_name_prefix="plex-subs-full") +# Bounds on what may wait for those pools — separate too, so held full-pass slots (minutes each) +# cannot refuse a window (seconds). The queues are unbounded and every job is a read of the library: +# a viewer scrubbing through a film queued a window per stop (the one where they stopped ran LAST), +# and a script could queue thousands. Past a bound nothing is queued — the next poll asks again, by +# which time something has finished. +_MAX_WINDOWS_INFLIGHT = 8 +_MAX_FULL_INFLIGHT = 6 _MAX_TRACK_WINDOWS = 2 _LOCK = threading.Lock() @@ -396,6 +420,12 @@ UNAVAILABLE_STATUS = { "extract_failed": 422, "plex_error": 422, } +# The codes that are a FINAL answer about the track: it can never be served as text, so the player +# stops asking. The others (`plex_error`, `extract_failed`) are about now or about one stretch — a +# memo that lapses, a window that failed where the rest is fine — and answered as `unavailable`: said, +# but asked again. Given as `failed`, one Plex hiccup ended the subtitles for the rest of the session, +# at cached positions too, and after the full pass had completed behind it. +FINAL_CODES = frozenset({"no_item", "no_track", "no_file", "not_fetchable", "bitmap"}) def reset_state() -> None: @@ -421,13 +451,28 @@ def shutdown() -> None: with _LOCK: _STOPPING = True procs = list(_PROCS) - _POOL.shutdown(wait=False, cancel_futures=True) + for pool in (_WIN_POOL, _FULL_POOL): + pool.shutdown(wait=False, cancel_futures=True) for p in procs: p.kill() +def _sweep_memos() -> None: + """(Caller holds _LOCK.) Drop every lapsed failure memo. They were only ever pruned when the SAME + key was read again, and the status route takes any rating_key/ordinal — so a script polling fresh + keys grew the API process without bound. Swept on every write, they hold at most a TTL's worth.""" + now = time.monotonic() + for k in [k for k, (_, t) in _FAILURES.items() if now - t >= FAIL_TTL_S]: + del _FAILURES[k] + for k in [k for k, t in _FULL_FAILED.items() if now - t >= FULL_RETRY_S]: + del _FULL_FAILED[k] + for k in list(_WIN_FAILED): + _live_window_failures(k) + + def _fail(key: str, code: str) -> None: with _LOCK: + _sweep_memos() _FAILURES[key] = (code, time.monotonic()) @@ -462,6 +507,7 @@ def _live_window_failures(key: str) -> list[tuple[float, float, float]]: def _window_fail(key: str, span: tuple[float, float]) -> None: with _LOCK: + _sweep_memos() _WIN_FAILED[key] = [*_live_window_failures(key), (span[0], span[1], time.monotonic())] @@ -474,33 +520,41 @@ def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict: """State of one subtitle track, scheduling whatever extraction is still missing. NEVER blocks. ``ready`` = fully extracted · ``partial`` = usable, covers ``at``, still growing · ``preparing`` - = nothing to show for ``at`` yet · ``failed`` = ``code`` says why. ``gen`` changes whenever the - cached file grows, so the client can refetch a window that has been superseded.""" + = nothing to show for ``at`` yet · ``unavailable`` = nothing for ``at`` and nothing being read + for it right now (``code`` says why), ask again · ``failed`` = the track can never be shown + (``code`` says why) — the only final answer. ``gen`` changes whenever the cached file grows, so + the client can refetch a window that has been superseded; 0 = nothing is cached at all.""" key = cache_key(rating_key, sub_ord) ranges, complete = read_meta(key) have = cache_read(key) is not None covered = have and covers(ranges, at) + gen = generation(key) if complete and have: - return {"state": "ready", "code": None, "gen": generation(key)} + return {"state": "ready", "code": None, "gen": gen} code = _recent_failure(key) if code and not covered: - return {"state": "failed", "code": code, "gen": generation(key)} - # "Failed" is a FINAL answer — the player stops asking — so for an ffmpeg failure it is given only - # once nothing is left that could still cover the position: the full pass failed, AND so did a - # window here. + return { + "state": "failed" if code in FINAL_CODES else "unavailable", + "code": code, + "gen": gen, + } + # Nothing left that could cover the position for now: the full pass failed, AND so did a window + # here. Still not final — the memos lapse, and the rest of the track may be right there. full_failed = _full_failed(rating_key) window_failed = not covered and _window_failed(key, at) if full_failed and window_failed: - return {"state": "failed", "code": "extract_failed", "gen": generation(key)} + return {"state": "unavailable", "code": "extract_failed", "gen": gen} if not code: - _schedule( - rating_key, - sub_ord, - at, - want_window=not covered and not window_failed, - want_full=not full_failed, - ) - return {"state": "partial" if covered else "preparing", "code": None, "gen": generation(key)} + probe, want_window = at, not covered and not window_failed + if covered: + # Read ahead of an edge instead of waiting to fall off it (see LOOKAHEAD_S). The probe is + # just PAST the edge: `covers` is inclusive, so the edge itself reads as already done. + edge = max(b for a, b in ranges if a <= at <= b) + if edge - at <= LOOKAHEAD_S and edge < _FULL_END_S: + 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} def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]: @@ -532,9 +586,11 @@ def _schedule( full pass is per ITEM, because one read fans out to every embedded text track of the file.""" jobs: list[tuple[tuple, float | None]] = [] with _LOCK: - if _STOPPING or len(_INFLIGHT) >= _MAX_INFLIGHT: + if _STOPPING: return - if want_window: + n_windows = sum(1 for span in _INFLIGHT.values() if span is not None) + n_full = len(_INFLIGHT) - n_windows + if want_window and n_windows < _MAX_WINDOWS_INFLIGHT: spans = [ span for ident, span in _INFLIGHT.items() @@ -548,11 +604,12 @@ def _schedule( ident = ("win", rating_key, sub_ord, w_start) _INFLIGHT[ident] = (w_start, w_start + WINDOW_PRE_S + WINDOW_LEN_S) jobs.append((ident, w_start)) - if want_full and ("full", rating_key) not in _INFLIGHT: + if want_full and n_full < _MAX_FULL_INFLIGHT and ("full", rating_key) not in _INFLIGHT: _INFLIGHT[("full", rating_key)] = None jobs.append((("full", rating_key), None)) for ident, window_start in jobs: - _POOL.submit(_run, ident, rating_key, sub_ord, window_start, at) + pool = _FULL_POOL if window_start is None else _WIN_POOL + pool.submit(_run, ident, rating_key, sub_ord, window_start, at) def _already_done(key: str, window_start: float | None, at: float) -> bool: @@ -601,6 +658,7 @@ def _pass_failed( window marks only the stretch it failed to read, for the tracks it was reading.""" if window_start is None: with _LOCK: + _sweep_memos() _FULL_FAILED[rating_key] = time.monotonic() return for key in keys: @@ -731,13 +789,23 @@ def _ffmpeg_extract( _PROCS.add(proc) if _STOPPING: proc.kill() + timed_out = False try: try: _, err = proc.communicate(timeout=EXTRACT_TIMEOUT_S) except subprocess.TimeoutExpired: - proc.kill() - proc.communicate() - raise + # A pass that ran out of time is a pass that died partway: what it reached is kept and + # published as partial (the caller's unclean path), like any other non-zero exit. It + # used to be killed and its outputs deleted unread — and with the full-pass backoff, the + # same doomed read came back every hour. TERM first: ffmpeg closes its outputs on it, + # whereas a KILL leaves only what was already flushed in whole blocks (measured: none). + timed_out = True + proc.terminate() + try: + _, err = proc.communicate(timeout=_TERM_GRACE_S) + except subprocess.TimeoutExpired: + proc.kill() + _, err = proc.communicate() finally: with _LOCK: _PROCS.discard(proc) @@ -748,7 +816,11 @@ def _ffmpeg_extract( } if not got: raise SubtitleUnavailable("extract_failed") - clean = proc.returncode == 0 + clean = proc.returncode == 0 and not timed_out if not clean: - log.warning("ffmpeg subtitle pass exited %s: %s", proc.returncode, (err or b"")[:200]) + log.warning( + "ffmpeg subtitle pass %s: %s", + "timed out" if timed_out else f"exited {proc.returncode}", + (err or b"")[:200], + ) return got, clean diff --git a/backend/tests/test_plex_subtitle_auth.py b/backend/tests/test_plex_subtitle_auth.py index 0ddb5ee8..a4f6b2fe 100644 --- a/backend/tests/test_plex_subtitle_auth.py +++ b/backend/tests/test_plex_subtitle_auth.py @@ -35,7 +35,8 @@ def test_a_suspended_account_cannot_drive_the_subtitle_extractor( monkeypatch.setattr(mod, "resolved_user_id", lambda request: (user.id, True)) monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path) pool = _NoPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + monkeypatch.setattr(subtitles, "_WIN_POOL", pool) + monkeypatch.setattr(subtitles, "_FULL_POOL", pool) subtitles.reset_state() client = TestClient(app) diff --git a/backend/tests/test_plex_subtitle_prepare.py b/backend/tests/test_plex_subtitle_prepare.py index 52a3385d..0cd0c9b4 100644 --- a/backend/tests/test_plex_subtitle_prepare.py +++ b/backend/tests/test_plex_subtitle_prepare.py @@ -14,8 +14,11 @@ These tests pin the three things that fixes it: the request never waits for ffmp publishes a window around the playhead before the whole track, and one ffmpeg run covers every embedded text track — plus what the deep-review of that fix found (cues doubled at the window/full seam, a truncated pass cached as complete, a poll that respawned ffmpeg every second, a sidecar stuck -behind an unrelated pass). Pure — no Postgres, no ffmpeg (the auth half is in -test_plex_subtitle_auth.py, which needs the DB). +behind an unrelated pass). No Postgres (the auth half is in test_plex_subtitle_auth.py, which needs +the DB). Almost every test stubs ffmpeg out, but not all: a few run a real child process, and +`test_a_window_lines_up_with_the_full_pass_on_a_real_file` needs the real ffmpeg — it is the only +guard of `-avoid_negative_ts disabled`. It skips without one, except in the gate's DB lane, where +conftest refuses to run without ffmpeg rather than let it skip. """ import asyncio @@ -77,6 +80,24 @@ class _ManualPool: fn(*args) return len(jobs) + def shutdown(self, wait=True, cancel_futures=False) -> None: + pass # jobs still run only when the test says so — which is what the shutdown tests probe + + +def _manual_pool(monkeypatch) -> _ManualPool: + """One manual pool standing in for BOTH extraction pools, so a test sees every job in the order + it was submitted (window or full pass — `window_start` tells them apart).""" + pool = _ManualPool() + monkeypatch.setattr(subtitles, "_WIN_POOL", pool) + monkeypatch.setattr(subtitles, "_FULL_POOL", pool) + return pool + + +def _real_pools(monkeypatch) -> None: + """Fresh single-worker pools (the module's own may have been shut down by an earlier test).""" + monkeypatch.setattr(subtitles, "_WIN_POOL", ThreadPoolExecutor(max_workers=1)) + monkeypatch.setattr(subtitles, "_FULL_POOL", ThreadPoolExecutor(max_workers=1)) + def _cue(start: str, end: str, text: str) -> str: return f"{start} --> {end}\n{text}" @@ -120,8 +141,7 @@ def test_a_cold_track_answers_at_once_instead_of_waiting_for_ffmpeg(client, monk def test_a_burst_of_polls_starts_one_window_and_one_full_pass(monkeypatch): """The player polls once a second while it waits. Each poll must JOIN the running jobs, not start another ffmpeg over the same 17 GB.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) for _ in range(5): subtitles.ensure("98614", 0, at=600.0) assert len(pool.jobs) == 2 # one window (at 600 s) + the one full pass @@ -135,8 +155,7 @@ def test_a_burst_of_polls_starts_one_window_and_one_full_pass(monkeypatch): def test_status_walks_preparing_to_partial_to_ready(client, monkeypatch): """A window around the playhead makes the track showable long before it is complete, and the generation changes when it grows so the browser refetches instead of keeping the short VTT.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) key = subtitles.cache_key("98614", 0) def _publish(rating_key, sub_ord, window_start): @@ -170,8 +189,7 @@ def test_status_walks_preparing_to_partial_to_ready(client, monkeypatch): def test_a_position_outside_the_extracted_window_is_not_called_showable(client, monkeypatch): """Honest states: cues exist, but none for where the viewer just seeked to. Saying "ready" there would put an empty track on screen with no explanation — the original complaint.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + _manual_pool(monkeypatch) subtitles.publish( subtitles.cache_key("98614", 0), _vtt(_cue("00:10.000", "00:12.000", "x")), @@ -183,8 +201,7 @@ def test_a_position_outside_the_extracted_window_is_not_called_showable(client, def test_an_unshowable_track_says_why_and_maps_to_a_status(client, monkeypatch): - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) def _bitmap(rating_key, sub_ord, window_start): raise subtitles.SubtitleUnavailable("bitmap") @@ -379,10 +396,9 @@ def test_a_full_pass_that_died_is_not_cached_as_the_whole_track(monkeypatch): """A truncated full pass used to be published as complete: `ensure` answered "ready" forever, the player stopped polling, and — the cache has no eviction — the subtitles simply ended wherever ffmpeg had died, in every later session too. Now it covers what it provably reached; past that - a window is tried, and only when that fails too is the position answered as failed.""" + a window is tried, and only when that fails too is the position answered as unavailable.""" _stub_item(monkeypatch, clean=False, cues=("39:58.000", "40:00.000")) - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) subtitles._extract_and_publish("98614", 3, None) key = subtitles.cache_key("98614", 3) assert subtitles.read_meta(key) == ([[0.0, 2400.0]], False) @@ -392,14 +408,13 @@ def test_a_full_pass_that_died_is_not_cached_as_the_whole_track(monkeypatch): _window_raises(monkeypatch) pool.run_all() after = subtitles.ensure("98614", 3, at=3000.0) - assert (after["state"], after["code"]) == ("failed", "extract_failed") + assert (after["state"], after["code"]) == ("unavailable", "extract_failed") 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.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) for at in (600.0, 601.0, 602.0, 603.0, 604.0): subtitles.ensure("98614", 1, at=at) assert len(pool.jobs) == 2, "one window + the one full pass" @@ -409,8 +424,7 @@ def test_a_window_for_a_different_stretch_does_not_wait_behind_the_first(monkeyp """Measured on localdev: after a resume the first poll can still report 0 (the position is only known once playback ticks), so the first window is cut around the wrong spot. The poll that follows, at the real position, must get its own window at once — not queue behind that one.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) subtitles.ensure("98614", 1, at=0.0) subtitles.ensure("98614", 1, at=1198.0) assert sorted(a[3] for _, a in pool.jobs if a[3] is not None) == [0.0, 1168.0] @@ -419,8 +433,7 @@ def test_a_window_for_a_different_stretch_does_not_wait_behind_the_first(monkeyp def test_a_queued_window_that_was_covered_meanwhile_reads_nothing(monkeypatch): """Every embedded track rides each pass over its file, so by the time a queued window for one language runs, another language's window may already have published it.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) calls = _stub_item(monkeypatch) subtitles.ensure("98614", 3, at=600.0) subtitles.publish( @@ -437,8 +450,7 @@ def test_a_sidecar_does_not_wait_behind_an_embedded_full_pass(monkeypatch): """The full pass is one job per ITEM (the embedded fan-out shares it). A sidecar picked while an embedded track's pass was running used to find that identity taken and wait out the whole read — minutes — for a track that is one small fetch.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) _stub_item(monkeypatch) subtitles._INFLIGHT[("full", "98614")] = None # an embedded track's full pass is running subtitles.ensure("98614", 0, at=600.0) @@ -494,7 +506,7 @@ def test_shutdown_kills_a_running_extraction(monkeypatch): "Popen", lambda cmd, **kw: real_popen([sys.executable, "-c", "import time; time.sleep(60)"], **kw), ) - monkeypatch.setattr(subtitles, "_POOL", ThreadPoolExecutor(max_workers=1)) + _real_pools(monkeypatch) finished = threading.Event() def _extract(): @@ -519,8 +531,7 @@ def test_the_track_fetch_serves_what_is_cached_and_schedules_nothing(client, mon readiness at position 0. A viewer resumed an hour in got an unrequested extraction over the head of the file, and with a failure memo alive the fetch refused a window that WAS on disk while the status poll (which knows the position) called the track showable: silently blank subtitles.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) assert client.get("/api/plex/subtitle/98614/0").status_code == 202 assert pool.jobs == [], "the fetch scheduled an extraction" key = subtitles.cache_key("98614", 0) @@ -534,8 +545,7 @@ def test_the_track_fetch_serves_what_is_cached_and_schedules_nothing(client, mon @pytest.mark.parametrize("at", ["inf", "-1", "nan"]) def test_an_impossible_position_is_rejected(client, monkeypatch, at): """`?at=inf` reached the scheduler's arithmetic and came back as a 500 with a traceback.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) assert client.get(f"/api/plex/subtitle/98614/0/status?at={at}").status_code == 422 assert pool.jobs == [] @@ -549,8 +559,7 @@ def test_an_impossible_offset_is_rejected(client, q): def test_an_absurd_position_is_rejected_before_it_reaches_ffmpeg(client, monkeypatch): """ffmpeg refuses an `-ss` past its int64 microseconds, and that failure was recorded against the TRACK — every other viewer at an uncovered position was told "failed" for a minute.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) assert client.get("/api/plex/subtitle/98614/0/status?at=1e13").status_code == 422 assert pool.jobs == [] @@ -582,8 +591,7 @@ def test_a_full_pass_that_failed_is_not_read_again_while_its_output_is_watched(m re-armed a read of the whole container. A failure that reproduces did so every time: a full read of the file every few minutes for as long as they watched.""" _stub_item(monkeypatch, clean=False, cues=("39:58.000", "40:00.000")) - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) subtitles._extract_and_publish("98614", 3, None) _later(monkeypatch, subtitles.FAIL_TTL_S + 1) for _ in range(3): @@ -595,8 +603,7 @@ def test_a_failed_window_does_not_end_the_poll_while_the_full_pass_may_cover_it( """ "failed" is final — the player stops asking. A window that failed used to set the TRACK's failure memo, so a viewer anywhere outside the covered part got "failed" and never heard about the full pass that landed a minute later.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) _stub_item(monkeypatch) _window_raises(monkeypatch) subtitles.ensure("98614", 3, at=3000.0) @@ -609,8 +616,7 @@ def test_a_failed_window_does_not_end_the_poll_while_the_full_pass_may_cover_it( def test_a_backward_seek_gets_its_own_window(monkeypatch): """A window bucketed by position joined a seek back to 6:40 onto a running window at 9:10 that could not cover it — the right one waited for the wrong one.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) subtitles.ensure("98614", 1, at=580.0) subtitles.ensure("98614", 1, at=400.0) assert sorted(a[3] for _, a in pool.jobs if a[3] is not None) == [370.0, 550.0] @@ -619,8 +625,7 @@ def test_a_backward_seek_gets_its_own_window(monkeypatch): def test_a_playhead_crossing_a_bucket_edge_joins_the_window_that_covers_it(monkeypatch): """…and the same bucketing queued a second read when a playhead crossed an edge while the first window — which already covered it — was still running.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) for at in (628.0, 629.0, 630.0, 631.0, 632.0): subtitles.ensure("98614", 1, at=at) assert len(pool.jobs) == 2, "one window + the one full pass" @@ -628,27 +633,26 @@ def test_a_playhead_crossing_a_bucket_edge_joins_the_window_that_covers_it(monke def test_a_scrubbing_viewer_cannot_queue_a_window_per_stop(monkeypatch): """The pool's queue is unbounded and every job is a read of the library.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) for at in (0.0, 1000.0, 2000.0, 3000.0, 4000.0): subtitles.ensure("98614", 1, at=at) assert len([1 for _, a in pool.jobs if a[3] is not None]) == subtitles._MAX_TRACK_WINDOWS def test_nothing_more_is_queued_while_the_extractor_is_saturated(monkeypatch): - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) for rk in range(20): subtitles.ensure(str(rk), 0, at=0.0) - assert len(pool.jobs) == subtitles._MAX_INFLIGHT + windows = [a for _, a in pool.jobs if a[3] is not None] + assert len(windows) == subtitles._MAX_WINDOWS_INFLIGHT + assert len(pool.jobs) - len(windows) == subtitles._MAX_FULL_INFLIGHT def test_a_window_starts_where_ffmpeg_is_told_to_seek(monkeypatch): """ffmpeg gets `-ss` to the millisecond, and its cues come back on THAT zero. Shifted back by an unrounded start they landed up to half a millisecond off the full pass's — spelled differently, and so kept twice by the merge.""" - pool = _ManualPool() - monkeypatch.setattr(subtitles, "_POOL", pool) + pool = _manual_pool(monkeypatch) subtitles.ensure("98614", 1, at=1198.4375) (window_start,) = [a[3] for _, a in pool.jobs if a[3] is not None] assert window_start == 1168.438 @@ -690,7 +694,8 @@ def test_shutdown_drops_queued_extractions(monkeypatch): """Only the kill half used to be pinned. A job still QUEUED at shutdown would otherwise start after the kill sweep — a fresh read of the file holding the process open.""" pool = ThreadPoolExecutor(max_workers=1) - monkeypatch.setattr(subtitles, "_POOL", pool) + monkeypatch.setattr(subtitles, "_WIN_POOL", pool) + monkeypatch.setattr(subtitles, "_FULL_POOL", pool) busy, release, ran = threading.Event(), threading.Event(), threading.Event() pool.submit(lambda: (busy.set(), release.wait(timeout=5))) pool.submit(ran.set) @@ -703,7 +708,7 @@ def test_shutdown_drops_queued_extractions(monkeypatch): def test_a_worker_that_reaches_ffmpeg_after_shutdown_never_starts_it(monkeypatch): """A worker still in its Plex lookup when the kill sweep ran used to start its ffmpeg anyway.""" - monkeypatch.setattr(subtitles, "_POOL", ThreadPoolExecutor(max_workers=1)) + _real_pools(monkeypatch) started: list = [] monkeypatch.setattr(subtitles.subprocess, "Popen", lambda cmd, **kw: started.append(cmd)) subtitles.shutdown() @@ -789,3 +794,211 @@ def test_a_window_lines_up_with_the_full_pass_on_a_real_file(tmp_path, monkeypat subtitles.publish(key, full[0], (0.0, subtitles._FULL_END_S), True) merged = subtitles.cache_read(key) or "" assert merged.count("-->") == 30, "a window cue survived beside its full-pass twin" + + +# --- the third deep-review round ---------------------------------------------------------------- + + +def test_a_transient_failure_is_said_but_is_not_a_final_answer(monkeypatch): + """`failed` ends the player's poll and unmounts the track. A Plex hiccup memo (a minute) was given + as `failed`, so one blip ended the subtitles for the rest of the session — at cached positions + too, and after the full pass had completed behind it. It is `unavailable` now: said, asked again.""" + pool = _manual_pool(monkeypatch) + key = subtitles.cache_key("98614", 0) + subtitles.publish(key, _vtt(_cue("01:40.000", "01:42.000", "x")), (70.0, 400.0), False) + subtitles._fail(key, "plex_error") + here = subtitles.ensure("98614", 0, at=3000.0) + assert (here["state"], here["code"]) == ("unavailable", "plex_error") + assert here["gen"] > 0, "the client needs to know there are cues elsewhere to keep showing" + assert subtitles.ensure("98614", 0, at=100.0)["state"] == "partial" + assert pool.jobs == [], "nothing is re-read while the memo is alive" + _later(monkeypatch, subtitles.FAIL_TTL_S + 1) + assert subtitles.ensure("98614", 0, at=3000.0)["state"] == "preparing" + assert [a[3] for _, a in pool.jobs] == [2970.0, None], "asked again once the memo lapsed" + + +def test_the_next_window_is_read_ahead_of_its_edge(monkeypatch): + """With the full pass backed off (or slower than a window lasts) a window was only asked for once + the playhead had LEFT coverage — the track unmounted at every edge for a poll plus a read.""" + pool = _manual_pool(monkeypatch) + subtitles._FULL_FAILED["98614"] = time.monotonic() + key = subtitles.cache_key("98614", 0) + subtitles.publish(key, _vtt(_cue("16:20.000", "16:22.000", "x")), (970.0, 1300.0), False) + assert subtitles.ensure("98614", 0, at=1200.0)["state"] == "partial" + assert pool.jobs == [], "far from the edge: nothing to read yet" + assert subtitles.ensure("98614", 0, at=1250.0)["state"] == "partial" + assert [a[3] for _, a in pool.jobs] == [1271.0], "the next window, before the edge is reached" + + +def test_a_window_that_died_partway_marks_the_stretch_it_did_not_reach(monkeypatch): + """Without the span memo, a window that died before the playhead left it uncovered AND not + failed — so every poll queued the same read of the library again.""" + _stub_item(monkeypatch, clean=False, cues=("00:10.000", "00:12.000")) + pool = _manual_pool(monkeypatch) + subtitles._extract_and_publish("98614", 3, 3570.0) + key = subtitles.cache_key("98614", 3) + assert subtitles.read_meta(key) == ([[3570.0, 3582.0]], False) + assert subtitles.ensure("98614", 3, at=3700.0)["state"] == "preparing" + assert [a[3] for _, a in pool.jobs] == [None], "the dead stretch was queued again" + + +def test_a_crashed_pass_is_recorded_like_a_failed_one(monkeypatch): + """A timeout or a crash lands in `_run`'s catch-all. Recorded against the TRACK, a crashed window + told every viewer at any uncovered position "failed", and a crashed full pass re-armed a + whole-file read every minute instead of backing off.""" + + def _crash(rating_key, sub_ord, window_start): + raise RuntimeError("boom") + + monkeypatch.setattr(subtitles, "_extract_and_publish", _crash) + key = subtitles.cache_key("98614", 3) + subtitles._run(("win", "98614", 3, 570.0), "98614", 3, 570.0, 600.0) + assert subtitles._window_failed(key, 600.0) and not subtitles._window_failed(key, 3000.0) + subtitles._run(("full", "98614"), "98614", 3, None, 600.0) + assert subtitles._full_failed("98614") + assert subtitles._recent_failure(key) is None, "a crash is not a verdict on the whole track" + + +def test_the_failure_memos_lapse(monkeypatch): + """Both memos exist to stop a re-read, not to forbid one: permanent, a transient failure would + have held until the process restarted.""" + pool = _manual_pool(monkeypatch) + key = subtitles.cache_key("98614", 3) + subtitles._pass_failed("98614", [key], None) + subtitles._pass_failed("98614", [key], 570.0) + assert subtitles.ensure("98614", 3, at=600.0)["state"] == "unavailable" + _later(monkeypatch, subtitles.FAIL_TTL_S + 1) + subtitles.ensure("98614", 3, at=600.0) + assert [a[3] for _, a in pool.jobs] == [570.0], "the window memo lapsed; the full one has not" + pool.jobs.clear() + subtitles._INFLIGHT.clear() + _later(monkeypatch, subtitles.FULL_RETRY_S + 1) + subtitles.ensure("98614", 3, at=600.0) + assert None in [a[3] for _, a in pool.jobs], "the full pass was never tried again" + + +def test_lapsed_memos_are_swept_not_kept_forever(monkeypatch): + """They were pruned only when the SAME key was read again, and the status route takes any key: + polling fresh ones grew the API process without bound.""" + for i in range(50): + subtitles._fail(f"sub_x{i}_0", "no_item") + subtitles._pass_failed(f"x{i}", [f"sub_x{i}_0"], None) + subtitles._pass_failed(f"x{i}", [f"sub_x{i}_0"], 0.0) + _later(monkeypatch, subtitles.FULL_RETRY_S + 1) + subtitles._fail("sub_y_0", "no_item") + assert list(subtitles._FAILURES) == ["sub_y_0"] + assert subtitles._FULL_FAILED == {} and subtitles._WIN_FAILED == {} + + +def test_a_window_is_not_refused_while_full_passes_hold_their_slots(monkeypatch): + """One pool and one cap for both kinds of job: full passes (minutes each) held every worker and + every slot, and a seek window — seconds of work — waited behind them or was not queued at all.""" + win, full = _ManualPool(), _ManualPool() + monkeypatch.setattr(subtitles, "_WIN_POOL", win) + monkeypatch.setattr(subtitles, "_FULL_POOL", full) + for rk in range(subtitles._MAX_FULL_INFLIGHT): + subtitles._INFLIGHT[("full", str(rk))] = None + subtitles.ensure("98614", 1, at=600.0) + assert [a[3] for _, a in win.jobs] == [570.0] + assert full.jobs == [], "past its bound no full pass is queued" + + +def test_a_poll_after_shutdown_queues_nothing(monkeypatch): + pool = _manual_pool(monkeypatch) + subtitles.shutdown() + subtitles.ensure("98614", 1, at=600.0) + assert pool.jobs == [] + + +def test_a_job_dequeued_after_shutdown_reads_nothing(monkeypatch): + pool = _manual_pool(monkeypatch) + ran: list = [] + monkeypatch.setattr(subtitles, "_extract_and_publish", lambda *a: ran.append(a)) + subtitles.ensure("98614", 1, at=600.0) + subtitles.shutdown() + pool.run_all() + assert ran == [] + + +def test_a_child_started_while_the_shutdown_sweep_runs_is_killed(monkeypatch): + """The sweep can run between the pre-Popen check and the child's registration.""" + killed: list = [] + + class _Child: + returncode = -9 + + def kill(self): + killed.append(True) + + def communicate(self, timeout=None): + return b"", b"" + + def _popen(cmd, **kw): + subtitles._STOPPING = True # the shutdown sweep ran while this child was being started + return _Child() + + monkeypatch.setattr(subtitles.subprocess, "Popen", _popen) + with pytest.raises(subtitles.SubtitleUnavailable): + subtitles._ffmpeg_extract("/plex-media/f.mkv", [0], None, None) + assert killed == [True] + + +def test_a_pass_that_times_out_keeps_what_it_reached(monkeypatch): + """It was killed and its outputs deleted unread — so a file too big to read inside the timeout + never got past the head, and with the full-pass backoff the same doomed read came back hourly.""" + real_popen = subprocess.Popen + cue = _vtt(_cue("00:05.000", "00:07.000", "reached")) + + def _popen(cmd, **kw): + out = cmd[cmd.index("-y") + 1] + script = f"import time\nopen({out!r}, 'w').write({cue!r})\ntime.sleep(60)" + return real_popen([sys.executable, "-c", script], **kw) + + monkeypatch.setattr(subtitles.subprocess, "Popen", _popen) + monkeypatch.setattr(subtitles, "EXTRACT_TIMEOUT_S", 1.0) + out, clean = subtitles._ffmpeg_extract("/plex-media/f.mkv", [0], None, None) + assert not clean, "a pass that ran out of time must not pass for a whole one" + assert "reached" in out[0] + + +def test_an_orphan_sidecar_is_not_merged_into(monkeypatch): + """A sidecar with no VTT behind it claims cues that are not there. Merged into, it made one + window read as the whole track — `ready`, and the full pass skipped as already done.""" + key = subtitles.cache_key("98614", 0) + subtitles._write_meta(key, [[0.0, subtitles._FULL_END_S]], True) + subtitles.publish(key, _vtt(_cue("16:20.000", "16:22.000", "x")), (970.0, 1300.0), False) + assert subtitles.read_meta(key) == ([[970.0, 1300.0]], False) + assert subtitles.ensure("98614", 0, at=5000.0)["state"] != "ready" + + +def test_an_unreadable_track_is_not_overwritten_by_one_window(monkeypatch): + """`cache_read` answers None for a file it could not READ as well as for one that is not there. + Taken for absent, a whole track was replaced by the one window being published.""" + key = subtitles.cache_key("98614", 0) + whole = _vtt(_cue("00:01.000", "00:02.000", "whole")) + subtitles.publish(key, whole, (0.0, subtitles._FULL_END_S), True) + monkeypatch.setattr(subtitles, "cache_read", lambda k: None) # an I/O error on the read + subtitles.publish(key, _vtt(_cue("16:20.000", "16:22.000", "x")), (970.0, 1300.0), False) + assert "whole" in subtitles._vtt_path(key).read_text("utf-8") + + +def test_a_region_that_could_not_be_written_is_not_claimed(monkeypatch): + monkeypatch.setattr(subtitles, "cache_write", lambda key, vtt: False) + key = subtitles.cache_key("98614", 0) + subtitles.publish(key, _vtt(_cue("16:20.000", "16:22.000", "x")), (0.0, 1e9), True) + assert subtitles.read_meta(key) == ([], False) + + +def test_a_nul_in_the_rating_key_is_not_a_500(client, monkeypatch): + """`%00` reached `Path.stat`, which raises ValueError rather than OSError — a 500 on every poll, + after the scheduler had already queued two jobs for a key that names nothing.""" + _manual_pool(monkeypatch) + assert client.get("/api/plex/subtitle/%00/0/status").status_code == 200 + + +def test_a_nul_rating_key_names_no_row(): + """Postgres refuses NUL in text, so looking it up was a DataError (a 500) on every Plex route.""" + from app.models import PlexItem + from app.plex.lookups import by_rating_key + + assert by_rating_key(None, PlexItem, "12\x0034") is None # type: ignore[arg-type] diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 440a109b..9be6b783 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -662,13 +662,15 @@ export const plexApi = { // Whether a subtitle track can be shown yet. Extracting one out of a big MKV costs a full read of // the container (measured: 154 s for a 17 GB film), so the server does it in the background — around // `at`, the viewer's position, first — and the player polls this instead of mounting a that - // would sit empty for minutes. `partial` = a window around `at` is ready and still growing. + // would sit empty for minutes. `partial` = a window around `at` is ready and still growing; + // `unavailable` = nothing for `at` right now (`code` says why) but ask again — cues elsewhere, when + // `gen` > 0, are still there; `failed` = the track can never be shown (the only final answer). plexSubtitleStatus: ( id: string, ord: number, at = 0, ): Promise<{ - state: "ready" | "partial" | "preparing" | "failed"; + state: "ready" | "partial" | "preparing" | "unavailable" | "failed"; code: string | null; gen: number; }> => diff --git a/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx b/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx index 462fde58..a1ebf86c 100644 --- a/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx +++ b/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx @@ -249,6 +249,49 @@ describe("usePlexTracks — when the status poll asks again, and when it stops", await waitFor(() => expect(result.current.subNotice).toBe("failed")); expect(result.current.subShowable).toBe(true); // the window it has is still worth showing }); + + it("says `unavailable` but keeps asking — and keeps the cues it has on screen", async () => { + // A Plex hiccup, or a stretch ffmpeg could not read, is the server's answer about NOW. Taken as + // final it stopped the poll and unmounted the track: no subtitles for the rest of the session, + // at cached positions too, after the full pass had completed behind it. + const status = vi + .spyOn(api, "plexSubtitleStatus") + .mockResolvedValueOnce({ state: "unavailable", code: "plex_error", gen: 5 }) + .mockResolvedValue({ state: "partial", code: null, gen: 6 }); + const { result } = mount(client()); + result.current.setSubOrd(0); + await waitFor(() => expect(result.current.subNotice).toBe("failed")); + expect(result.current.subShowable).toBe(true); + await vi.advanceTimersByTimeAsync(5100); + expect(status).toHaveBeenCalledTimes(2); + await waitFor(() => expect(result.current.subNotice).toBeNull()); + }); + + it("does not mount an `unavailable` track that has no cues anywhere", async () => { + vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ + state: "unavailable", + code: "plex_error", + gen: 0, + }); + const { result } = mount(client()); + result.current.setSubOrd(0); + await waitFor(() => expect(result.current.subNotice).toBe("failed")); + expect(result.current.subShowable).toBe(false); + }); + + it("re-asks the server at once when the viewer picks the track that is already selected", async () => { + // Same track, same query key: without an explicit refetch "picking it again" did nothing for a + // poll that was backing off after a failed request. + const status = vi.spyOn(api, "plexSubtitleStatus").mockRejectedValue(new Error("offline")); + const { result } = mount(client()); + result.current.setSubOrd(0); + await waitFor(() => expect(result.current.subNotice).toBe("failed")); + expect(status).toHaveBeenCalledTimes(1); + status.mockResolvedValue({ state: "partial", code: null, gen: 2 }); + act(() => result.current.changeSubtitle(0)); + await waitFor(() => expect(status).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.subShowable).toBe(true)); + }); }); // Rendered for real, not by calling the hook's callback: the failure arrives as the 's own diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 051d790f..797d9373 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -353,6 +353,10 @@ export type SubFailReason = "bitmap" | "missing" | "failed"; // whose own VTT fetch failed is remounted before the track is reported as unavailable, and // the first of the (doubling) pauses before each remount. const SUB_POLL_ERROR_MS = 10_000; +// …and after an `unavailable` answer: nothing is being read for this position right now (a memo the +// 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; const SUB_TRACK_RETRIES = 2; const SUB_TRACK_RETRY_MS = 2_000; @@ -467,14 +471,19 @@ export function usePlexTracks(opts: { (ord: number | null) => { setSubOrd(ord); // Picking a track (the same one included) is the viewer asking for it NOW: a track given up on - // after failed loads gets a fresh budget instead of repeating "couldn't be loaded" unasked. + // after failed loads gets a fresh budget instead of repeating "couldn't be loaded" unasked — + // and the server is asked again at once. Re-picking the highlighted track changes no query key, + // so without the refetch that half of "now" did nothing: a poll backing off after a failed + // request, or one waiting out an `unavailable`, kept waiting. setLoadFails({ key: "", n: 0 }); + if (ord != null && ord === subOrd) + void qc.refetchQueries({ queryKey: qk.plexSubtitle(id, ord), exact: true }); patchPrefs({ subLang: ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? ""), }); }, - [detail, patchPrefs, setSubOrd], + [detail, patchPrefs, setSubOrd, subOrd, qc, id], ); // 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off). const cycleAudio = useCallback(() => { @@ -543,10 +552,15 @@ export function usePlexTracks(opts: { // either, or one dropped request would leave "unavailable" on screen for a track that is fine. // So it backs off to SUB_POLL_ERROR_MS; the player shows the failure inline meanwhile, which is // also why the request is `quiet` (no global error dialog re-opening under the film). + // + // `unavailable` is not settled either — it is the server's "nothing for this position right now" + // (a Plex hiccup, a stretch ffmpeg could not read), which it forgets within a minute. Treated as + // final it ended the subtitles for the rest of the session, cached stretches included. refetchInterval: (q) => { if (q.state.status === "error") return SUB_POLL_ERROR_MS; 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; }, staleTime: 0, @@ -563,9 +577,14 @@ export function usePlexTracks(opts: { const subRetry = loadFails.key === loadKey ? loadFails.n : 0; const subGaveUp = subRetry > SUB_TRACK_RETRIES; // Mount the 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. + // viewer needs right now, and the poll widens it underneath them. And for an `unavailable` one that + // has cues somewhere (`gen` > 0): nothing shows at a stretch with no cues, while a seek back into a + // cached one shows them again — unmounting it blanked those too. const subShowable = - (subStatus?.state === "ready" || subStatus?.state === "partial") && !subGaveUp; + (subStatus?.state === "ready" || + subStatus?.state === "partial" || + (subStatus?.state === "unavailable" && subStatus.gen > 0)) && + !subGaveUp; // A failing poll is said even while an earlier window stays mounted: react-query keeps the last // good `data` through a failed refetch, so a `partial` answer kept the track "showable" and the // failure had nowhere to appear — blank subtitles past the window, no spinner, no message. @@ -574,7 +593,7 @@ export function usePlexTracks(opts: { ? null : subStatusQ.isError || subGaveUp ? "failed" - : subStatus?.state === "failed" + : subStatus?.state === "failed" || subStatus?.state === "unavailable" ? subFailReason(subStatus.code) : subShowable ? null