Files
siftlode/backend/tests/test_plex_overlay.py
T
peter 5806adda7e fix(plex): a listing cut short no longer clears watch state or Hungarian text
The daily watch reconcile reads "absent from Plex's scan" as "Plex un-watched it" and deletes the
row — but it walked the sections without asking _paginate whether the walk reached the server's
total, so a Plex hiccup mid-walk (an empty page early) un-watched every title it failed to reach
until the next complete run. The scan now reports completeness; an incomplete one still pulls in
what it saw, re-pushes dirty rows, and clears nothing (logged as plex.watch.reconcile_incomplete).

The HU overlay had the same shape: clear every row's HU text, then refill from the listing. It now
reads the listing first and clears only after a complete one. The overlay moved to plex/overlay.py
(plex/sync.py drops under the 800-line limit and off the size ratchet's list).

Tests: plex/paths (23% -> 100%: prefix boundary, longest prefix, dead mount), the reconcile's
clear/keep/re-push rules, the cut-short scan and listing (each seen red with the guard removed).
2026-10-03 19:33:30 +02:00

461 lines
17 KiB
Python

"""DB-backed regression (E5 S5a): the HU-overlay sync writes Hungarian metadata onto the matched EN
rows (paired by guid), never creating browsable HU rows, and overlay libraries are excluded from the
browse scope. Also covers the canonical-guid parse and that the generated search_vector_hu populates.
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. Runs against the real schema
(migrations 0063/0064) so the JSONB + STORED generated columns are exercised.
"""
from alembic import command
from alembic.config import Config
from sqlalchemy import func, select
from sqlalchemy.orm import Session
import app.plex.sync as syncmod
from app import sysconfig
from app.db import engine
from app.models import PlexItem, PlexLibrary, PlexShow
from app.plex.genres import hu_genre, hu_genres
from app.plex.overlay import sync_overlay
from app.plex.sync import _apply_item, _canonical_guid
from app.plex.query import content_libs
class _StubPlex:
"""Minimal PlexClient stand-in: sync_overlay only calls section_items via _paginate. `total`
over the item count is a listing cut short (the server said more than it handed over)."""
def __init__(self, items: list[dict], total: int | None = None):
self._items = items
self._total = len(items) if total is None else total
def section_items(self, key, item_type, start, size):
return self._items[start : start + size], self._total
def _hu_movie(rk: str, title: str, summary: str, genres: list[str], guid: str | None) -> dict:
meta = {
"ratingKey": rk,
"title": title,
"summary": summary,
"Genre": [{"tag": g} for g in genres],
}
if guid:
meta["Guid"] = [{"id": guid}]
return meta
def test_canonical_guid_prefers_tmdb_then_imdb():
assert _canonical_guid({"Guid": [{"id": "imdb://tt1"}, {"id": "tmdb://55"}]}) == "tmdb:55"
assert _canonical_guid({"Guid": [{"id": "imdb://tt9"}, {"id": "tvdb://7"}]}) == "imdb:tt9"
assert _canonical_guid({"Guid": []}) is None
assert _canonical_guid({}) is None
def test_sync_overlay_writes_hu_onto_matched_en_rows(db):
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
matched = PlexItem(
rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1"
)
unmatched = PlexItem(
rating_key="m2", library_id=en_lib.id, kind="movie", title="Solo", guid="tmdb:2"
)
db.add_all([matched, unmatched])
db.commit()
stub = _StubPlex(
[
_hu_movie("h1", "Öldöklés", "Egy rablás terve.", ["Akció", "Krimi"], "tmdb://1"),
_hu_movie("h2", "Ismeretlen", "Nincs EN párja.", ["Dráma"], "tmdb://999"),
]
)
stats = {"overlaid": 0}
sync_overlay(db, stub, hu_lib, en_lib.id, stats)
db.commit()
db.refresh(matched)
db.refresh(unmatched)
assert matched.title_hu == "Öldöklés"
assert matched.summary_hu == "Egy rablás terve."
assert matched.genres_hu == ["Akció", "Krimi"]
assert (
matched.search_vector_hu is not None
) # the STORED generated column populated from title_hu
# The unmatched EN row keeps NULL HU fields (serving falls back to EN).
assert unmatched.title_hu is None
assert stats["overlaid"] == 1
# No browsable rows were created — the HU items never became plex_items of the overlay library.
hu_row_count = db.scalar(
select(func.count(PlexItem.id)).where(PlexItem.library_id == hu_lib.id)
)
assert hu_row_count == 0
# The EN library still holds exactly its two movies (no dup from the overlay pass).
en_row_count = db.scalar(
select(func.count(PlexItem.id)).where(PlexItem.library_id == en_lib.id)
)
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, read in full, no longer lists this film (only another one).
other = _hu_movie("h9", "Más", "más", [], "tmdb://99")
sync_overlay(db, _StubPlex([other]), 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_a_listing_cut_short_keeps_the_hungarian_text_it_did_not_reach(db):
"""E32 S2: the reconcile clears HU text only after a COMPLETE listing. A walk a Plex hiccup cut
short (an empty page before the server's total), or an empty section, says nothing about the
titles it did not reach — clearing them dropped their Hungarian text until the next full sync."""
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
kept = PlexItem(
rating_key="m1",
library_id=en_lib.id,
kind="movie",
title="Heat",
guid="tmdb:1",
title_hu="Szemtől szemben",
summary_hu="régi",
genres_hu=["Akció"],
)
fresh = PlexItem(rating_key="m2", library_id=en_lib.id, kind="movie", title="Up", guid="tmdb:2")
db.add_all([kept, fresh])
db.commit()
stats: dict = {}
reached = _hu_movie("h2", "Fel", "fel", [], "tmdb://2")
sync_overlay(db, _StubPlex([reached], total=2), hu_lib, en_lib.id, stats)
db.commit()
db.refresh(kept)
db.refresh(fresh)
assert kept.title_hu == "Szemtől szemben" and kept.summary_hu == "régi"
assert fresh.title_hu == "Fel" # what the listing did reach is still applied
assert stats["overlay_incomplete"] == 1
sync_overlay(db, _StubPlex([]), hu_lib, en_lib.id, {}) # an empty section: the same answer
db.commit()
db.refresh(kept)
assert kept.title_hu == "Szemtől szemben"
def test_apply_item_keeps_prior_guid_when_listing_lacks_guid():
row = PlexItem(rating_key="m1", kind="movie", guid="tmdb:1")
_apply_item(row, lib_id=7, kind="movie", meta={"title": "Heat"}) # no Guid array
assert row.guid == "tmdb:1" # the prior guid survives a guid-less listing
_apply_item(row, lib_id=7, kind="movie", meta={"title": "Heat", "Guid": [{"id": "tmdb://2"}]})
assert row.guid == "tmdb:2" # a real guid in the listing still updates it
def test_overlay_applies_to_all_editions_sharing_a_guid(db):
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
ed1 = PlexItem(rating_key="a", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1")
ed2 = PlexItem(
rating_key="b",
library_id=en_lib.id,
kind="movie",
title="Heat (Director's Cut)",
guid="tmdb:1",
)
db.add_all([ed1, ed2])
db.commit()
# Two HU entries share the guid — first wins, no double count.
stub = _StubPlex(
[
_hu_movie("h1", "Öldöklés", "első", ["Akció"], "tmdb://1"),
_hu_movie("h2", "Öldöklés (dup)", "második", ["Krimi"], "tmdb://1"),
]
)
stats = {"overlaid": 0}
sync_overlay(db, stub, hu_lib, en_lib.id, stats)
db.commit()
db.refresh(ed1)
db.refresh(ed2)
assert ed1.title_hu == "Öldöklés" and ed2.title_hu == "Öldöklés" # both editions overlaid
assert ed1.genres_hu == ["Akció"] and ed2.genres_hu == ["Akció"] # first HU entry won
assert stats["overlaid"] == 1 # counted once for the guid, not per edition or per dup HU item
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")
db.add_all([en_lib, en_shows])
db.flush()
overlay = PlexLibrary(
plex_key="9", title="Filmek", kind="movie", metadata_lang="hu", overlay_of=en_lib.id
)
db.add(overlay)
db.commit()
movie_ids, show_ids = content_libs(db)
assert en_lib.id in movie_ids
assert overlay.id not in movie_ids # the HU overlay is never browsable
assert show_ids == [en_shows.id]
class _KindStub:
"""An overlay section listing per Plex type (2 = show, 4 = episode), like a HU TV section."""
def __init__(self, by_type: dict[int, list[dict]]):
self._by = by_type
def section_items(self, key, item_type, start, size):
items = self._by.get(item_type, [])
return items[start : start + size], len(items)
def test_a_show_overlay_writes_hungarian_episode_titles_and_summaries(db):
"""E30: the HU TV section lists every episode in Hungarian; they were never read, so a Hungarian
reader saw every episode title and summary in English."""
en_lib = PlexLibrary(plex_key="1", title="TV Shows", kind="show")
hu_lib = PlexLibrary(plex_key="9", title="Sorozatok", kind="show", metadata_lang="hu")
db.add_all([en_lib, hu_lib])
db.flush()
show = PlexShow(rating_key="s1", library_id=en_lib.id, title="3 Body Problem", guid="tmdb:10")
db.add(show)
db.flush()
ep = PlexItem(
rating_key="e1",
library_id=en_lib.id,
kind="episode",
show_id=show.id,
title="Countdown",
summary="EN.",
guid="tmdb:501",
)
db.add(ep)
db.commit()
stub = _KindStub(
{
syncmod._TYPE_SHOW: [_hu_movie("hs", "A 3-test-probléma", "HU.", [], "tmdb://10")],
syncmod._TYPE_EPISODE: [
_hu_movie("he", "Visszaszámlálás", "Magyar epizód.", [], "tmdb://501")
],
}
)
sync_overlay(db, stub, hu_lib, en_lib.id, {})
db.commit()
db.refresh(ep)
db.refresh(show)
assert (ep.title_hu, ep.summary_hu) == ("Visszaszámlálás", "Magyar epizód.")
assert show.title_hu == "A 3-test-probléma"
def test_the_overlay_gives_every_row_one_hungarian_word_per_genre(db):
"""E30: Plex's HU agent mixes "Action" with "Dráma", and a title with no HU twin kept only its
English list — so the HU genre list offered "Drama" beside "Dráma"."""
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()
twin = PlexItem(
rating_key="m1",
library_id=en_lib.id,
kind="movie",
title="Heat",
guid="tmdb:1",
genres=["Action", "Crime"],
)
lone = PlexItem(
rating_key="m2",
library_id=en_lib.id,
kind="movie",
title="Solo",
guid="tmdb:2",
genres=["Drama", "Science Fiction"],
)
db.add_all([twin, lone])
db.commit()
meta = _hu_movie(
"h1", "Szemtől szemben", "HU.", ["Action", "Bűnügyi", "Drama", "Dráma"], "tmdb://1"
)
meta["tagline"] = "Magyar mottó."
sync_overlay(db, _StubPlex([meta]), hu_lib, en_lib.id, {})
db.commit()
db.refresh(twin)
db.refresh(lone)
assert twin.genres_hu == ["Akció", "Bűnügyi", "Dráma"]
assert twin.tagline_hu == "Magyar mottó."
assert lone.genres_hu == ["Dráma", "Sci-Fi"] # no twin: its own English list, in Hungarian
assert lone.tagline_hu is None
def test_a_twin_without_genres_keeps_the_rows_own_hungarian_list(db):
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()
row = PlexItem(
rating_key="m1",
library_id=en_lib.id,
kind="movie",
title="Heat",
guid="tmdb:1",
genres=["Crime"],
)
db.add(row)
db.commit()
sync_overlay(
db,
_StubPlex([_hu_movie("h1", "Szemtől szemben", "HU.", [], "tmdb://1")]),
hu_lib,
en_lib.id,
{},
)
db.commit()
db.refresh(row)
assert (row.title_hu, row.genres_hu) == ("Szemtől szemben", ["Bűnügyi"])
def test_every_synced_row_gets_hungarian_genres():
"""E30 review: not only an overlaid row — the HU facets and filter read genres_hu for all."""
from app.plex.sync import _apply_facet_fields
row = PlexItem(title="Solo")
_apply_facet_fields(row, {"Genre": [{"tag": "Drama"}, {"tag": "Action"}]})
assert (row.genres, row.genres_hu) == (["Drama", "Action"], ["Dráma", "Akció"])
def test_the_genre_map_is_idempotent_and_keeps_an_unknown_word():
assert hu_genre("science fiction") == "Sci-Fi" # case-insensitive
assert hu_genre("Dráma") == "Dráma"
assert hu_genre("Space Opera") == "Space Opera" # unknown: shown as Plex gave it, never dropped
once = hu_genres(["Drama", "Action", "Dráma"])
assert once == ["Dráma", "Akció"]
assert hu_genres(once) == once
assert hu_genres(None) is None and hu_genres([]) is None
def test_0094_gives_every_row_without_a_hungarian_list_one(db):
cfg = Config("alembic.ini")
command.downgrade(cfg, "0093_plex_person_known_for_votes")
upgraded = False
try:
with Session(engine) as s:
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
s.add(lib)
s.flush()
rows = {
"bare": PlexItem(genres=["Action", "Biography"]),
"overlaid": PlexItem(
genres=["Action"], genres_hu=["Kaland"]
), # its twin's own list
"genreless": PlexItem(genres=[]),
}
for key, row in rows.items():
row.rating_key, row.library_id, row.kind, row.title = key, lib.id, "movie", key
show = PlexShow(rating_key="s", library_id=lib.id, title="s", genres=["Comedy"])
s.add_all([*rows.values(), show])
s.commit()
ids = {k: r.id for k, r in rows.items()}
show_id = show.id
command.upgrade(cfg, "head")
upgraded = True
with Session(engine) as s:
got = {k: s.get(PlexItem, i).genres_hu for k, i in ids.items()} # type: ignore[union-attr]
show_hu = s.get(PlexShow, show_id).genres_hu # type: ignore[union-attr]
assert got == {"bare": ["Akció", "Életrajzi"], "overlaid": ["Kaland"], "genreless": None}
assert show_hu == ["Vígjáték"]
finally:
if not upgraded:
command.upgrade(cfg, "head")