diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json index 50e22559..a438d546 100644 --- a/backend/.basedpyright/baseline.json +++ b/backend/.basedpyright/baseline.json @@ -639,14 +639,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 27, - "endColumn": 33, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { diff --git a/backend/alembic/versions/0091_plex_tagline_hu.py b/backend/alembic/versions/0091_plex_tagline_hu.py new file mode 100644 index 00000000..3110e243 --- /dev/null +++ b/backend/alembic/versions/0091_plex_tagline_hu.py @@ -0,0 +1,27 @@ +"""add tagline_hu to plex_items + plex_shows — the HU overlay's tagline (E30 S1) + +Revision ID: 0091_plex_tagline_hu +Revises: 0090_debug_session_draft +Create Date: 2026-09-29 + +The Hungarian overlay section lists a tagline beside the title and summary; the info page showed the +English one to a Hungarian reader. NULL until the next overlay sync fills it (serving falls back to EN). +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0091_plex_tagline_hu" +down_revision: str | None = "0090_debug_session_draft" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.add_column("plex_items", sa.Column("tagline_hu", sa.Text(), nullable=True)) + op.add_column("plex_shows", sa.Column("tagline_hu", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("plex_shows", "tagline_hu") + op.drop_column("plex_items", "tagline_hu") diff --git a/backend/app/models.py b/backend/app/models.py index 5677c594..8c729591 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1224,6 +1224,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): # Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched, # so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors. title_hu: Mapped[str | None] = mapped_column(String(512)) + tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline (E30) summary_hu: Mapped[str | None] = mapped_column(Text) genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) search_vector: Mapped[object | None] = mapped_column( @@ -1385,6 +1386,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): # Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched # (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors. title_hu: Mapped[str | None] = mapped_column(String(512)) + tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline (E30) summary_hu: Mapped[str | None] = mapped_column(Text) genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) search_vector: Mapped[object | None] = mapped_column( diff --git a/backend/app/plex/genres.py b/backend/app/plex/genres.py new file mode 100644 index 00000000..2b19cb4a --- /dev/null +++ b/backend/app/plex/genres.py @@ -0,0 +1,100 @@ +"""One Hungarian word per Plex genre (E30). + +Plex's Hungarian agent returns a MIXED vocabulary: some genres come back translated ("Dráma", +"Vígjáték"), others stay English ("Action", "Adventure", "Biography"), and a title with no HU twin has +only its English genres. The HU genre list therefore offered "Drama" beside "Dráma" and a dozen English +words. This map sends every genre word we have met — English (Plex EN agent, TMDB, IMDb) or Plex's +Hungarian — to the ONE word a Hungarian reader sees. The overlay sync writes it into `genres_hu`, and a +genre filter is read through it too, so a saved filter still holding "Action" keeps matching "Akció". + +A word not in the map passes through unchanged: an unknown genre stays visible (in whatever language +Plex gave it) rather than disappearing. +""" + +from collections.abc import Iterable + +# Keys are matched case-insensitively (see `_BY_KEY`). The Hungarian values double as keys mapping to +# themselves, so running a list through twice changes nothing. +_HU: dict[str, str] = { + "Action": "Akció", + "Action & Adventure": "Akció és kaland", + "Adventure": "Kaland", + "Animation": "Animációs", + "Anime": "Anime", + "Biography": "Életrajzi", + "Children": "Gyerek", + "Comedy": "Vígjáték", + "Crime": "Bűnügyi", + "Documentary": "Dokumentum", + "Drama": "Dráma", + "Family": "Családi", + "Fantasy": "Fantasy", + "Film-Noir": "Film noir", + "Food": "Gasztronómia", + "Game Show": "Vetélkedő", + "Game-Show": "Vetélkedő", + "History": "Történelmi", + "Home and Garden": "Otthon és kert", + "Horror": "Horror", + "Indie": "Független", + "Kids": "Gyerek", + "Martial Arts": "Harcművészet", + "Mini-Series": "Minisorozat", + "Music": "Zenei", + "Musical": "Musical", + "Mystery": "Rejtély", + "News": "Hírek", + "Reality": "Valóságshow", + "Reality-TV": "Valóságshow", + "Romance": "Romantikus", + "Sci-Fi": "Sci-Fi", + "Sci-Fi & Fantasy": "Sci-Fi és fantasy", + "Science Fiction": "Sci-Fi", + "Short": "Rövidfilm", + "Soap": "Szappanopera", + "Sport": "Sport", + "Suspense": "Feszültség", + "Talk": "Talkshow", + "Talk Show": "Talkshow", + "Thriller": "Thriller", + "Travel": "Utazás", + "TV Movie": "TV film", + "War": "Háborús", + "War & Politics": "Háború és politika", + "Western": "Western", + # Plex's own Hungarian words, pinned to themselves (and to the spelling above where they differ). + "Akció": "Akció", + "Animációs": "Animációs", + "Bűnügyi": "Bűnügyi", + "Családi": "Családi", + "Dokumentum": "Dokumentum", + "Dokumentumfilm": "Dokumentum", + "Dráma": "Dráma", + "Életrajzi": "Életrajzi", + "Háborús": "Háborús", + "Kaland": "Kaland", + "Rejtély": "Rejtély", + "Romantikus": "Romantikus", + "Történelmi": "Történelmi", + "TV film": "TV film", + "Vígjáték": "Vígjáték", + "Zene": "Zenei", + "Zenei": "Zenei", +} +_BY_KEY = {k.casefold(): v for k, v in _HU.items()} + + +def hu_genre(word: str) -> str: + """The Hungarian word for one genre; an unknown word comes back unchanged.""" + return _BY_KEY.get(word.strip().casefold(), word) + + +def hu_genres(words: Iterable[str] | None) -> list[str] | None: + """A genre list in Hungarian, in its first-seen order, each word once ("Drama" + "Dráma" → one + "Dráma"). None or empty stays None, like the listing's own `_tags`.""" + out: list[str] = [] + for w in words or (): + hu = hu_genre(w) + if hu not in out: + out.append(hu) + return out or None diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 406ffe8c..2bcf6366 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -11,6 +11,7 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Session from app.models import PlexItem, PlexLibrary +from app.plex.genres import hu_genre TS_CONFIG = "public.unaccent_simple" @@ -181,6 +182,10 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list: Genre chips are language-specific strings, so they filter the active-language genre column.""" conds: list = [] gsel = split_csv(p.get("genres")) + if gsel and lang == "hu": + # The HU column holds one Hungarian word per genre (plex/genres.py); a filter saved while it + # still offered "Action" is read as "Akció", so it keeps matching instead of emptying the grid. + gsel = list(dict.fromkeys(hu_genre(g) for g in gsel)) if gsel: gcol = genres_col(model, lang) gc = [gcol.contains([g]) for g in gsel] diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 271a2d9f..b8e05dfd 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -24,6 +24,7 @@ from app import logs, sysconfig from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow from app.plex import detail, people from app.plex.client import PlexClient, PlexError, PlexNotConfigured +from app.plex.genres import hu_genres from app.plex.lookups import by_rating_key log = logs.get_logger(__name__) @@ -659,35 +660,66 @@ 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. 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/tagline/genres + differ by language; the language-neutral people_text feeds both search vectors. A show section's + episodes are overlaid too (E30): the HU section lists every episode with its Hungarian title and + summary, and the episode rows live in the EN show library. 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": - rows = db.execute( - select(PlexItem).where( - PlexItem.library_id == en_lib_id, - PlexItem.kind == "movie", - PlexItem.guid.is_not(None), + passes = [ + ( + _TYPE_MOVIE, + select(PlexItem).where( + PlexItem.library_id == en_lib_id, + PlexItem.kind == "movie", + PlexItem.guid.is_not(None), + ), ) - ).scalars() - item_type = _TYPE_MOVIE + ] else: - rows = db.execute( - select(PlexShow).where(PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None)) - ).scalars() - item_type = _TYPE_SHOW + passes = [ + ( + _TYPE_SHOW, + select(PlexShow).where( + PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None) + ), + ), + ( + _TYPE_EPISODE, + select(PlexItem).where( + PlexItem.library_id == en_lib_id, + PlexItem.kind == "episode", + PlexItem.guid.is_not(None), + ), + ), + ] + for item_type, rows_q in passes: + _overlay_pass(db, plex, lib, item_type, db.execute(rows_q).scalars(), stats) + db.flush() + + +def _overlay_pass( + db: Session, plex: PlexClient, lib: PlexLibrary, item_type: int, rows, stats: dict +): + """One kind of the overlay (movies, shows or episodes) — see `_sync_overlay`.""" # 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. + # The genres are the exception: every row gets Hungarian ones, from its HU twin or, lacking one, from + # its own English list — otherwise the HU genre list mixed "Drama" (no twin) with "Dráma". + episodes = item_type == _TYPE_EPISODE for group in by_guid.values(): for row in group: - row.title_hu = row.summary_hu = row.genres_hu = None + row.title_hu = row.summary_hu = None + if not episodes: + row.tagline_hu = None + row.genres_hu = hu_genres(row.genres) seen: set[str] = ( set() ) # first HU entry for a guid wins; a dup HU item can't overwrite or double-count @@ -699,13 +731,15 @@ def _sync_overlay( seen.add(g) title_hu = (meta.get("title") or "").strip() or None summary_hu = meta.get("summary") - genres_hu = _tags(meta, "Genre") + tagline_hu = (meta.get("tagline") or "").strip() or None + genres_hu = hu_genres(_tags(meta, "Genre")) for row in targets: row.title_hu = title_hu row.summary_hu = summary_hu - row.genres_hu = genres_hu + if not episodes: + row.tagline_hu = tagline_hu + row.genres_hu = genres_hu or row.genres_hu stats["overlaid"] = stats.get("overlaid", 0) + 1 - db.flush() def _upsert_collection( diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index f621ba6b..0215ee25 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -468,7 +468,7 @@ def item_detail( "genres": (it.genres_hu or it.genres if hu else it.genres) or [], "directors": it.directors or [], "studio": it.studio, - "tagline": it.tagline, + "tagline": (it.tagline_hu or it.tagline) if hu else it.tagline, } else: rich = plex_detail.rich_detail(meta) diff --git a/backend/tests/test_plex_lang.py b/backend/tests/test_plex_lang.py index 1113320a..8dc185d4 100644 --- a/backend/tests/test_plex_lang.py +++ b/backend/tests/test_plex_lang.py @@ -30,7 +30,9 @@ def _seed(db): summary="English summary.", summary_hu="Magyar összefoglaló.", genres=["Drama"], - genres_hu=["Dráma"], + genres_hu=["Dráma", "Akció"], + tagline="English tagline.", + tagline_hu="Magyar mottó.", rating=6.5, imdb_rating=None, year=2001, @@ -102,12 +104,25 @@ def test_genre_filter_matches_active_language_vocabulary(db, monkeypatch, overri assert miss.json()["items"] == [] +def test_a_genre_filter_saved_in_english_still_matches_in_hungarian(db, monkeypatch, override_dep): + """E30: the HU column holds one Hungarian word per genre. A filter saved while the HU list still + offered "Action" is read through the same map, so it finds the "Akció" title instead of nothing.""" + user = _seed(db) + resp = _get( + db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu&genres=Action" + ) + assert _titles(resp) == {"Magyar Cím"} + + def test_item_detail_language_and_rating_split(db, monkeypatch, override_dep): user = _seed(db) resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1?lang=hu") body = resp.json() assert body["title"] == "Magyar Cím" assert body["summary"] == "Magyar összefoglaló." + assert body["tagline"] == "Magyar mottó." # E30: the HU overlay's tagline + en = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1").json() + assert en["tagline"] == "English tagline." # imdb_rating is IMDb-only (None here); the audience score is served separately as `rating`. assert body["imdb_rating"] is None assert body["rating"] == 6.5 diff --git a/backend/tests/test_plex_overlay.py b/backend/tests/test_plex_overlay.py index 5b30d00b..ae8fab37 100644 --- a/backend/tests/test_plex_overlay.py +++ b/backend/tests/test_plex_overlay.py @@ -10,7 +10,8 @@ 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, PlexShow +from app.plex.genres import hu_genre, hu_genres from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay from app.plex.query import content_libs @@ -244,3 +245,100 @@ def test_content_libs_excludes_overlay_libraries(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_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