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).
461 lines
17 KiB
Python
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")
|