diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 258e93eb..a581a4b0 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -472,15 +472,20 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None: mf = _media_facts(meta) new_vcodec = mf.get("codec_video") 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: + # The physical file was replaced when its video codec changes OR — for a SINGLE-version item — when its + # Plex Part key changes (a re-encode / quality upgrade gets a new part id). 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. + # + # The part-key check is gated on single-version because `_media_facts` reads media[0]/part[0], and for a + # MULTI-version item (an original + a Plex "Optimized" copy) that order isn't guaranteed stable across + # listings — an unguarded part-key compare would then reset+re-probe every sync and oscillate the + # transcode_hw bucket between the two versions. A multi-version same-codec re-encode not self-healing is + # an acceptable rare edge; a per-sync re-probe loop is not. + single_version = len(meta.get("Media") or []) <= 1 + codec_changed = (row.codec_video or "").lower() != (new_vcodec or "").lower() + if codec_changed or (single_version and row.part_key != new_part_key): row.video_bit_depth = None row.media_probed_at = None row.file_path = mf.get("file_path") diff --git a/backend/tests/test_plex_playback_class.py b/backend/tests/test_plex_playback_class.py index 94d973e7..627c09c2 100644 --- a/backend/tests/test_plex_playback_class.py +++ b/backend/tests/test_plex_playback_class.py @@ -451,3 +451,45 @@ def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch): 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} + + +def test_active_playable_narrows_the_other_facets(db, monkeypatch): + # The flip side of self-exclusion: an active playable class DOES narrow the OTHER facet groups (so + # e.g. the year range reflects only the selected class). Seed a Direct movie (2000) and a + # software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000. + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all( + [ + PlexItem( + rating_key="d2000", + library_id=lib.id, + kind="movie", + title="Direct2000", + playable="direct", + year=2000, + ), + PlexItem( + rating_key="s2020", + library_id=lib.id, + kind="movie", + title="SW2020", + playable="transcode", + codec_video="mpeg4", + transcode_hw=False, + year=2020, + ), + ] + ) + user = User(email="pnarrow@example.com") + db.add(user) + db.commit() + client = _client(db, user, monkeypatch) + try: + wide = client.get("/api/plex/facets?scope=movie").json() + narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json() + finally: + app.dependency_overrides.clear() + assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range + assert (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 3d398c1b..2c2867eb 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -293,7 +293,6 @@ "playable": { "direct": "Plays directly in the browser", "remux": "Direct Stream — a light remux (no video re-encode)", - "transcode": "Needs transcoding (heavier)", "transcode_hw": "Transcoded on the hardware encoder", "transcode_sw": "Transcoded in software (heaviest)" }, diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index de5f3053..5d85c1c0 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -293,7 +293,6 @@ "playable": { "direct": "Közvetlenül játszható a böngészőben", "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)" },