fix(plex): re-probe on file replace, HU parity, public class const, playback-class test gaps
Deep-review round 1 on feature/plex-playback-class-filter: - Sticky watermark (medium): _apply_item reset the bit-depth probe watermark only on a codec-string change, so a same-codec re-encode (8-bit->10-bit HEVC) kept a stale video_bit_depth/transcode_hw forever (the backfill only re-probes media_probed_at IS NULL rows). Also reset when the Plex Part key changes (the file was replaced), not just the codec. - i18n parity (medium): the HU playback-class labels were mixed-language (only 'direct' translated). Translated remux/transcode_hw/transcode_sw and the tooltips consistently to Hungarian (Kozvetlen streameles / Atkodolas). - Layering (low): _PLAYBACK_CLASSES was module-private yet imported across into routes.plex.browse; renamed to public PLAYBACK_CLASSES (it is the shared source of truth for facet order + zero-fill). - a11y (low): the browse-card playback-class dot conveyed meaning only via a mouse-hover title; added role=img + aria-label so SR/touch users get the class. - tests: the NULL/unprobed transcode_hw -> SW fold (filter + facet), facet self-exclusion under an active playable filter, the migration codec-set == HW_DECODABLE_CODECS drift guard, the probe-batch bound + oldest-first order, the transient probe-failure retry path, and the same-codec re-encode re-probe. Backend gate green (ruff + full pytest); FE gate green (tsc/lint/knip/prettier/ vitest). NOTE: the facet-vs-runtime disagreement for 10-bit HEVC (C1) is the known merge-order constraint — this branch's runtime is codec-only; the facet becomes truthful once BUG1/tonemap's bit-depth-aware routing lands with it. Left as a sequencing constraint, not code-changed here.
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user