fix(plex): address S5a review — overlay reconcile, dup-guid, guid-wipe, tests

- Correctness (stale HU): _sync_overlay is now a full reconcile — it clears every guid-bearing EN
  row's *_hu first, then repopulates the matches, so a title whose HU twin vanished or was re-guid'd
  falls back to EN instead of keeping stale Hungarian metadata (only rows that held HU data go dirty).
- Correctness (dup guid): by_guid maps a guid to the LIST of EN rows carrying it, so two editions of
  one film both receive the overlay instead of just the last-iterated one.
- Robustness (guid wipe): _apply_item/_sync_shows keep a prior guid when a listing carries none
  (`_canonical_guid(meta) or row.guid`), so a server ignoring includeGuids can't erase the match key.
- Reuse: merged _sync_overlay's movie/show branches (only the select + item_type differ).
- Test-coverage: a full-sync test drives sync() over a stub Plex (sections + section_items +
  overlay_map) asserting pass-2 designation (metadata_lang/overlay_of), guid capture, the HU write,
  and browse exclusion; plus a reconcile test that a vanished twin clears stale *_hu.

Full backend suite 274 passed / 2 skipped.
This commit is contained in:
2026-08-16 22:31:08 +02:00
parent c959859d29
commit 704c6c35d4
2 changed files with 128 additions and 28 deletions
+40 -28
View File
@@ -311,7 +311,9 @@ def _apply_facet_fields(row, meta: dict) -> None:
def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None: def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
row.library_id = lib_id row.library_id = lib_id
row.kind = kind row.kind = kind
row.guid = _canonical_guid(meta) # EN<->HU pairing key (movies; harmless/unused on episodes) # EN<->HU pairing key (movies; harmless/unused on episodes). Keep the prior guid if this listing
# carries none (a server ignoring includeGuids mustn't wipe the overlay's only match key).
row.guid = _canonical_guid(meta) or row.guid
row.title = (meta.get("title") or "").strip() or "Untitled" row.title = (meta.get("title") or "").strip() or "Untitled"
row.summary = meta.get("summary") row.summary = meta.get("summary")
row.year = meta.get("year") row.year = meta.get("year")
@@ -366,7 +368,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
db.add(sh) db.add(sh)
shows[rk] = sh shows[rk] = sh
sh.library_id = lib.id sh.library_id = lib.id
sh.guid = _canonical_guid(meta) # EN<->HU pairing key sh.guid = _canonical_guid(meta) or sh.guid # EN<->HU pairing key; keep prior if listing has none
sh.title = (meta.get("title") or "").strip() or "Untitled" sh.title = (meta.get("title") or "").strip() or "Untitled"
sh.summary = meta.get("summary") sh.summary = meta.get("summary")
sh.year = meta.get("year") sh.year = meta.get("year")
@@ -430,39 +432,49 @@ def _sync_overlay(
db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict
) -> None: ) -> None:
"""Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates """Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates
NO browsable rows — an overlay library is never listed on its own. EN rows with no HU match keep NO browsable rows — an overlay library is never listed on its own. Only title/summary/genres differ
their *_hu NULL (serving falls back to EN); HU items with no EN match are skipped. Only title/ by language; the language-neutral people_text feeds both search vectors.
summary/genres differ by language; the language-neutral people_text feeds both search vectors."""
This is a full RECONCILE: every guid-bearing EN row's HU fields are cleared first, then the HU
section repopulates the matches — so an EN row whose HU twin vanished (or was re-guid'd) falls back
to EN instead of keeping stale Hungarian metadata. HU items with no EN match are skipped."""
if lib.kind == "movie": if lib.kind == "movie":
by_guid = { rows = db.execute(
r.guid: r select(PlexItem).where(
for r in db.execute( PlexItem.library_id == en_lib_id,
select(PlexItem).where( PlexItem.kind == "movie",
PlexItem.library_id == en_lib_id, PlexItem.guid.is_not(None),
PlexItem.kind == "movie", )
PlexItem.guid.is_not(None), ).scalars()
)
).scalars()
}
item_type = _TYPE_MOVIE item_type = _TYPE_MOVIE
else: else:
by_guid = { rows = db.execute(
r.guid: r select(PlexShow).where(
for r in db.execute( PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None)
select(PlexShow).where( )
PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None) ).scalars()
)
).scalars()
}
item_type = _TYPE_SHOW item_type = _TYPE_SHOW
# guid -> the EN rows carrying it (a list: two editions of one film can share a guid; both overlay).
by_guid: dict[str, list] = {}
for r in rows:
by_guid.setdefault(r.guid, []).append(r)
# Reconcile step: clear HU fields on all guid-bearing rows. Only rows that actually held HU data
# become dirty (SQLAlchemy skips a no-net-change UPDATE), so this is cheap; matches are re-set below.
for group in by_guid.values():
for row in group:
row.title_hu = row.summary_hu = row.genres_hu = None
for meta in _paginate(plex, lib.plex_key, item_type): for meta in _paginate(plex, lib.plex_key, item_type):
g = _canonical_guid(meta) g = _canonical_guid(meta)
row = by_guid.get(g) if g else None targets = by_guid.get(g) if g else None
if row is None: if not targets:
continue continue
row.title_hu = (meta.get("title") or "").strip() or None title_hu = (meta.get("title") or "").strip() or None
row.summary_hu = meta.get("summary") summary_hu = meta.get("summary")
row.genres_hu = _tags(meta, "Genre") genres_hu = _tags(meta, "Genre")
for row in targets:
row.title_hu = title_hu
row.summary_hu = summary_hu
row.genres_hu = genres_hu
stats["overlaid"] = stats.get("overlaid", 0) + 1 stats["overlaid"] = stats.get("overlaid", 0) + 1
db.flush() db.flush()
+88
View File
@@ -7,6 +7,8 @@ Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. Runs
""" """
from sqlalchemy import func, select from sqlalchemy import func, select
import app.plex.sync as syncmod
from app import sysconfig
from app.models import PlexItem, PlexLibrary from app.models import PlexItem, PlexLibrary
from app.plex.sync import _canonical_guid, _sync_overlay from app.plex.sync import _canonical_guid, _sync_overlay
from app.routes.plex.browse import _content_libs from app.routes.plex.browse import _content_libs
@@ -82,6 +84,92 @@ def test_sync_overlay_writes_hu_onto_matched_en_rows(db):
assert en_row_count == 2 assert en_row_count == 2
class _StubClient:
"""Full-sync stand-in: one EN movie section (key 4) + its HU twin (key 9), paired by guid."""
def __init__(self, db=None):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def sections(self):
return [
{"key": "4", "title": "Movies", "type": "movie"},
{"key": "9", "title": "Filmek", "type": "movie"},
]
def section_items(self, key, item_type, start, size):
if item_type != syncmod._TYPE_MOVIE:
return [], 0
if key == "4":
items = [{"ratingKey": "en1", "title": "Heat", "Guid": [{"id": "tmdb://1"}]}]
elif key == "9":
items = [
{
"ratingKey": "hu1", "title": "Öldöklés", "summary": "Rablás.",
"Genre": [{"tag": "Akció"}], "Guid": [{"id": "tmdb://1"}],
}
]
else:
items = []
return items[start : start + size], len(items)
def collections(self, key):
return []
def metadata(self, rk, markers=False):
return {}
def test_sync_designates_overlay_and_writes_hu(db, monkeypatch):
sysconfig.set_value(db, "plex_enabled", True)
sysconfig.set_value(db, "plex_libraries", "4") # only the EN section is browsable
sysconfig.set_value(db, "plex_overlay_map", "9:4") # HU section 9 overlays EN section 4
db.commit()
monkeypatch.setattr(syncmod, "PlexClient", _StubClient)
stats = syncmod.sync(db)
en = db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == "4")).scalars().one()
hu = db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == "9")).scalars().one()
assert en.metadata_lang == "en" and en.overlay_of is None
assert hu.metadata_lang == "hu" and hu.overlay_of == en.id # pass-2 designation
# The EN movie captured its guid (from includeGuids listing) and received the HU overlay.
m = db.execute(select(PlexItem).where(PlexItem.rating_key == "en1")).scalars().one()
assert m.guid == "tmdb:1"
assert m.title_hu == "Öldöklés"
assert m.genres_hu == ["Akció"]
# The HU section is not browsable and mirrored no rows of its own.
movie_ids, _ = _content_libs(db)
assert en.id in movie_ids and hu.id not in movie_ids
assert db.scalar(select(func.count(PlexItem.id)).where(PlexItem.library_id == hu.id)) == 0
assert stats["overlaid"] == 1
def test_overlay_reconcile_clears_stale_hu_when_twin_vanishes(db):
"""A re-run whose HU section no longer carries a title's guid must drop that EN row's stale HU."""
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu")
db.add_all([en_lib, hu_lib])
db.flush()
hu_lib.overlay_of = en_lib.id
row = PlexItem(
rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat",
guid="tmdb:1", title_hu="Öldöklés", summary_hu="régi", genres_hu=["Akció"],
)
db.add(row)
db.commit()
# The HU section no longer lists this film (empty overlay).
_sync_overlay(db, _StubPlex([]), hu_lib, en_lib.id, {"overlaid": 0})
db.commit()
db.refresh(row)
assert row.title_hu is None and row.summary_hu is None and row.genres_hu is None
def test_content_libs_excludes_overlay_libraries(db): def test_content_libs_excludes_overlay_libraries(db):
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie") en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
en_shows = PlexLibrary(plex_key="1", title="TV Shows", kind="show") en_shows = PlexLibrary(plex_key="1", title="TV Shows", kind="show")