diff --git a/backend/app/main.py b/backend/app/main.py index a8add1d1..ca70b442 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,6 +24,7 @@ log = logging.getLogger("siftlode.app") from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from sqlalchemy.exc import DataError from starlette.middleware.sessions import SessionMiddleware from app import state @@ -109,11 +110,13 @@ async def _reap_hls_sessions() -> None: """The periodic reaper plex/stream.py's docstring promises. `reap_idle` used to run only inside the NEXT session request, so after the last playback of the day an abandoned session — its ffmpeg still working through the film, its directory a whole segmented copy of it — lived until - somebody pressed play again or the process restarted.""" + somebody pressed play again or the process restarted. + + `reap_abandoned`, not `reap_idle`: a finished session is also a paused viewer's.""" while True: await asyncio.sleep(_HLS_REAP_EVERY_S) try: - await asyncio.to_thread(plex_stream.reap_idle) + await asyncio.to_thread(plex_stream.reap_abandoned) except Exception: log.exception("HLS session reap failed") @@ -147,6 +150,21 @@ async def _validation_error(request: Request, exc: RequestValidationError) -> JS ) +@app.exception_handler(DataError) +async def _nul_in_text(request: Request, exc: DataError) -> JSONResponse: + """A NUL byte in a string the request sent is a 422, on every route at once. Postgres cannot hold + NUL in text, so the driver refuses to even send the query — and a `%00` in a query parameter or + an escaped NUL in a JSON body (a playlist's `contains=`, a reorder's key list, a collection + search) was a 500 with a traceback wherever it reached a comparison. Guarding each site did not + hold: round 3 guarded the by-rating_key lookup and missed the rest. Any other DataError is left + alone — re-raised, it stays the 500 it always was.""" + if "NUL (0x00)" not in str(exc.orig): + raise exc + return JSONResponse( + status_code=422, content={"detail": {"code": "invalid_input", "reason": "nul"}} + ) + + app.add_middleware( SessionMiddleware, secret_key=settings.secret_key, diff --git a/backend/app/plex/lookups.py b/backend/app/plex/lookups.py index 4d2e4987..ff430253 100644 --- a/backend/app/plex/lookups.py +++ b/backend/app/plex/lookups.py @@ -13,8 +13,9 @@ def by_rating_key(db: Session, model: type[_M], rating_key: str) -> _M | None: tables (PlexItem / PlexShow / PlexSeason / PlexCollection); the return type is that model, so call 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.""" + A key with a NUL in it (`%00` in a path segment) names no row — a 404, like any unknown key. + Postgres refuses NUL in text, so asking would be a DataError; the app-wide handler turns the + places that DO ask (lists and filters compared to rating_key) into a 422 (see main.py).""" 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/stream.py b/backend/app/plex/stream.py index 3a18f9d3..fdb012a7 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -820,14 +820,36 @@ def sweep_orphans(older_than: float | None = None) -> int: return dropped -def reap_idle() -> int: +# How long a session whose ffmpeg has FINISHED may go unfetched before a new session request reaps +# it (see reap_idle). +_FINISHED_IDLE_S = 30 + + +def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int: + """Kill sessions nobody has fetched from for `_SESSION_IDLE_S` — or, once their ffmpeg has + finished, for `finished_idle_s`. + + The short rule is only safe where a NEW session is being requested (the default, POST /session): + a finished session is also what a PAUSED viewer has. A remux runs far faster than realtime, so + ffmpeg is done a minute or two in and writes ENDLIST; hls.js then stops asking for the playlist, + and with its forward buffer full a paused player fetches nothing at all. The periodic reaper + therefore uses `reap_abandoned` — with the 30 s rule, a one-minute pause killed the session and + playback died ~30 s after resuming (deep-review round 4). A viewer paused for longer than + `_SESSION_IDLE_S` still loses it; the player restarts one at the playhead when a fetch 404s.""" now = time.time() dropped = 0 with _lock: for key, s in list(_sessions.items()): done = s.proc.poll() is not None - if now - s.last_access > _SESSION_IDLE_S or (done and now - s.last_access > 30): + idle = now - s.last_access + if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s): _kill(s) _sessions.pop(key, None) dropped += 1 return dropped + + +def reap_abandoned() -> int: + """The periodic reaper's pass: only what nobody has fetched from for `_SESSION_IDLE_S`, finished + or not (see reap_idle for why a finished session is not yet an abandoned one).""" + return reap_idle(finished_idle_s=_SESSION_IDLE_S) diff --git a/backend/app/plex/subtitles.py b/backend/app/plex/subtitles.py index 7948bb7b..132f476e 100644 --- a/backend/app/plex/subtitles.py +++ b/backend/app/plex/subtitles.py @@ -207,7 +207,17 @@ def read_meta(key: str) -> tuple[list[list[float]], bool]: raw = json.loads(_meta_path(key).read_text(encoding="utf-8")) return [[float(a), float(b)] for a, b in raw.get("ranges", [])], bool(raw.get("complete")) except (OSError, ValueError, TypeError): - return ([[0.0, _FULL_END_S]], True) if _vtt_path(key).exists() else ([], False) + return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False) + + +def _exists(p: Path) -> bool: + """`Path.exists` that answers False for a path the OS refuses outright: pathlib swallows only + ENOENT-like errors, so an over-long name (ENAMETOOLONG, from an absurd rating_key or ordinal) + escaped as a 500 from the status route.""" + try: + return p.exists() + except (OSError, ValueError): + return False def _write_meta(key: str, ranges: list[list[float]], complete: bool) -> None: @@ -305,8 +315,11 @@ def _last_cue_end(vtt: str) -> float: return end -def publish(key: str, vtt: str, covered: tuple[float, float], complete: bool) -> None: - """Merge a freshly extracted region into the cached track and widen its covered ranges. +def publish(key: str, vtt: str, covered: tuple[float, float], complete: bool) -> bool: + """Merge a freshly extracted region into the cached track and widen its covered ranges. False + when the region did not land (the cache is unwritable, or the VTT there cannot be read) — the + caller must then record a FAILED pass: returning quietly let the next poll schedule the same read + again, a whole-container pass every time, while the player said "preparing" for good. A read-merge-write, so it runs under `_PUBLISH_LOCK`: two jobs publish into the same keys (a window and the full pass, and every embedded track of the file rides each of them), and without @@ -319,7 +332,7 @@ def publish(key: str, vtt: str, covered: tuple[float, float], complete: bool) -> # 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 + return False old_ranges, was_complete = read_meta(key) if old is None: # A sidecar with no VTT behind it claims cues that are not there, so it is not merged @@ -330,8 +343,10 @@ def publish(key: str, vtt: str, covered: tuple[float, float], complete: bool) -> 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 + log.warning("subtitle cache %s could not be written", key) + return False # the region never landed, so the sidecar must not claim it _write_meta(key, merge_ranges(old_ranges + [list(covered)]), complete or was_complete) + return True # --- background extraction ---------------------------------------------------------------------- @@ -531,6 +546,12 @@ def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict: gen = generation(key) if complete and have: return {"state": "ready", "code": None, "gen": gen} + if not have and _exists(_vtt_path(key)): + # On disk but unreadable (permissions, a bad volume): nothing can serve it, and no extraction + # can help — `publish` refuses to merge into it, and a job whose sidecar covers the position + # skips as already done — so scheduling only kept the player on "preparing" for good. `gen` 0: + # there is nothing a could fetch. + return {"state": "unavailable", "code": "cache_unreadable", "gen": 0} code = _recent_failure(key) if code and not covered: return { @@ -616,7 +637,7 @@ def _already_done(key: str, window_start: float | None, at: float) -> bool: """A queued job looks again before it reads anything. While it waited, another job may have covered it: every embedded track rides each pass over its file, so a window for one language also publishes the others.""" - if not _vtt_path(key).exists(): + if not _exists(_vtt_path(key)): return False ranges, complete = read_meta(key) return complete or (window_start is not None and covers(ranges, at)) @@ -696,7 +717,8 @@ def _extract_and_publish(rating_key: str, sub_ord: int, window_start: float | No # identity is per ITEM (the embedded fan-out shares it), so a sidecar picked while an # embedded track's full pass was running sat behind that pass for minutes. vtt = _sidecar_vtt(db, want) - publish(cache_key(rating_key, sub_ord), vtt, (0.0, _FULL_END_S), True) + if not publish(cache_key(rating_key, sub_ord), vtt, (0.0, _FULL_END_S), True): + raise SubtitleUnavailable("extract_failed") return if want.get("index") is None: raise SubtitleUnavailable("not_fetchable") @@ -721,13 +743,16 @@ def _extract_and_publish(rating_key: str, sub_ord: int, window_start: float | No # the track read "ready" for good while its subtitles stopped wherever ffmpeg did (the cache # has no eviction, so nothing would ever have extracted it again). covered_end = end if clean else start + _last_cue_end(vtt) - publish( + landed = publish( key, shift_vtt(vtt, -start), # zero-based window clock → absolute cue times (start, covered_end), clean and length is None, ) - if not clean: + if not landed: + # A read that could not be kept is a failed read (see publish) — backed off like one. + _pass_failed(rating_key, [key], window_start, (start, end)) + elif not clean: _pass_failed(rating_key, [key], window_start, (covered_end, end)) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index f2c28603..85ef7a11 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -26,7 +26,7 @@ from app.models import ( User, Video, ) -from app.routes._common import owned_or_404 +from app.routes._common import get_or_404, owned_or_404 from app.sync.explore import explore_ingest_page from app.sync.runner import backfill_channel_now, run_recent_backfill from app.sync.subscriptions import apply_channel_details @@ -663,8 +663,7 @@ def block_channel( """Block a channel for this user: its videos won't appear in their live search (and won't be ingested for them) and are hidden from their feed / explore. Idempotent. The now-hidden un-kept search/explore videos are reclaimed by the discovery-cleanup job in due course.""" - if db.get(Channel, channel_id) is None: - raise HTTPException(status_code=404, detail="Unknown channel") + get_or_404(db, Channel, channel_id, what="channel") if not _is_blocked(db, user, channel_id): db.add(BlockedChannel(user_id=user.id, channel_id=channel_id)) # Stop any active exploration of it so it can be cleaned up. diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index a6dbb662..417d7c72 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -38,6 +38,7 @@ from app.models import ( MediaAsset, User, ) +from app.routes._common import get_or_404 from app.security import hash_password from app.userscope import is_messageable_user, messageable_clauses from app.utils import iso @@ -1091,8 +1092,7 @@ def admin_set_quota( admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: - if db.get(User, user_id) is None: - raise HTTPException(status_code=404, detail="Unknown user") + get_or_404(db, User, user_id, what="user") cur = quota.resolve(db, user_id) row = db.get(DownloadQuota, user_id) if row is None: diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index a1a8a8c3..e037295b 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -29,6 +29,7 @@ from app.models import ( Video, VideoState, ) +from app.routes._common import get_or_404 from app.sync.videos import parse_iso8601_duration from app.utils import iso from app.youtube.client import YouTubeClient, YouTubeError @@ -235,8 +236,7 @@ def set_video_state( status = body.status if status not in VALID_STATES: raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}") - if db.get(Video, video_id) is None: - raise HTTPException(status_code=404, detail="Unknown video") + get_or_404(db, Video, video_id, what="video") now = datetime.now(timezone.utc) @@ -332,8 +332,7 @@ def set_video_progress( if position < 0: position = 0 - if db.get(Video, video_id) is None: - raise HTTPException(status_code=404, detail="Unknown video") + get_or_404(db, Video, video_id, what="video") # Decide whether this position is worth keeping (else clear it). near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index a7083c95..c7617c50 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -23,7 +23,7 @@ from app.sync.playlists import ( sync_user_playlists, ) from app.ratelimit import RateLimiter -from app.routes._common import not_found, owned_or_404 +from app.routes._common import get_or_404, owned_or_404 from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api/playlists", tags=["playlists"]) @@ -292,8 +292,7 @@ def add_watch_later( ) -> dict: """Bookmark shortcut: add a video to the built-in Watch later playlist.""" video_id = body.video_id - if db.get(Video, video_id) is None: - raise not_found("video") + get_or_404(db, Video, video_id, what="video") pl = _watch_later(db, user) _add_item(db, pl, video_id, mark_dirty=False) return {"saved": True, "playlist_id": pl.id} @@ -513,8 +512,7 @@ def add_item( ) -> dict: pl = _own_playlist(db, user, playlist_id) video_id = body.video_id - if db.get(Video, video_id) is None: - raise not_found("video") + get_or_404(db, Video, video_id, what="video") _add_item(db, pl, video_id, mark_dirty=True) return {"playlist_id": pl.id, "video_id": video_id} diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index 248855a3..3420b1d0 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -10,6 +10,7 @@ from fastapi import ( BackgroundTasks, Depends, HTTPException, + Path, Query, Request, Response, @@ -95,12 +96,19 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response # poster, not for a route that starts reads of the media library: a suspended or "log out # everywhere"-revoked cookie kept scheduling them. They are not bursty (one poll a second, one fetch # per track), so the DB user-load costs nothing that matters. +# +# Both path parameters are bounded, the same way on both routes: they become a cache FILE name +# (`sub__`), and an absurd one — a 250-digit key or ordinal — outgrew the OS's name +# limit and turned the status poll into a 500. A Plex rating_key is a short number; the bound only +# has to be generous. +_RatingKey = Path(max_length=32) +_SubOrd = Path(ge=0, le=9999) @router.get("/subtitle/{rating_key}/{sub_ord}/status") def subtitle_status( - rating_key: str, - sub_ord: int, + rating_key: str = _RatingKey, + sub_ord: int = _SubOrd, user: User = Depends(current_user), # Bounded both ways: a non-finite position used to reach the scheduler's arithmetic and 500, and # an absurd one reached ffmpeg's `-ss`, which refused it. @@ -117,8 +125,8 @@ def subtitle_status( @router.get("/subtitle/{rating_key}/{sub_ord}") def subtitle( - rating_key: str, - sub_ord: int, + rating_key: str = _RatingKey, + sub_ord: int = _SubOrd, user: User = Depends(current_user), # Finite, like every float this router takes: `-inf` crashed the shift (a 500), `nan` stacked # every cue at 0. It can legitimately be negative (a subtitle nudged later than the session start). diff --git a/backend/tests/test_hls_reaper.py b/backend/tests/test_hls_reaper.py index 51521ed5..0c8d22ea 100644 --- a/backend/tests/test_hls_reaper.py +++ b/backend/tests/test_hls_reaper.py @@ -3,12 +3,20 @@ plex/stream.py promises "a periodic reaper kills idle ones", but `reap_idle` ran only inside POST /session: after the last playback, an abandoned session (its ffmpeg still working through the film, its directory a whole segmented copy of it) lived until somebody pressed play again. + +And it must not take a PAUSED viewer's session: a remux finishes long before the film does, and a +paused player with a full buffer fetches nothing — so "finished and unfetched for 30 s" (the rule a +new session request applies) is not "abandoned". """ import asyncio +import time + +import pytest import app.main as app_main from app.main import app +from app.plex import stream class _Configured: @@ -31,7 +39,7 @@ def test_idle_sessions_are_reaped_while_nobody_starts_a_new_one(monkeypatch): monkeypatch.setattr(app_main, "shutdown_scheduler", lambda: None) monkeypatch.setattr(app_main.plex_subtitles, "shutdown", lambda: None) monkeypatch.setattr(app_main, "_HLS_REAP_EVERY_S", 0.01) - monkeypatch.setattr(app_main.plex_stream, "reap_idle", lambda: reaped.append(1) or 0) + monkeypatch.setattr(app_main.plex_stream, "reap_abandoned", lambda: reaped.append(1) or 0) async def _cycle(): async with app_main.lifespan(app): @@ -39,3 +47,60 @@ def test_idle_sessions_are_reaped_while_nobody_starts_a_new_one(monkeypatch): asyncio.run(_cycle()) assert len(reaped) >= 2, "no periodic reap ran while the app was up" + + +class _Proc: + def __init__(self, finished: bool): + self.finished = finished + self.killed = False + + def poll(self): + return 0 if self.finished else None + + def terminate(self): + self.killed = True + + def wait(self, timeout=None): + return 0 + + +class _Session: + def __init__(self, tmp_path, name: str, finished: bool, idle_s: float): + self.dir = tmp_path / name + self.dir.mkdir() + self.proc = _Proc(finished) + self.last_access = time.time() - idle_s + + +@pytest.fixture +def sessions(monkeypatch): + live: dict = {} + monkeypatch.setattr(stream, "_sessions", live) + return live + + +def test_the_periodic_pass_keeps_a_paused_viewers_finished_session(tmp_path, sessions): + # A remux that finished a minute ago, its viewer paused with a full buffer since. + s = _Session(tmp_path, "paused", finished=True, idle_s=90) + sessions["12345"] = s + assert stream.reap_abandoned() == 0 + assert "12345" in sessions and s.dir.exists() and not s.proc.killed + + +def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limit( + tmp_path, sessions +): + sessions["1"] = _Session( + tmp_path, "gone_done", finished=True, idle_s=stream._SESSION_IDLE_S + 5 + ) + sessions["2"] = _Session( + tmp_path, "gone_live", finished=False, idle_s=stream._SESSION_IDLE_S + 5 + ) + assert stream.reap_abandoned() == 2 + assert sessions == {} + + +def test_a_new_session_request_still_reaps_finished_sessions_quickly(tmp_path, sessions): + sessions["12345"] = _Session(tmp_path, "done", finished=True, idle_s=45) + assert stream.reap_idle() == 1 + assert sessions == {} diff --git a/backend/tests/test_not_found_detail.py b/backend/tests/test_not_found_detail.py index c5eb78a2..b3616189 100644 --- a/backend/tests/test_not_found_detail.py +++ b/backend/tests/test_not_found_detail.py @@ -18,3 +18,36 @@ def test_a_gone_view_tag_or_pin_says_what_is_gone(db, make_user, as_user): r = getattr(client, method)(url, **({"json": {"name": "x"}} if method == "patch" else {})) assert r.status_code == 404, (url, r.text) assert r.json()["detail"] == {"code": "not_found", "reason": what}, url + + +def test_a_gone_channel_video_or_user_says_what_is_gone(db, make_user, as_user): + """The siblings that still hand-wrote `db.get(...) is None → 404 "Unknown …"` (deep-review + round 4): the same gone row, but the Hungarian UI showed the English.""" + admin = make_user(role="admin") + client = as_user(admin) + for method, url, body, what in [ + ("post", "/api/channels/UCgone000000000000000000/block", None, "channel"), + ("post", "/api/videos/gone0000000/state", {"status": "watched"}, "video"), + ( + "post", + "/api/videos/gone0000000/progress", + {"position_seconds": 60, "duration_seconds": 600}, + "video", + ), + ("put", "/api/admin/downloads/quota/999999", {"max_bytes": 1}, "user"), + ]: + r = getattr(client, method)(url, **({"json": body} if body is not None else {})) + assert r.status_code == 404, (url, r.text) + assert r.json()["detail"] == {"code": "not_found", "reason": what}, url + + +def test_a_nul_in_a_request_string_is_a_422_not_a_500(db, make_user, as_user): + """Postgres cannot hold NUL in text, so the driver refuses the query outright: a DataError, i.e. + a 500 with a traceback, wherever a `%00` or an escaped NUL reached a comparison (deep-review + round 4 — round 3 had guarded only the by-rating_key lookup). One app-wide handler now answers + all of them.""" + client = as_user(make_user()) + r = client.get("/api/plex/playlists?contains=%00") + assert r.status_code == 422, r.text + assert r.json()["detail"] == {"code": "invalid_input", "reason": "nul"} + assert client.get("/api/plex/playlists?contains_group=1,%00").status_code == 422 diff --git a/backend/tests/test_plex_subtitle_prepare.py b/backend/tests/test_plex_subtitle_prepare.py index 0cd0c9b4..53085c17 100644 --- a/backend/tests/test_plex_subtitle_prepare.py +++ b/backend/tests/test_plex_subtitle_prepare.py @@ -1002,3 +1002,75 @@ def test_a_nul_rating_key_names_no_row(): from app.plex.lookups import by_rating_key assert by_rating_key(None, PlexItem, "12\x0034") is None # type: ignore[arg-type] + + +# --- the fourth deep-review round --------------------------------------------------------------- + + +def test_a_pass_whose_output_could_not_be_kept_is_backed_off_like_a_failed_one(monkeypatch): + """`publish` refusing (a full or read-only cache volume) returned quietly, so `_run` recorded + nothing and every 1 s poll queued the window AND the whole-container pass again — the file read + end to end over and over, the player on "preparing" for good.""" + _stub_item(monkeypatch) + pool = _manual_pool(monkeypatch) + monkeypatch.setattr(subtitles, "cache_write", lambda key, vtt: False) + assert subtitles.ensure("98614", 3, at=600.0)["state"] == "preparing" + assert [a[3] for _, a in pool.jobs] == [570.0, None] + pool.run_all() + after = subtitles.ensure("98614", 3, at=600.0) + assert (after["state"], after["code"]) == ("unavailable", "extract_failed") + for _ in range(3): + subtitles.ensure("98614", 3, at=600.0) + assert pool.jobs == [], "the same reads were queued again" + + +def test_a_sidecar_that_could_not_be_kept_is_recorded_as_failed(monkeypatch): + _stub_item(monkeypatch) + pool = _manual_pool(monkeypatch) + monkeypatch.setattr(subtitles, "cache_write", lambda key, vtt: False) + subtitles.ensure("98614", 0, at=600.0) + pool.run_all() + subtitles.ensure("98614", 0, at=600.0) + assert pool.jobs == [], "a refused sidecar publish was queued again" + + +def test_an_unreadable_cached_track_is_said_once_and_not_rescheduled(monkeypatch): + """A VTT on disk that cannot be read: `publish` will not merge into it and a job whose sidecar + covers the position skips as done, so scheduling could only ever leave the player on + "preparing".""" + pool = _manual_pool(monkeypatch) + key = subtitles.cache_key("98614", 0) + subtitles.publish(key, _vtt(_cue("16:20.000", "16:22.000", "x")), (970.0, 1300.0), False) + monkeypatch.setattr(subtitles, "cache_read", lambda k: None) # an I/O error on the read + st = subtitles.ensure("98614", 0, at=1000.0) + assert (st["state"], st["code"], st["gen"]) == ("unavailable", "cache_unreadable", 0) + assert pool.jobs == [] + + +def test_an_absurd_rating_key_or_ordinal_is_refused_not_a_500(client, monkeypatch): + """They become a cache FILE name: a 250-digit key outgrew the OS name limit, and pathlib's + `exists()` re-raises that (ENAMETOOLONG) instead of answering False — a 500 on every poll.""" + pool = _manual_pool(monkeypatch) + long_key = "9" * 250 + for url in ( + f"/api/plex/subtitle/{long_key}/0/status", + f"/api/plex/subtitle/98614/{long_key}/status", + f"/api/plex/subtitle/{long_key}/0", + ): + assert client.get(url).status_code == 422, url + assert pool.jobs == [] + # …and the cache helpers themselves no longer throw on such a name. + assert subtitles.read_meta(subtitles.cache_key(long_key, 0)) == ([], False) + + +def test_a_read_ahead_window_that_failed_is_not_queued_again(monkeypatch): + """Near a covered edge the probe moves just past it, and `_schedule` has no failed-span check of + its own: without the guard, every poll re-queued the same failing window 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) + subtitles._window_fail(key, (1271.0, 1601.0)) + for at in (1250.0, 1252.0, 1254.0): + assert subtitles.ensure("98614", 0, at=at)["state"] == "partial" + assert pool.jobs == [], "the failed read-ahead window was queued again" diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index 2d1b8388..64c6d201 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -13,7 +13,8 @@ "move_target": "The playlist you're moving it to doesn't exist any more.", "video": "This video isn't in the library any more.", "invite": "This invite doesn't exist any more.", - "user": "This user doesn't exist any more." + "user": "This user doesn't exist any more.", + "channel": "This channel isn't in the library any more." }, "ok": "OK", "offline": { diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index 7fb3e5c8..14b0d867 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -13,7 +13,8 @@ "move_target": "A lejátszási lista, ahová áthelyeznéd, már nem létezik.", "video": "Ez a videó már nincs a könyvtárban.", "invite": "Ez a meghívó már nem létezik.", - "user": "Ez a felhasználó már nem létezik." + "user": "Ez a felhasználó már nem létezik.", + "channel": "Ez a csatorna már nincs a könyvtárban." }, "ok": "OK", "offline": {