diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 0433680f..2f6f0479 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -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: row.library_id = lib_id 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.summary = meta.get("summary") row.year = meta.get("year") @@ -366,7 +368,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> db.add(sh) shows[rk] = sh 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.summary = meta.get("summary") sh.year = meta.get("year") @@ -430,39 +432,49 @@ def _sync_overlay( db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict ) -> None: """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 - their *_hu NULL (serving falls back to EN); HU items with no EN match are skipped. Only title/ - summary/genres differ by language; the language-neutral people_text feeds both search vectors.""" + NO browsable rows — an overlay library is never listed on its own. Only title/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": - by_guid = { - r.guid: r - for r in db.execute( - select(PlexItem).where( - PlexItem.library_id == en_lib_id, - PlexItem.kind == "movie", - PlexItem.guid.is_not(None), - ) - ).scalars() - } + rows = db.execute( + select(PlexItem).where( + PlexItem.library_id == en_lib_id, + PlexItem.kind == "movie", + PlexItem.guid.is_not(None), + ) + ).scalars() item_type = _TYPE_MOVIE else: - by_guid = { - r.guid: r - for r in db.execute( - select(PlexShow).where( - PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None) - ) - ).scalars() - } + rows = db.execute( + select(PlexShow).where( + PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None) + ) + ).scalars() 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): g = _canonical_guid(meta) - row = by_guid.get(g) if g else None - if row is None: + targets = by_guid.get(g) if g else None + if not targets: continue - row.title_hu = (meta.get("title") or "").strip() or None - row.summary_hu = meta.get("summary") - row.genres_hu = _tags(meta, "Genre") + title_hu = (meta.get("title") or "").strip() or None + summary_hu = meta.get("summary") + 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 db.flush() diff --git a/backend/tests/test_plex_overlay.py b/backend/tests/test_plex_overlay.py index bf7fead1..909fc972 100644 --- a/backend/tests/test_plex_overlay.py +++ b/backend/tests/test_plex_overlay.py @@ -7,6 +7,8 @@ Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. Runs """ from sqlalchemy import func, select +import app.plex.sync as syncmod +from app import sysconfig from app.models import PlexItem, PlexLibrary from app.plex.sync import _canonical_guid, _sync_overlay 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 +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): en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie") en_shows = PlexLibrary(plex_key="1", title="TV Shows", kind="show")