From 13d95ef70ea971156b1f24f074b371b40c9cfcd0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 29 Sep 2026 01:52:02 +0200 Subject: [PATCH] feat(plex): episodes, taglines and genres in Hungarian from the HU overlay (E30 S1a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HU TV section lists every episode with its Hungarian title and summary, but the overlay read only movies and shows, so every episode stayed English. It now overlays the episodes too, and the HU tagline (new tagline_hu column, served on ?lang=hu). Plex's HU agent returns a mixed genre vocabulary (Action beside Dráma), and a title with no HU twin kept its English list, so the HU genre list showed Drama and Dráma. plex/genres.py maps every genre word to one Hungarian word; the overlay writes it into genres_hu for every row, and a HU genre filter is read through the same map, so a saved filter still holding Action keeps matching. --- backend/.basedpyright/baseline.json | 8 -- .../alembic/versions/0091_plex_tagline_hu.py | 27 +++++ backend/app/models.py | 2 + backend/app/plex/genres.py | 100 ++++++++++++++++++ backend/app/plex/query.py | 5 + backend/app/plex/sync.py | 68 +++++++++--- backend/app/routes/plex/detail.py | 2 +- backend/tests/test_plex_lang.py | 17 ++- backend/tests/test_plex_overlay.py | 100 +++++++++++++++++- 9 files changed, 301 insertions(+), 28 deletions(-) create mode 100644 backend/alembic/versions/0091_plex_tagline_hu.py create mode 100644 backend/app/plex/genres.py 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