diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index f48c4d8d..19f21885 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -212,7 +212,8 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"): # The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`. # ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds` # filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software. -_PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw") +# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill). +PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw") def playback_class_expr(): @@ -230,7 +231,7 @@ def playback_class_expr(): def playable_conds(sel: list[str]) -> list: """A one-element condition list (or empty) selecting the given playback classes — an OR via `class IN (...)` over the shared CASE. Unknown keys are dropped.""" - keep = [c for c in sel if c in _PLAYBACK_CLASSES] + keep = [c for c in sel if c in PLAYBACK_CLASSES] return [playback_class_expr().in_(keep)] if keep else [] diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 38a6a92c..258e93eb 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -471,13 +471,20 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None: _apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows) mf = _media_facts(meta) new_vcodec = mf.get("codec_video") - # A changed video codec means the physical file was replaced — any probed bit-depth is stale, so - # reset the probe watermark and re-derive transcode_hw from codec alone until the backfill re-probes. - if (row.codec_video or "").lower() != (new_vcodec or "").lower(): + new_part_key = mf.get("part_key") + # The physical file was replaced when its Plex Part key changes (a re-encode / quality upgrade gets a + # new part id) OR — a coarser signal — when the video codec changes. Either way any probed bit-depth is + # stale, so reset the probe watermark and re-derive transcode_hw from codec alone until the backfill + # re-probes. Codec alone is NOT enough: a same-codec bit-depth re-encode (8-bit→10-bit HEVC) keeps the + # codec `hevc` but flips the HW class, and only the part-key move catches it. A spurious reset (a rare + # part-id reassignment on an unchanged file) costs at most one extra bounded re-probe — safe direction. + if (row.codec_video or "").lower() != ( + new_vcodec or "" + ).lower() or row.part_key != new_part_key: row.video_bit_depth = None row.media_probed_at = None row.file_path = mf.get("file_path") - row.part_key = mf.get("part_key") + row.part_key = new_part_key row.container = mf.get("container") row.codec_video = new_vcodec row.codec_audio = mf.get("codec_audio") diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 3ddacc0b..0ff9dff8 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -27,8 +27,8 @@ from app.models import ( from app.plex import rollup from app.plex.cards import episode_card from app.plex.query import ( - _PLAYBACK_CLASSES, FIELD_COLUMNS, + PLAYBACK_CLASSES, TS_CONFIG, LibraryFilters, apply_meta_filters, @@ -511,7 +511,7 @@ def facets( # Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole # section when its facet list is empty, which would trap a zero-result selection with no way to undo). for c in split_csv(f.playable): - if c in _PLAYBACK_CLASSES: + if c in PLAYBACK_CLASSES: playable_counts.setdefault(c, 0) return { "genres": [ @@ -527,7 +527,7 @@ def facets( # Fixed good→heavy order (not by count), so the class list reads as a severity scale. "playable": [ {"value": c, "count": playable_counts[c]} - for c in _PLAYBACK_CLASSES + for c in PLAYBACK_CLASSES if c in playable_counts ], "year_min": min(years) if years else None, diff --git a/backend/tests/test_plex_playback_class.py b/backend/tests/test_plex_playback_class.py index 9df9af2c..94d973e7 100644 --- a/backend/tests/test_plex_playback_class.py +++ b/backend/tests/test_plex_playback_class.py @@ -7,6 +7,9 @@ backfill). These tests pin that a 10-bit/HDR HEVC lands in SW even though its co the exact case the runtime VAAPI path (plex.stream) also special-cases. """ +import re +from pathlib import Path + import app.state as state from app.auth import current_user from app.db import get_db @@ -70,8 +73,11 @@ class TestSyncDerivation: """`_apply_item` sets transcode_hw codec-only at sync (bit depth unknown from the listing), and a changed video codec resets the probe watermark so a stale bit-depth can't linger.""" - def _meta(self, vcodec): - return {"title": "X", "Media": [{"videoCodec": vcodec, "Part": [{"file": "/m.mkv"}]}]} + def _meta(self, vcodec, *, key="/parts/1/file.mkv", file="/m.mkv"): + return { + "title": "X", + "Media": [{"videoCodec": vcodec, "Part": [{"file": file, "key": key}]}], + } def test_codec_only_derivation_at_sync(self): row = PlexItem(rating_key="x", kind="movie") @@ -102,6 +108,37 @@ class TestSyncDerivation: sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("h264")) assert row.video_bit_depth is None and row.media_probed_at is None + def test_same_codec_reencode_with_a_new_part_resets_the_probe(self): + # A same-codec bit-depth re-encode (e.g. 8-bit→10-bit HEVC) keeps codec 'hevc' but replaces the + # physical file, so its Plex Part key changes. Codec-only reset would miss this and keep the stale + # 8-bit/HW verdict forever (the backfill only re-probes media_probed_at IS NULL rows) — the + # part-key move must reset the watermark so the new bit depth is re-probed. + row = PlexItem(rating_key="x", kind="movie") + sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/1/f.mkv")) + row.transcode_hw = True + row.video_bit_depth = 8 + row.media_probed_at = sync.datetime.now(sync.timezone.utc) + # Re-encoded in place: same codec 'hevc', but a new Part key (new file). + sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/2/f.mkv")) + assert row.video_bit_depth is None and row.media_probed_at is None # re-probe scheduled + + +def test_migration_codec_set_matches_hw_decodable_codecs(): + # Migration 0077 hardcodes the HW-decodable codec set in raw SQL and its comment mandates it stay + # identical to plex.sync.HW_DECODABLE_CODECS. A comment isn't enforcement (house rule) — pin the + # equality so adding/removing a codec from the frozenset without editing the migration fails here + # (else already-migrated prod rows keep a wrong transcode_hw the runtime derivation wouldn't catch). + mig = ( + Path(__file__).resolve().parent.parent + / "alembic" + / "versions" + / "0077_plex_playback_class.py" + ) + m = re.search(r"lower\(codec_video\)\s+IN\s+\(([^)]*)\)", mig.read_text()) + assert m, "could not find the codec IN(...) list in migration 0077" + codecs = {c.strip().strip("'\"").lower() for c in m.group(1).split(",")} + assert codecs == {c.lower() for c in sync.HW_DECODABLE_CODECS} + class _FakePlex: """Stands in for PlexClient.metadata — returns a canned full-metadata dict per rating_key.""" @@ -188,6 +225,93 @@ def test_probe_stamps_even_when_plex_has_no_stream(db): assert item.transcode_hw is True +def test_probe_media_batch_is_bounded_and_oldest_first(db): + # The per-run bound (limit) caps Plex load; oldest-id-first means successive runs cover the whole + # backlog without starving any item. Seed 3 eligible items, probe with limit=2. + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + items = [ + PlexItem( + rating_key=f"h{i}", + library_id=lib.id, + kind="movie", + title=f"m{i}", + playable="transcode", + codec_video="hevc", + transcode_hw=True, + ) + for i in range(3) + ] + db.add_all(items) # inserted in order → ascending ids + db.commit() + plex = _FakePlex( + { + f"h{i}": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]} + for i in range(3) + } + ) + assert sync._probe_media_batch(db, plex, limit=2) == 2 # exactly the bound + for it in items: + db.refresh(it) + # The two LOWEST-id rows were probed; the third waits for the next run. + assert items[0].media_probed_at is not None and items[1].media_probed_at is not None + assert items[2].media_probed_at is None + # The next run continues with the remaining item. + assert sync._probe_media_batch(db, plex, limit=2) == 1 + db.refresh(items[2]) + assert items[2].media_probed_at is not None + + +class _RaisingPlex: + """metadata() raises for `bad_key`, returns a canned dict otherwise — to exercise the transient + fetch-failure branch (leave the watermark NULL so the item retries next run).""" + + def __init__(self, by_key, bad_key): + self.by_key = by_key + self.bad_key = bad_key + + def metadata(self, rating_key, markers=False): + if rating_key == self.bad_key: + raise RuntimeError("plex timeout") + return self.by_key.get(rating_key) + + +def test_probe_media_batch_leaves_a_transient_failure_unstamped_to_retry(db): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + good = PlexItem( + rating_key="g", + library_id=lib.id, + kind="movie", + title="good", + playable="transcode", + codec_video="hevc", + transcode_hw=True, + ) + bad = PlexItem( + rating_key="b", + library_id=lib.id, + kind="movie", + title="bad", + playable="transcode", + codec_video="hevc", + transcode_hw=True, + ) + db.add_all([good, bad]) + db.commit() + plex = _RaisingPlex( + {"g": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]}}, bad_key="b" + ) + # The raise on 'b' must not abort the batch: 'g' is still probed and counted; 'b' stays NULL. + assert sync._probe_media_batch(db, plex) == 1 + db.refresh(good) + db.refresh(bad) + assert good.media_probed_at is not None and good.transcode_hw is False # 10-bit → SW + assert bad.media_probed_at is None # transient failure → retried next run, not stamped + + def _seed_classes(db): lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) @@ -274,3 +398,56 @@ def test_facets_count_playback_classes_in_severity_order(db, monkeypatch): "transcode_hw": 1, "transcode_sw": 1, } + + +def test_unprobed_transcode_folds_into_sw(db, monkeypatch): + # transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's + # CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with + # software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the + # item from the feed (an honest-state violation). + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add( + PlexItem( + rating_key="tnull", + library_id=lib.id, + kind="movie", + title="Unprobed", + playable="transcode", + codec_video="hevc", + transcode_hw=None, + ) + ) + user = User(email="pnull@example.com") + db.add(user) + db.commit() + client = _client(db, user, monkeypatch) + try: + sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw") + assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket + hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw") + assert hw.json()["items"] == [] # never HW + facets = client.get("/api/plex/facets?scope=movie") + counts = {p["value"]: p["count"] for p in facets.json()["playable"]} + assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0 + finally: + app.dependency_overrides.clear() + + +def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch): + # A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion), + # otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting + # 'direct' must still return all four classes at their full, unnarrowed counts. + _seed_classes(db) + user = User(email="pself@example.com") + db.add(user) + db.commit() + client = _client(db, user, monkeypatch) + try: + resp = client.get("/api/plex/facets?scope=movie&playable=direct") + finally: + app.dependency_overrides.clear() + assert resp.status_code == 200, resp.text + counts = {p["value"]: p["count"] for p in resp.json()["playable"]} + assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1} diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index c7350f0c..7608fe2e 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -100,6 +100,8 @@ export function PlexPosterCard({ const cls = playableClass(card.playable, card.transcode_hw); return ( diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index e629cc3b..de5f3053 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -107,9 +107,9 @@ "playable": "Lejátszás", "playableOpt": { "direct": "Közvetlen lejátszás", - "remux": "Direct Stream", - "transcode_hw": "Transcode · HW", - "transcode_sw": "Transcode · SW" + "remux": "Közvetlen streamelés", + "transcode_hw": "Átkódolás · HW", + "transcode_sw": "Átkódolás · SW" }, "moreRefine": "még {{count}} — finomíts kereséssel" }, @@ -292,10 +292,10 @@ }, "playable": { "direct": "Közvetlenül játszható a böngészőben", - "remux": "Direct Stream — könnyű remux (nincs videó-újrakódolás)", - "transcode": "Transcode kell (nehezebb)", - "transcode_hw": "Hardveres kódolóval transcode-olva", - "transcode_sw": "Szoftveresen transcode-olva (legnehezebb)" + "remux": "Közvetlen streamelés — könnyű remux (nincs videó-újrakódolás)", + "transcode": "Átkódolás szükséges (nehezebb)", + "transcode_hw": "Hardveres kódolóval átkódolva", + "transcode_sw": "Szoftveresen átkódolva (legnehezebb)" }, "playlistAdd": { "manage": "Listához adás",