feat(plex): episodes, taglines and genres in Hungarian from the HU overlay (E30 S1a)

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.
This commit is contained in:
2026-09-29 01:52:02 +02:00
parent 0f9ed7cdbf
commit 13d95ef70e
9 changed files with 301 additions and 28 deletions
-8
View File
@@ -639,14 +639,6 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 27,
"endColumn": 33,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
@@ -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")
+2
View File
@@ -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(
+100
View File
@@ -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
+5
View File
@@ -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]
+51 -17
View File
@@ -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(
+1 -1
View File
@@ -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)
+16 -1
View File
@@ -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
+99 -1
View File
@@ -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