From 13d95ef70ea971156b1f24f074b371b40c9cfcd0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 29 Sep 2026 01:52:02 +0200 Subject: [PATCH 01/23] 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 From 632e39d458d910cdc02b3a4668cf074067f2a170 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 29 Sep 2026 02:12:32 +0200 Subject: [PATCH 02/23] feat(plex): every card rail in the reader's language; an episode in a poster rail stands as its show (E30 S1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid served Hungarian titles, but every other card surface (Discover rails, the show page's episodes, search, collections, the person page, playlists, Home's resume rail, hero and comfort shelf) served English. The card serializers now take a required keyword-only lang (a forgotten call site fails instead of going English), and each of those reads takes ?lang=hu, with the language in its query key. A Hungarian search finds an episode by its Hungarian title too. An episode on a poster rail showed its 16:9 still cropped into a poster, titled only with the episode name; it now shows the show's poster and name, with S·E · title under it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the number in the reader's language; a season with a name of its own keeps it. Home's comfort-shelf move wrote its optimistic order to the key without the language, which nothing read any more; it writes the query's own key. --- backend/app/discover/hero.py | 9 +- backend/app/discover/pins.py | 12 +- backend/app/discover/queries.py | 24 ++-- backend/app/discover/rails.py | 21 ++-- backend/app/plex/cards.py | 46 +++++--- backend/app/routes/discover.py | 21 +++- backend/app/routes/plex/_shared.py | 6 +- backend/app/routes/plex/browse.py | 29 +++-- backend/app/routes/plex/collections.py | 12 +- backend/app/routes/plex/detail.py | 19 +-- backend/app/routes/plex/discover.py | 39 ++++--- backend/app/routes/plex/people.py | 15 ++- backend/app/routes/plex/playlists.py | 11 +- backend/tests/test_discover.py | 92 ++++++++------- backend/tests/test_plex_cards.py | 8 +- backend/tests/test_plex_lang.py | 109 +++++++++++++++++- frontend/src/components/Home.dom.test.tsx | 33 +++++- frontend/src/components/Home.tsx | 15 ++- .../PlexCollectionView.dom.test.tsx | 19 ++- .../src/components/PlexCollectionView.tsx | 10 +- .../components/PlexPersonView.dom.test.tsx | 33 +++++- .../PlexPlaylistReorder.dom.test.tsx | 4 +- .../PlexPlaylistView.menu.dom.test.tsx | 14 +++ frontend/src/components/comfortPin.tsx | 5 +- .../components/plexCards.lang.dom.test.tsx | 71 ++++++++++++ frontend/src/components/plexCards.tsx | 39 +++++-- frontend/src/components/plexViews.tsx | 24 ++-- .../usePlexPlaylistEdits.dom.test.tsx | 8 +- .../src/components/usePlexPlaylistEdits.ts | 19 +-- frontend/src/i18n/locales/en/plex.json | 3 + frontend/src/i18n/locales/hu/plex.json | 3 + frontend/src/lib/api/discover.dom.test.ts | 13 +++ frontend/src/lib/api/discover.ts | 12 +- frontend/src/lib/api/plex.ts | 10 +- frontend/src/lib/api/plexLang.dom.test.ts | 42 +++++++ frontend/src/lib/plexDetail.ts | 17 +++ frontend/src/lib/queryKeys.ts | 9 +- 37 files changed, 673 insertions(+), 203 deletions(-) create mode 100644 frontend/src/components/plexCards.lang.dom.test.tsx create mode 100644 frontend/src/lib/api/plexLang.dom.test.ts diff --git a/backend/app/discover/hero.py b/backend/app/discover/hero.py index e75a2e88..520c02de 100644 --- a/backend/app/discover/hero.py +++ b/backend/app/discover/hero.py @@ -48,6 +48,7 @@ def pick( shelf: list[dict] | None = None, exclude: set[str] | None = None, start: int = 0, + lang: str, ) -> dict | None: """`{"tier": ..., "card": ...}`, or None when nothing at all fits. @@ -70,8 +71,8 @@ def pick( skip = exclude or set() def _resume() -> dict | None: - for card in rails.continue_watching(db, user) if resume is None else resume: - playable = rails.playable(db, user.id, card) + for card in rails.continue_watching(db, user, lang=lang) if resume is None else resume: + playable = rails.playable(db, user.id, card, lang=lang) # Excluded on the card the USER saw (the show), not only on what it resolves to, so a # rerolled-past show does not come straight back as its own next episode. if cards.key(card) in skip or (playable is not None and cards.key(playable) in skip): @@ -82,7 +83,7 @@ def pick( def _comfort() -> dict | None: if shelf is None: - own = pins.shelf(db, user, include_plex=rails.plex_available(db)) + own = pins.shelf(db, user, include_plex=rails.plex_available(db), lang=lang) else: own = shelf for card in pins.playables(own): @@ -93,7 +94,7 @@ def pick( def _surprise() -> dict | None: # A wider draw than the one card we need: the shuffle is seeded, so without the headroom an # excluded pick would leave the tier empty rather than move past it. - for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW): + for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW, lang=lang): if cards.key(card) not in skip: return card return None diff --git a/backend/app/discover/pins.py b/backend/app/discover/pins.py index 318b68ec..9b6ede72 100644 --- a/backend/app/discover/pins.py +++ b/backend/app/discover/pins.py @@ -170,7 +170,7 @@ class _Batch: return b -def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict | None: +def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch, lang: str) -> dict | None: """One pin as a unified card with its resolved `next`. None when the pinned row has gone (the FK cascade removes the pin itself, so this is only the window between a delete and a refetch).""" card: dict | None = None @@ -196,7 +196,7 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict if it is None: return None st = rollup.item_state_map(db, user.id, [it.id]).get(it.id) - card = cards.from_plex(leaf_card(it, st)) + card = cards.from_plex(leaf_card(it, st, lang=lang)) nxt = dict(card) # a movie or a single episode plays itself elif pin.plex_show_id is not None: @@ -204,8 +204,8 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict if sh is None: return None status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new") - card = cards.from_plex(show_card(sh, status)) - ep = queries.next_episode(db, user.id, sh.id) + card = cards.from_plex(show_card(sh, status, lang=lang)) + ep = queries.next_episode(db, user.id, sh.id, lang=lang) nxt = cards.from_plex(ep) if ep else None elif pin.playlist_id is not None: @@ -229,7 +229,7 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict _PLEX_COLUMNS = ("plex_item_id", "plex_show_id") -def shelf(db: Session, user: User, *, include_plex: bool = True) -> list[dict]: +def shelf(db: Session, user: User, *, include_plex: bool = True, lang: str) -> list[dict]: """The whole shelf, in the user's own order. `include_plex=False` is the Home surface with the deployment's Plex toggle off (the owner's call, @@ -243,7 +243,7 @@ def shelf(db: Session, user: User, *, include_plex: bool = True) -> list[dict]: pins = [p for p in pins if all(getattr(p, c) is None for c in _PLEX_COLUMNS)] batch = _Batch.load(db, user, pins) for pin in pins: - card = _shelf_card(db, user, pin, batch) + card = _shelf_card(db, user, pin, batch, lang) if card is not None: out.append(card) return out diff --git a/backend/app/discover/queries.py b/backend/app/discover/queries.py index 8b6e82f7..33ad77cf 100644 --- a/backend/app/discover/queries.py +++ b/backend/app/discover/queries.py @@ -40,7 +40,7 @@ from app.models import ( VideoState, ) from app.plex import rollup -from app.plex.cards import leaf_card, movie_card, show_card +from app.plex.cards import display_title, leaf_card, movie_card, show_card from app.plex.query import apply_show_state_filter, content_libs # How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike @@ -221,7 +221,9 @@ def _unseen_movie_conds(uid: int): return ~seen -def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tuple[object, dict]]: +def plex_in_progress( + db: Session, uid: int, limit: int = RAIL_LIMIT, *, lang: str +) -> list[tuple[object, dict]]: """In-progress movies + partially-watched shows as (progressed_at, card). Mirrors `routes/plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a @@ -249,7 +251,7 @@ def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tup .limit(limit) ) for it, st in db.execute(mq).all(): - out.append((st.progress_updated_at, movie_card(it, st))) + out.append((st.progress_updated_at, movie_card(it, st, lang=lang))) if show_libs: prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery() @@ -263,12 +265,14 @@ def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tup for sh, resumed_at in db.execute( sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit) ).all(): - out.append((resumed_at, show_card(sh, "in_progress"))) + out.append((resumed_at, show_card(sh, "in_progress", lang=lang))) return out -def plex_surprise(db: Session, uid: int, budget: str, seed: int, limit: int) -> list[dict]: +def plex_surprise( + db: Session, uid: int, budget: str, seed: int, limit: int, *, lang: str +) -> list[dict]: """Unseen playable movies inside the budget, in seeded-shuffle order. Movies only. A show is not a thing you can start playing, and resolving every candidate show to @@ -300,14 +304,14 @@ def plex_surprise(db: Session, uid: int, budget: str, seed: int, limit: int) -> q = q.where(PlexItem.duration_s <= hi) items = db.execute(q).scalars().all() states = rollup.item_state_map(db, uid, [it.id for it in items]) - return [movie_card(it, states.get(it.id)) for it in items] + return [movie_card(it, states.get(it.id), lang=lang) for it in items] # Watch states that take an episode out of the running for "what would play next". _DONE = ("watched", "hidden") -def next_episode(db: Session, uid: int, show_id: int) -> dict | None: +def next_episode(db: Session, uid: int, show_id: int, *, lang: str) -> dict | None: """What "put that show on" means: the episode in progress, else the first unwatched one, else the first — over the same `(season, episode)` broadcast ordering `detail.py::_episode_nav` uses. @@ -357,5 +361,9 @@ def next_episode(db: Session, uid: int, show_id: int) -> dict | None: ep, st = pick show = db.get(PlexShow, show_id) return leaf_card( - ep, st, show_title=show.title if show else None, show_rk=show.rating_key if show else None + ep, + st, + show_title=display_title(show, lang) if show else None, + show_rk=show.rating_key if show else None, + lang=lang, ) diff --git a/backend/app/discover/rails.py b/backend/app/discover/rails.py index 0598e0a1..5af9e90a 100644 --- a/backend/app/discover/rails.py +++ b/backend/app/discover/rails.py @@ -22,7 +22,7 @@ def plex_available(db: Session) -> bool: return sysconfig.get_bool(db, "plex_enabled") -def playable(db: Session, uid: int, card: dict) -> dict | None: +def playable(db: Session, uid: int, card: dict, *, lang: str) -> dict | None: """The thing that would actually START from this card — itself for anything with a runtime, and for a Plex SHOW the episode that is next (in progress, else the first unwatched, else the first). @@ -40,11 +40,11 @@ def playable(db: Session, uid: int, card: dict) -> dict | None: sh = by_rating_key(db, PlexShow, card["id"]) if sh is None: return None - ep = queries.next_episode(db, uid, sh.id) + ep = queries.next_episode(db, uid, sh.id, lang=lang) return cards.from_plex(ep) if ep else None -def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[dict]: +def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT, *, lang: str) -> list[dict]: """One resume rail across both sources, most recently progressed first. A Plex SHOW enters as a show CARD — that is what the Plex rail shows, and it is the artwork the @@ -57,17 +57,22 @@ def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[ (ts, cards.from_video(r)) for ts, r in queries.yt_in_progress(db, user, limit) ] if plex_available(db): - rows += [(ts, cards.from_plex(c)) for ts, c in queries.plex_in_progress(db, user.id, limit)] + rows += [ + (ts, cards.from_plex(c)) + for ts, c in queries.plex_in_progress(db, user.id, limit, lang=lang) + ] rows.sort(key=lambda r: r[0] or NO_TS, reverse=True) out = [card for _, card in rows[:limit]] # Resolve AFTER the slice: only the rows that survive it are worth a query. for card in out: if card["kind"] == "show": - card["next"] = playable(db, user.id, card) + card["next"] = playable(db, user.id, card, lang=lang) return out -def surprise(db: Session, user: User, budget: str, seed: int, limit: int = 3) -> list[dict]: +def surprise( + db: Session, user: User, budget: str, seed: int, limit: int = 3, *, lang: str +) -> list[dict]: """The hero's draw pool: unseen things that fit the budget, from both sources under ONE seed. Interleaved rather than concatenated — the same alternation `routes/plex/discover.py` uses for its @@ -83,7 +88,9 @@ def surprise(db: Session, user: User, budget: str, seed: int, limit: int = 3) -> (cards.from_video, queries.yt_surprise(db, user, budget, seed, limit)), ( cards.from_plex, - queries.plex_surprise(db, user.id, budget, seed, limit) if plex_available(db) else [], + queries.plex_surprise(db, user.id, budget, seed, limit, lang=lang) + if plex_available(db) + else [], ), ] if seed % 2: diff --git a/backend/app/plex/cards.py b/backend/app/plex/cards.py index ae0344e7..620a964b 100644 --- a/backend/app/plex/cards.py +++ b/backend/app/plex/cards.py @@ -15,20 +15,35 @@ def _art_url(rating_key: str, art_key: str | None) -> str | None: # Card hover-overview: a short synopsis for the reveal panel, trimmed so a strip/related row of cards -# stays a few hundred bytes each rather than carrying full synopses. (These non-grid card paths serve -# the EN summary/rating; the main grid coalesces the HU twin in browse.unified_library.) +# stays a few hundred bytes each rather than carrying full synopses. def _card_summary(summary: str | None) -> str | None: return (summary or "")[:300] or None -def movie_card(it: PlexItem, st: PlexState | None) -> dict: +def norm_lang(lang: str | None) -> str: + """The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN.""" + return "hu" if lang == "hu" else "en" + + +# Every card speaks the reader's language (E30): `lang="hu"` serves the HU overlay twin's title and +# summary, falling back to EN where a row has none — the same rule as browse.unified_library's +# `title_col`/`summary_col`. These rails once served English to a Hungarian reader. +def display_title(row: PlexItem | PlexShow, lang: str) -> str: + return (row.title_hu or row.title) if lang == "hu" else row.title + + +def display_summary(row: PlexItem | PlexShow, lang: str) -> str | None: + return (row.summary_hu or row.summary) if lang == "hu" else row.summary + + +def movie_card(it: PlexItem, st: PlexState | None, *, lang: str) -> dict: return { "id": it.rating_key, "type": "movie", - "title": it.title, + "title": display_title(it, lang), "year": it.year, "rating": it.rating, - "summary": _card_summary(it.summary), + "summary": _card_summary(display_summary(it, lang)), "duration_seconds": it.duration_s, "thumb": f"/api/plex/image/{it.rating_key}", "art": _art_url(it.rating_key, it.art_key), @@ -39,14 +54,14 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict: } -def show_card(sh: PlexShow, status: str = "new") -> dict: +def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict: return { "id": sh.rating_key, "type": "show", - "title": sh.title, + "title": display_title(sh, lang), "year": sh.year, "rating": sh.rating, - "summary": _card_summary(sh.summary), + "summary": _card_summary(display_summary(sh, lang)), "thumb": f"/api/plex/image/{sh.rating_key}", "art": _art_url(sh.rating_key, sh.art_key), "season_count": sh.child_count, @@ -55,12 +70,12 @@ def show_card(sh: PlexShow, status: str = "new") -> dict: } -def episode_card(e: PlexItem, st: PlexState | None) -> dict: +def episode_card(e: PlexItem, st: PlexState | None, *, lang: str) -> dict: return { "id": e.rating_key, "type": "episode", - "title": e.title, - "summary": e.summary, + "title": display_title(e, lang), + "summary": display_summary(e, lang), "season_number": e.season_number, "episode_number": e.episode_number, "duration_seconds": e.duration_s, @@ -77,12 +92,15 @@ def leaf_card( st: PlexState | None, show_title: str | None = None, show_rk: str | None = None, + *, + lang: str, ) -> dict: """A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds. - `show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season.""" + `show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season. + `show_title` is the caller's, already in `lang` (see `display_title`).""" if it.kind == "episode": - card = episode_card(it, st) + card = episode_card(it, st, lang=lang) card["show_title"] = show_title card["show_id"] = show_rk return card - return movie_card(it, st) + return movie_card(it, st, lang=lang) diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py index a1f7ae23..2de01fc8 100644 --- a/backend/app/routes/discover.py +++ b/backend/app/routes/discover.py @@ -23,6 +23,7 @@ from app.discover import hero as hero_mod from app.discover import pins, rails from app.discover.budget import Budget, DEFAULT_BUDGET from app.models import ComfortPin, User +from app.plex.cards import norm_lang from app.routes._common import owned_or_404, not_found router = APIRouter(prefix="/api/discover", tags=["discover"]) @@ -45,6 +46,7 @@ class ReorderIn(BaseModel): def get_discover( budget: Budget = DEFAULT_BUDGET, seed: int = 0, + lang: str = "en", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -52,13 +54,14 @@ def get_discover( `resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from are not queried twice for a single page load.""" - resume = rails.continue_watching(db, user) + lang = norm_lang(lang) + resume = rails.continue_watching(db, user, lang=lang) # Plex off → the shelf rail (and so the hero's comfort tier) goes YouTube-only like the rails # beside it; the pins are kept and `GET /pins` still lists them (`pins.shelf`). - shelf = pins.shelf(db, user, include_plex=rails.plex_available(db)) + shelf = pins.shelf(db, user, include_plex=rails.plex_available(db), lang=lang) return { "budget": budget, - "hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf), + "hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf, lang=lang), "continue_watching": resume, "comfort": shelf, } @@ -80,6 +83,7 @@ def get_pick( seed: int = 0, exclude: str = "", start: int = 0, + lang: str = "en", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -99,18 +103,25 @@ def get_pick( return { "budget": budget, "hero": hero_mod.pick( - db, user, budget, seed, exclude=set(list(skip)[:MAX_EXCLUDE]), start=start + db, + user, + budget, + seed, + exclude=set(list(skip)[:MAX_EXCLUDE]), + start=start, + lang=norm_lang(lang), ), } @router.get("/pins") def list_pins( + lang: str = "en", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """The shelf on its own — for the shelf editor, which needs it without the rest of the page.""" - return {"items": pins.shelf(db, user)} + return {"items": pins.shelf(db, user, lang=norm_lang(lang))} @router.post("/pins") diff --git a/backend/app/routes/plex/_shared.py b/backend/app/routes/plex/_shared.py index 78319f9c..af59d693 100644 --- a/backend/app/routes/plex/_shared.py +++ b/backend/app/routes/plex/_shared.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session from app import logs from app.models import PlexItem +from app.plex.cards import norm_lang as _norm_lang # noqa: F401 — the package's historic name from app.plex.lookups import by_rating_key from app.routes._common import not_found @@ -15,8 +16,3 @@ def item_or_404(db: Session, rating_key: str) -> PlexItem: if it is None: raise not_found("plex_item") return it - - -def _norm_lang(lang: str | None) -> str: - """The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN.""" - return "hu" if lang == "hu" else "en" diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 42d04506..61fc25f7 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -10,6 +10,7 @@ from sqlalchemy import ( func, literal, null, + or_, select, ) from sqlalchemy.orm import Session, aliased @@ -25,7 +26,7 @@ from app.models import ( User, ) from app.plex import rollup -from app.plex.cards import episode_card +from app.plex.cards import display_title, episode_card from app.plex.query import ( FIELD_COLUMNS, PLAYBACK_CLASSES, @@ -281,26 +282,32 @@ def unified_library( episodes = [] if tsq is not None and want_shows and field in ("all", "episode"): ep_st = aliased(PlexState) - ep_vec = ( - func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, "")) - if field == "episode" - else PlexItem.search_vector - ) + # In HU an episode is found by its Hungarian title too (E30: the overlay now fills it) — and + # still by its English one, like `vector_match` does for the grid. + if field == "episode": + ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, "")) + ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, "")) + else: + ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu + hit = ep_vec.op("@@")(tsq) + if lang == "hu": + hit = or_(ep_vec_hu.op("@@")(tsq), hit) + ep_vec = ep_vec_hu eps = db.execute( - select(PlexItem, ep_st, PlexShow.title.label("show_title")) + select(PlexItem, ep_st, PlexShow) .outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id)) .outerjoin(PlexShow, PlexShow.id == PlexItem.show_id) .where( PlexItem.kind == "episode", PlexItem.library_id.in_(show_lib_ids), - ep_vec.op("@@")(tsq), + hit, ) .order_by(func.ts_rank(ep_vec, tsq).desc()) .limit(24) ).all() - for e, s, show_title in eps: - card = episode_card(e, s) - card["show_title"] = show_title + for e, s, sh in eps: + card = episode_card(e, s, lang=lang) + card["show_title"] = display_title(sh, lang) if sh is not None else None episodes.append(card) return { diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index f22c9288..0f315677 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -22,7 +22,7 @@ from app.models import ( ) from app.plex import rollup from app.plex import sync as plex_sync -from app.plex.cards import movie_card, show_card +from app.plex.cards import display_title, movie_card, norm_lang, show_card from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.lookups import by_rating_key from app.plex.query import content_libs, runtime_by_key, top_art_keys @@ -229,6 +229,7 @@ def collection_detail( rating_key: str, sort: str = DEFAULT_MEMBER_SORT, sort_dir: str = "asc", + lang: str = "en", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -237,6 +238,7 @@ def collection_detail( control means the same thing on both surfaces; the default is release year ascending, which is what this page did before it had a control. Read-only here; edits go through the admin mutation endpoints below.""" + lang = norm_lang(lang) col = by_rating_key(db, PlexCollection, rating_key) if col is None: raise not_found("collection") @@ -285,7 +287,7 @@ def collection_detail( Member( kind="movie", obj=m, - title=(m.title or "").lower(), + title=display_title(m, lang).lower(), year=m.year, rating=m.rating, added=m.added_at, @@ -298,7 +300,7 @@ def collection_detail( Member( kind="show", obj=sh, - title=(sh.title or "").lower(), + title=display_title(sh, lang).lower(), year=sh.year, rating=sh.rating, added=sh.added_at, @@ -310,9 +312,9 @@ def collection_detail( ] members = sort_members(members, sort, sort_dir) items = [ - movie_card(m.obj, mstate.get(m.obj.id)) + movie_card(m.obj, mstate.get(m.obj.id), lang=lang) if m.kind == "movie" - else show_card(m.obj, sstate.get(m.obj.id, "new")) + else show_card(m.obj, sstate.get(m.obj.id, "new"), lang=lang) for m in members ] years = [m.year for m in members if m.year is not None] diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index 0215ee25..c0798a56 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -107,7 +107,8 @@ def show_detail( db: Session = Depends(get_db), ) -> dict: """A show's seasons + episodes (the drill-down view) with per-user watch state.""" - hu = _norm_lang(lang) == "hu" + lang = _norm_lang(lang) + hu = lang == "hu" sh = by_rating_key(db, PlexShow, rating_key) if sh is None: raise not_found("show") @@ -144,7 +145,7 @@ def show_detail( by_season: dict[int | None, list] = {} all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup for e in eps: - card = episode_card(e, states.get(e.id)) + card = episode_card(e, states.get(e.id), lang=lang) by_season.setdefault(e.season_id, []).append(card) all_cards.append(card) show_roll = rollup.rollup(all_cards) @@ -156,7 +157,7 @@ def show_detail( related: list[dict] = [] try: meta, related_raw = _show_enrichment(db, sh.rating_key) - related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id) + related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id, lang=lang) if sh.people_enriched_at is None and meta: plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta)) plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views @@ -234,7 +235,9 @@ def _season_block(se: PlexSeason, cards: list[dict]) -> dict: } -def _relatedshow_cards(db: Session, user_id: int, related: list[dict], exclude: int) -> list[dict]: +def _relatedshow_cards( + db: Session, user_id: int, related: list[dict], exclude: int, lang: str +) -> list[dict]: """Map Plex 'related' metadata to show cards for the shows we actually mirror (so they're openable), excluding the current show. Order preserved; capped.""" rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")] @@ -254,13 +257,13 @@ def _relatedshow_cards(db: Session, user_id: int, related: list[dict], exclude: if sh is None or rk in seen: continue seen.add(rk) - out.append(show_card(sh, statuses.get(sh.id, "new"))) + out.append(show_card(sh, statuses.get(sh.id, "new"), lang=lang)) if len(out) >= 20: break return out -def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: +def _collection_strips(db: Session, it: PlexItem, user_id: int, lang: str) -> list[dict]: """The collections this movie belongs to, each with its member titles (playable cards) — the 'Avatar Collection' strip on the info page. Pure local lookup (no Plex call).""" keys = it.collection_keys or [] @@ -305,7 +308,7 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: "id": rk, "title": col.title, "source": _collection_source(col), - "items": [movie_card(m, states.get(m.id)) for m in members], + "items": [movie_card(m, states.get(m.id), lang=lang) for m in members], } ) return out @@ -512,7 +515,7 @@ def item_detail( "episode_number": it.episode_number, "prev_id": prev_id, "next_id": next_id, - "collections": _collection_strips(db, it, user.id), + "collections": _collection_strips(db, it, user.id, lang), # TMDB enrichment (E5 S2), served from the row — movies only (episodes aren't enriched). "tmdb": plex_tmdb.serve_block(db, it) if it.kind == "movie" else None, } diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py index 6a6283a6..5adc8f43 100644 --- a/backend/app/routes/plex/discover.py +++ b/backend/app/routes/plex/discover.py @@ -22,7 +22,7 @@ from app.auth import current_user from app.db import get_db from app.models import PlexItem, PlexShow, PlexState, User from app.plex import rollup -from app.plex.cards import leaf_card, movie_card, show_card +from app.plex.cards import display_title, leaf_card, movie_card, show_card from app.plex.query import ( LibraryFilters, apply_meta_filters, @@ -61,14 +61,14 @@ def _movie_meta(q, p: dict, lang: str, f: LibraryFilters): return q -def _movie_cards(db: Session, uid: int, items: list[PlexItem]) -> list[dict]: +def _movie_cards(db: Session, uid: int, items: list[PlexItem], lang: str) -> list[dict]: sts = rollup.item_state_map(db, uid, [it.id for it in items]) - return [movie_card(it, sts.get(it.id)) for it in items] + return [movie_card(it, sts.get(it.id), lang=lang) for it in items] -def _show_cards(db: Session, uid: int, shows: list[PlexShow]) -> list[dict]: +def _show_cards(db: Session, uid: int, shows: list[PlexShow], lang: str) -> list[dict]: statuses = rollup.state_map(db, uid, [s.id for s in shows]) - return [show_card(s, statuses.get(s.id, "new")) for s in shows] + return [show_card(s, statuses.get(s.id, "new"), lang=lang) for s in shows] def _recently_added( @@ -123,8 +123,8 @@ def _recently_added( # share one), so the merge doesn't depend on object identity staying alive. top_movies = [o for _, kind, o in top if kind == "movie"] top_shows = [o for _, kind, o in top if kind == "show"] - mcards = {m.rating_key: c for m, c in zip(top_movies, _movie_cards(db, uid, top_movies))} - scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows))} + mcards = {m.rating_key: c for m, c in zip(top_movies, _movie_cards(db, uid, top_movies, lang))} + scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang))} return [mcards[o.rating_key] if kind == "movie" else scards[o.rating_key] for _, kind, o in top] @@ -156,10 +156,10 @@ def _recently_watched( .order_by(PlexState.watched_at.desc().nullslast()) .limit(RAIL_LIMIT) ).all(): - rows.append((st.watched_at, leaf_card(it, st))) + rows.append((st.watched_at, leaf_card(it, st, lang=lang))) if show_ids: eq = ( - select(PlexItem, PlexState, PlexShow.title, PlexShow.rating_key) + select(PlexItem, PlexState, PlexShow) .join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid)) .join(PlexShow, PlexShow.id == PlexItem.show_id) .where( @@ -170,8 +170,11 @@ def _recently_watched( .order_by(PlexState.watched_at.desc().nullslast()) .limit(RAIL_LIMIT) ) - for ep, st, sh_title, sh_rk in db.execute(eq).all(): - rows.append((st.watched_at, leaf_card(ep, st, show_title=sh_title, show_rk=sh_rk))) + for ep, st, sh in db.execute(eq).all(): + card = leaf_card( + ep, st, show_title=display_title(sh, lang), show_rk=sh.rating_key, lang=lang + ) + rows.append((st.watched_at, card)) rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True) return [card for _, card in rows[:RAIL_LIMIT]] @@ -214,7 +217,7 @@ def _continue_watching( .limit(RAIL_LIMIT) ).all() for it, st in mrows: - rows.append((st.progress_updated_at, movie_card(it, st))) + rows.append((st.progress_updated_at, movie_card(it, st, lang=lang))) if show_ids: # Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus @@ -234,7 +237,7 @@ def _continue_watching( .limit(RAIL_LIMIT) ).all() for sh, resumed_at in srows: - rows.append((resumed_at, show_card(sh, "in_progress"))) + rows.append((resumed_at, show_card(sh, "in_progress", lang=lang))) rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True) return [card for _, card in rows[:RAIL_LIMIT]] @@ -346,9 +349,11 @@ def _because_you_watched( ordered_shows = [shows[rk] for rk in rks if rk in shows] # rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()). mcards = { - m.rating_key: c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies)) + m.rating_key: c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies, lang)) + } + scards = { + s.rating_key: c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang)) } - scards = {s.rating_key: c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows))} items: list[dict] = [] for rk in rks: if rk in mcards: @@ -456,8 +461,8 @@ def surprise( .scalars() .all() ) - mcards = _movie_cards(db, user.id, movies) - scards = _show_cards(db, user.id, shows) + mcards = _movie_cards(db, user.id, movies, lang) + scards = _show_cards(db, user.id, shows, lang) # Interleave movie/show cards, then trim to `limit`. items: list[dict] = [] for i in range(max(len(mcards), len(scards))): diff --git a/backend/app/routes/plex/people.py b/backend/app/routes/plex/people.py index 4b31922a..053d2dec 100644 --- a/backend/app/routes/plex/people.py +++ b/backend/app/routes/plex/people.py @@ -18,7 +18,7 @@ from app.models import ( ) from app.plex import rollup from app.plex import tmdb as plex_tmdb -from app.plex.cards import movie_card, show_card +from app.plex.cards import movie_card, norm_lang, show_card from app.plex.query import content_libs, split_csv router = APIRouter(prefix="/api/plex", tags=["plex"]) @@ -31,6 +31,7 @@ def _resolve_known_for( uid: int, movie_lib_ids: list[int], show_lib_ids: list[int], + lang: str, ) -> list[dict]: """The person's TMDB "Known for" credits (top by popularity) that are IN our library, as cards, in popularity order. Matched by the canonical guid `tmdb:`; out-of-library credits are dropped.""" @@ -50,9 +51,9 @@ def _resolve_known_for( # A credit resolves against its OWN media_type's bucket only. if k.get("media_type") == "tv": if g in shows: - out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new"))) + out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new"), lang=lang)) elif g in movies: - out.append(movie_card(movies[g], m_states.get(movies[g].id))) + out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang)) return out @@ -159,6 +160,7 @@ def _enrich_person_bg(person_id: int) -> None: def person_detail( name: str, background_tasks: BackgroundTasks, + lang: str = "en", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -173,6 +175,7 @@ def person_detail( The fetch runs OFF the request path (a background task); the first open serves whatever's in the row (bio possibly still null) and the bio lands from the row by the next open. """ + lang = norm_lang(lang) person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first() # Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path. # Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded) @@ -206,7 +209,7 @@ def person_detail( .all() ) states = rollup.item_state_map(db, user.id, [it.id for it in items]) - movies = [movie_card(it, states.get(it.id)) for it in items] + movies = [movie_card(it, states.get(it.id), lang=lang) for it in items] shows: list[dict] = [] if show_lib_ids: @@ -227,10 +230,10 @@ def person_detail( .all() ) statuses = rollup.state_map(db, user.id, [s.id for s in show_rows]) - shows = [show_card(sh, statuses.get(sh.id, "new")) for sh in show_rows] + shows = [show_card(sh, statuses.get(sh.id, "new"), lang=lang) for sh in show_rows] known_for = _resolve_known_for( - db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids + db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang ) return { "name": name, diff --git a/backend/app/routes/plex/playlists.py b/backend/app/routes/plex/playlists.py index 0e742652..7d9d4a27 100644 --- a/backend/app/routes/plex/playlists.py +++ b/backend/app/routes/plex/playlists.py @@ -19,7 +19,7 @@ from app.models import ( PlexState, User, ) -from app.plex.cards import leaf_card +from app.plex.cards import display_title, leaf_card, norm_lang from app.plex.lookups import by_rating_key from app.routes._common import owned_or_404, not_found from app.refusal import refused @@ -137,8 +137,12 @@ def create_playlist( @router.get("/playlists/{pid}") def playlist_detail( - pid: int, user: User = Depends(current_user), db: Session = Depends(get_db) + pid: int, + lang: str = "en", + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: + lang = norm_lang(lang) pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist") rows = db.execute(_playlist_item_query(pid)).all() items = [it for (_pi, it) in rows] @@ -163,8 +167,9 @@ def playlist_detail( leaf_card( it, states.get(it.id), - shows[it.show_id].title if it.show_id in shows else None, + display_title(shows[it.show_id], lang) if it.show_id in shows else None, shows[it.show_id].rating_key if it.show_id in shows else None, + lang=lang, ) for it in items ] diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py index 1a6894a0..a798393f 100644 --- a/backend/tests/test_discover.py +++ b/backend/tests/test_discover.py @@ -180,7 +180,7 @@ def test_continue_watching_merges_both_sources_by_recency(db): ) db.commit() - got = rails.continue_watching(db, user) + got = rails.continue_watching(db, user, lang="en") assert [(c["source"], c["id"]) for c in got] == [("plex", "pm1"), ("youtube", "vmed")] assert got[0]["remaining_seconds"] == 100 * 60 - 600 @@ -208,7 +208,7 @@ def test_continue_watching_is_youtube_only_when_plex_is_off(db): ] ) db.commit() - assert {c["source"] for c in rails.continue_watching(db, user)} == {"youtube"} + assert {c["source"] for c in rails.continue_watching(db, user, lang="en")} == {"youtube"} def test_the_resume_rail_obeys_the_channel_blocklist(db): @@ -227,13 +227,13 @@ def test_the_resume_rail_obeys_the_channel_blocklist(db): ) ) db.commit() - assert [c["id"] for c in rails.continue_watching(db, user)] == ["vmed"] + assert [c["id"] for c in rails.continue_watching(db, user, lang="en")] == ["vmed"] db.add(BlockedChannel(user_id=user.id, channel_id=ch.id)) db.commit() - assert rails.continue_watching(db, user) == [] + assert rails.continue_watching(db, user, lang="en") == [] # And so the hero, whose resume tier draws from that rail, has nothing to offer either. - assert hero.pick(db, user, "any", 0) is None + assert hero.pick(db, user, "any", 0, lang="en") is None def test_a_null_timestamp_sorts_last(db): @@ -263,7 +263,7 @@ def test_a_null_timestamp_sorts_last(db): ] ) db.commit() - got = rails.continue_watching(db, user) + got = rails.continue_watching(db, user, lang="en") assert got[0]["id"] == "vshort" assert len(got) == 3 @@ -283,7 +283,7 @@ def test_hero_prefers_resume_and_labels_the_tier(db): ) ) db.commit() - got = hero.pick(db, user, "any", seed=1) + got = hero.pick(db, user, "any", seed=1, lang="en") assert got["tier"] == "resume" assert got["card"]["id"] == "vmed" @@ -302,7 +302,7 @@ def test_hero_skips_a_resume_that_does_not_fit_the_budget(db): ) ) db.commit() - got = hero.pick(db, user, "short", seed=1) + got = hero.pick(db, user, "short", seed=1, lang="en") assert got["tier"] == "surprise" assert got["card"]["duration_seconds"] <= BANDS["short"][1] @@ -312,7 +312,7 @@ def test_hero_falls_back_to_the_shelf_before_guessing(db): user, ch = _seed_yt(db) db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0)) db.commit() - got = hero.pick(db, user, "medium", seed=1) + got = hero.pick(db, user, "medium", seed=1, lang="en") assert got["tier"] == "comfort" assert got["card"]["id"] == "vmed" @@ -332,7 +332,7 @@ def test_hero_resolves_a_show_to_the_episode_that_would_play(db): ) ) db.commit() - got = hero.pick(db, user, "any", seed=1) + got = hero.pick(db, user, "any", seed=1, lang="en") assert got["tier"] == "resume" assert got["card"]["kind"] == "episode" assert got["card"]["id"] == "ps1e2" # ep1 is finished → ep2 is what plays @@ -345,17 +345,19 @@ def test_hero_is_none_when_nothing_fits(db): user, ch = _seed_yt(db) db.execute(text("DELETE FROM videos WHERE id = 'vlong'")) db.commit() - assert hero.pick(db, user, "long", seed=1) is None + assert hero.pick(db, user, "long", seed=1, lang="en") is None def test_surprise_is_reproducible_for_a_seed(db): """The reroll button depends on this: one seed drives both sources, so the same seed must give the same answer and a different seed must be free to give another.""" user, _ = _seed_yt(db) - a = rails.surprise(db, user, "any", seed=7, limit=3) - b = rails.surprise(db, user, "any", seed=7, limit=3) + a = rails.surprise(db, user, "any", seed=7, limit=3, lang="en") + b = rails.surprise(db, user, "any", seed=7, limit=3, lang="en") assert [c["id"] for c in a] == [c["id"] for c in b] - seeds = {tuple(c["id"] for c in rails.surprise(db, user, "any", s, 3)) for s in range(12)} + seeds = { + tuple(c["id"] for c in rails.surprise(db, user, "any", s, 3, lang="en")) for s in range(12) + } assert len(seeds) > 1 # the seed actually reaches the ordering @@ -374,7 +376,9 @@ def test_surprise_never_offers_something_already_started(db): ) ) db.commit() - assert "vlong" not in {c["id"] for c in rails.surprise(db, user, "long", seed=3, limit=5)} + assert "vlong" not in { + c["id"] for c in rails.surprise(db, user, "long", seed=3, limit=5, lang="en") + } def test_the_seed_decides_which_source_leads(db): @@ -384,7 +388,9 @@ def test_the_seed_decides_which_source_leads(db): user, _ = _seed_yt(db) _seed_plex(db, user) _enable_plex(db) - leaders = {rails.surprise(db, user, "any", s, limit=1)[0]["source"] for s in range(6)} + leaders = { + rails.surprise(db, user, "any", s, limit=1, lang="en")[0]["source"] for s in range(6) + } assert leaders == {"youtube", "plex"} @@ -413,10 +419,10 @@ def test_the_reroll_must_be_able_to_move_past_the_resume_tier(db): ) db.commit() - first = hero.pick(db, user, "any", seed=1) + first = hero.pick(db, user, "any", seed=1, lang="en") assert first["card"]["id"] == "vmed" # Same seed, same everything — only the exclusion differs. - second = hero.pick(db, user, "any", seed=1, exclude={"youtube:vmed"}) + second = hero.pick(db, user, "any", seed=1, exclude={"youtube:vmed"}, lang="en") assert second["card"]["id"] == "vshort" @@ -438,7 +444,7 @@ def test_the_reroll_rotates_which_tier_answers(db): ) db.add(ComfortPin(user_id=user.id, video_id="vlong", position=0)) db.commit() - tiers = [hero.pick(db, user, "any", seed=1, start=n)["tier"] for n in range(4)] + tiers = [hero.pick(db, user, "any", seed=1, start=n, lang="en")["tier"] for n in range(4)] assert tiers == ["resume", "comfort", "surprise", "resume"] @@ -447,7 +453,7 @@ def test_the_rotation_still_falls_through_an_empty_tier(db): Here there is no shelf at all, so the comfort-led press has to answer from somewhere else.""" user, _ = _seed_yt(db) assert db.execute(select(func.count()).select_from(ComfortPin)).scalar_one() == 0 - got = hero.pick(db, user, "any", seed=1, start=1) + got = hero.pick(db, user, "any", seed=1, start=1, lang="en") assert got is not None and got["tier"] != "comfort" @@ -468,10 +474,10 @@ def test_excluding_a_show_also_excludes_the_episode_it_resolves_to(db): ) db.commit() - shown = hero.pick(db, user, "any", seed=1) + shown = hero.pick(db, user, "any", seed=1, lang="en") assert shown["card"]["id"] == "ps1e1" # the show resolved to its in-progress episode # Excluding what the USER saw — the show — must move the tier on, not return the episode again. - after = hero.pick(db, user, "any", seed=1, exclude={"plex:ps1"}) + after = hero.pick(db, user, "any", seed=1, exclude={"plex:ps1"}, lang="en") assert after is None or after["card"]["id"] != "ps1e1" @@ -518,7 +524,7 @@ def test_shelf_resolves_a_channel_to_its_newest_unwatched_video(db): user, ch = _seed_yt(db) db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=0)) db.commit() - (card,) = pins.shelf(db, user) + (card,) = pins.shelf(db, user, lang="en") assert card["kind"] == "channel" assert card["unwatched_count"] == 3 assert card["next"]["id"] == "vshort" # newest @@ -538,7 +544,7 @@ def test_shelf_resolves_a_playlist_to_its_first_unwatched_item(db): db.add(VideoState(user_id=user.id, video_id="vshort", status="watched", watched_at=NOW)) db.add(ComfortPin(user_id=user.id, playlist_id=pl.id, position=0)) db.commit() - (card,) = pins.shelf(db, user) + (card,) = pins.shelf(db, user, lang="en") assert card["kind"] == "playlist" assert card["item_count"] == 2 assert card["next"]["id"] == "vmed" @@ -585,7 +591,7 @@ def test_the_shelf_resolves_each_channel_and_playlist_on_its_own_terms(db): got = [ (c.get("unwatched_count", c.get("item_count")), (c["next"] or {}).get("id")) - for c in pins.shelf(db, user) + for c in pins.shelf(db, user, lang="en") ] assert got == [(3, "vshort"), (1, "vother"), (1, "vlong"), (0, None)] @@ -601,14 +607,14 @@ def test_the_shelf_costs_the_same_queries_for_one_pin_or_many(db): db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=0)) db.add(ComfortPin(user_id=user.id, playlist_id=lists[0].id, position=1)) db.commit() - few = _count_statements(db, lambda: pins.shelf(db, user)) + few = _count_statements(db, lambda: pins.shelf(db, user, lang="en")) for i, c in enumerate(extra): db.add(ComfortPin(user_id=user.id, channel_id=c.id, position=10 + i)) for i, p in enumerate(lists[1:]): db.add(ComfortPin(user_id=user.id, playlist_id=p.id, position=20 + i)) db.commit() - assert _count_statements(db, lambda: pins.shelf(db, user)) == few + assert _count_statements(db, lambda: pins.shelf(db, user, lang="en")) == few def test_a_pinned_video_survives_leaving_the_feed_scope(db): @@ -619,7 +625,7 @@ def test_a_pinned_video_survives_leaving_the_feed_scope(db): db.commit() db.execute(delete(Subscription)) db.commit() - (card,) = pins.shelf(db, user) + (card,) = pins.shelf(db, user, lang="en") assert card["id"] == "vmed" assert card["next"]["id"] == "vmed" @@ -633,7 +639,9 @@ def test_the_plex_surprise_obeys_the_time_budget(db): _seed_plex(db, user) _enable_plex(db) drawn = lambda budget: { # noqa: E731 - (c["source"], c["id"]) for s in range(6) for c in rails.surprise(db, user, budget, s, 5) + (c["source"], c["id"]) + for s in range(6) + for c in rails.surprise(db, user, budget, s, 5, lang="en") } assert ("plex", "pm1") not in drawn("short") # …and it IS offered where it fits, so this cannot pass by the rail being empty. @@ -658,7 +666,9 @@ def test_the_plex_surprise_never_offers_something_already_started(db): ) db.commit() drawn = { - (c["source"], c["id"]) for s in range(6) for c in rails.surprise(db, user, "long", s, 5) + (c["source"], c["id"]) + for s in range(6) + for c in rails.surprise(db, user, "long", s, 5, lang="en") } assert ("plex", "pm1") not in drawn @@ -682,7 +692,7 @@ def test_a_new_pin_lands_at_the_end_even_after_a_hole(db): db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=pins.next_position(db, user))) db.commit() - assert [c["kind"] for c in pins.shelf(db, user)] == ["video", "channel"] + assert [c["kind"] for c in pins.shelf(db, user, lang="en")] == ["video", "channel"] positions = ( db.execute(select(ComfortPin.position).order_by(ComfortPin.position)).scalars().all() ) @@ -695,7 +705,7 @@ def test_a_self_playing_pin_does_not_reference_itself(db): user, _ = _seed_yt(db) db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0)) db.commit() - (card,) = pins.shelf(db, user) + (card,) = pins.shelf(db, user, lang="en") assert card["next"] is not card assert "next" not in card["next"] @@ -718,7 +728,7 @@ def test_the_resume_rail_says_what_would_actually_play(db): ) ) db.commit() - rail = rails.continue_watching(db, user) + rail = rails.continue_watching(db, user, lang="en") tile = next(c for c in rail if c["kind"] == "show") assert tile["id"] == "ps1" # still the show assert tile["next"]["kind"] == "episode" @@ -762,7 +772,7 @@ def test_only_a_show_row_pays_for_a_resolution(db): ) db.commit() - rail = rails.continue_watching(db, user) + rail = rails.continue_watching(db, user, lang="en") # A video, a movie and a show — one row of each kind that can appear here, so the assertion below # actually exercises both sides of the equality. assert {c["kind"] for c in rail} == {"video", "movie", "show"} @@ -778,7 +788,7 @@ def test_next_episode_takes_an_episode_with_no_state_row_at_all(db): _, show, ep1 = _seed_plex(db, user) db.add(PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW)) db.commit() # ep2 has NO PlexState row - assert queries.next_episode(db, user.id, show.id)["id"] == "ps1e2" + assert queries.next_episode(db, user.id, show.id, lang="en")["id"] == "ps1e2" def test_a_finished_show_starts_again_from_the_top(db): @@ -793,7 +803,7 @@ def test_a_finished_show_starts_again_from_the_top(db): ] ) db.commit() - assert queries.next_episode(db, user.id, show.id)["id"] == "ps1e1" + assert queries.next_episode(db, user.id, show.id, lang="en")["id"] == "ps1e1" def test_next_episode_agrees_with_the_show_page_when_two_are_in_progress(db): @@ -825,12 +835,16 @@ def test_next_episode_agrees_with_the_show_page_when_two_are_in_progress(db): ) db.commit() - got = queries.next_episode(db, user.id, show.id) + got = queries.next_episode(db, user.id, show.id, lang="en") assert got["id"] == "ps1e2" # Asserted against the other implementation rather than against a literal, so the two cannot # drift apart again without this failing. ordered = [ - leaf_card(ep, db.execute(select(PlexState).where(PlexState.item_id == ep.id)).scalar_one()) + leaf_card( + ep, + db.execute(select(PlexState).where(PlexState.item_id == ep.id)).scalar_one(), + lang="en", + ) for ep in (ep1, ep2) ] assert rollup.rollup(ordered)["resume"]["id"] == got["id"] @@ -848,7 +862,7 @@ def test_next_episode_prefers_the_one_in_progress(db): ] ) db.commit() - assert queries.next_episode(db, user.id, show.id)["id"] == "ps1e2" + assert queries.next_episode(db, user.id, show.id, lang="en")["id"] == "ps1e2" # ── HTTP surface ────────────────────────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_plex_cards.py b/backend/tests/test_plex_cards.py index 8efa25de..fc35fe8c 100644 --- a/backend/tests/test_plex_cards.py +++ b/backend/tests/test_plex_cards.py @@ -11,21 +11,21 @@ from app.plex.cards import movie_card, show_card def test_movie_card_emits_art_when_art_key_present(): it = PlexItem(rating_key="m1", kind="movie", title="M", year=2020, art_key="/library/art/1") - card = movie_card(it, None) + card = movie_card(it, None, lang="en") assert card["thumb"] == "/api/plex/image/m1" assert card["art"] == "/api/plex/image/m1?variant=art" def test_movie_card_art_is_none_without_art_key(): it = PlexItem(rating_key="m2", kind="movie", title="M2") - assert movie_card(it, None)["art"] is None + assert movie_card(it, None, lang="en")["art"] is None def test_show_card_emits_art_when_art_key_present(): sh = PlexShow(rating_key="s1", title="S", art_key="/library/art/2") - assert show_card(sh)["art"] == "/api/plex/image/s1?variant=art" + assert show_card(sh, lang="en")["art"] == "/api/plex/image/s1?variant=art" def test_show_card_art_is_none_without_art_key(): sh = PlexShow(rating_key="s2", title="S2") - assert show_card(sh)["art"] is None + assert show_card(sh, lang="en")["art"] is None diff --git a/backend/tests/test_plex_lang.py b/backend/tests/test_plex_lang.py index 8dc185d4..0790f804 100644 --- a/backend/tests/test_plex_lang.py +++ b/backend/tests/test_plex_lang.py @@ -12,7 +12,18 @@ import app.state as state from app.auth import current_user from app.db import get_db from app.main import app -from app.models import PlexItem, PlexLibrary, User +from app import sysconfig +from app.models import ( + PlexCollection, + PlexItem, + PlexLibrary, + PlexPlaylist, + PlexPlaylistItem, + PlexSeason, + PlexShow, + PlexState, + User, +) def _seed(db): @@ -162,3 +173,99 @@ def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch, override_dep) assert "High IMDb" in _titles(hit) miss = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&rating_min=9") assert "High IMDb" not in _titles(miss) + + +def _seed_rails(db): + """`_seed` plus what every card rail reads: a HU-overlaid show with a HU-overlaid episode in + progress, a cast name, a collection, and a playlist holding the movie and the episode.""" + user = _seed(db) + m1 = db.query(PlexItem).filter_by(rating_key="m1").one() + m1.cast_names = ["Anna"] + m1.collection_keys = ["c1"] + db.add(PlexCollection(rating_key="c1", library_id=m1.library_id, title="Coll")) + tv = PlexLibrary(plex_key="2", title="TV Shows", kind="show", enabled=True) + db.add(tv) + db.flush() + show = PlexShow( + rating_key="s1", + library_id=tv.id, + title="English Show", + title_hu="Magyar Sorozat", + added_at=datetime(2024, 1, 3, tzinfo=timezone.utc), + ) + db.add(show) + db.flush() + season = PlexSeason(rating_key="s1s1", show_id=show.id, title="Season 1", season_number=1) + db.add(season) + db.flush() + ep = PlexItem( + rating_key="e1", + library_id=tv.id, + kind="episode", + show_id=show.id, + season_id=season.id, + title="Pilot", + title_hu="Kezdet", + season_number=1, + episode_number=1, + duration_s=3000, + ) + db.add(ep) + db.flush() + db.add( + PlexState( + user_id=user.id, + item_id=ep.id, + status="in_progress", + position_seconds=600, + progress_updated_at=datetime(2024, 2, 1, tzinfo=timezone.utc), + ) + ) + pl = PlexPlaylist(user_id=user.id, title="Mix") + db.add(pl) + db.flush() + db.add_all( + [ + PlexPlaylistItem(playlist_id=pl.id, item_id=m1.id, position=0), + PlexPlaylistItem(playlist_id=pl.id, item_id=ep.id, position=1), + ] + ) + sysconfig.set_value(db, "plex_enabled", True) + db.commit() + return user, pl.id + + +def test_every_card_rail_serves_the_readers_language(db, monkeypatch, override_dep): + """E30: the grid spoke Hungarian, but every other card surface (Discover rails, show page, + search, collection, person page, playlist, Home) served the English title to a HU reader.""" + user, pid = _seed_rails(db) + + def get(path): + return _get(db, user, monkeypatch, override_dep, path).json() + + def titles(cards): + return {c["title"] for c in cards} + + rails = get("/api/plex/discover?scope=both&lang=hu") + assert {"Magyar Cím", "Magyar Sorozat"} <= titles(rails["recently_added"]) + assert "Magyar Sorozat" in titles(rails["continue_watching"]) + assert titles(get("/api/plex/show/s1?lang=hu")["seasons"][0]["episodes"]) == {"Kezdet"} + found = get("/api/plex/library?scope=both&lang=hu&q=Kezdet")["episodes"] + assert [(e["title"], e["show_title"]) for e in found] == [("Kezdet", "Magyar Sorozat")] + assert titles(get("/api/plex/collections/c1?lang=hu")["items"]) == {"Magyar Cím"} + assert titles(get("/api/plex/person/Anna?lang=hu")["movies"]) == {"Magyar Cím"} + items = get(f"/api/plex/playlists/{pid}?lang=hu")["items"] + assert [(c["title"], c.get("show_title")) for c in items] == [ + ("Magyar Cím", None), + ("Kezdet", "Magyar Sorozat"), + ] + home = get("/api/discover?lang=hu")["continue_watching"] + plex = [c for c in home if c["source"] == "plex"] + assert [c["title"] for c in plex] == ["Magyar Sorozat"] + assert plex[0]["next"]["title"] == "Kezdet" + # EN (the default) still serves English on the same rails. + items = get(f"/api/plex/playlists/{pid}")["items"] + assert [(c["title"], c.get("show_title")) for c in items] == [ + ("English Title", None), + ("Pilot", "English Show"), + ] diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index 8b447c32..e3e1a886 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -9,6 +9,7 @@ // identical card straight back and the button looks dead. // i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside // a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. +import { setLanguage } from "../i18n"; import { StrictMode } from "react"; import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest"; import { act, render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react"; @@ -461,7 +462,7 @@ describe("Home", () => { mount(); await screen.findByText("Hero v1"); fireEvent.click(screen.getByText("home.budget.short")); // the empty-state branch needs a band - await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0)); + await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0, "en")); // A press that RESOLVES with nothing left: the honest empty state. discoverPick.mockResolvedValueOnce({ budget: "short", hero: null }); @@ -509,7 +510,7 @@ describe("Home", () => { // A never-resolving refetch: the point is what the page shows WHILE it waits. discover.mockReturnValue(new Promise(() => {})); fireEvent.click(screen.getByText("home.budget.short")); - await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0)); + await waitFor(() => expect(discover).toHaveBeenLastCalledWith("short", 0, "en")); // Still the old answer — not a spinner that would take the chips away with it. expect(screen.getByText("Hero v1")).toBeTruthy(); }); @@ -523,7 +524,7 @@ describe("Home", () => { await screen.findByText("Hero v1"); fireEvent.click(screen.getByText("home.hero.reroll")); await waitFor(() => - expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"], 1), + expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), ["youtube:v1"], 1, "en"), ); expect(await screen.findByText("Rerolled")).toBeTruthy(); }); @@ -584,7 +585,7 @@ describe("Home", () => { mount(); expect(await screen.findByText("Rerolled")).toBeTruthy(); - expect(discover).toHaveBeenLastCalledWith("short", 0); + expect(discover).toHaveBeenLastCalledWith("short", 0, "en"); // The very same question — seed, the cards already seen and the press count all came back, not // just the budget: the server answers each of them differently. await waitFor(() => expect(discoverPick.mock.lastCall).toEqual(asked)); @@ -613,7 +614,7 @@ describe("Home", () => { // …and on the press count the server reads as "which tier answers first": 2 leads with the // SURPRISE tier, which is what a command labelled "Meglepetés" promised. A plain next press (1) // led with the shelf, so anyone with one got "A polcodról" instead (owner's call, 2026-09-19). - expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 2); + expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 2, "en"); }); // The rotation carries on from the requested tier: the page's own button after a palette surprise @@ -753,7 +754,13 @@ describe("Home", () => { await screen.findByText("Hero v1"); fireEvent.click(screen.getByText("home.hero.reroll")); await waitFor(() => - expect(discoverPick).toHaveBeenLastCalledWith("any", expect.any(Number), ["youtube:v1"], 1), + expect(discoverPick).toHaveBeenLastCalledWith( + "any", + expect.any(Number), + ["youtube:v1"], + 1, + "en", + ), ); await screen.findByText("Rerolled"); fireEvent.click(screen.getByText("home.hero.reroll")); @@ -767,6 +774,7 @@ describe("Home", () => { // reinstate the measured nine-card cycle) left the whole suite green. ["youtube:v1", "youtube:v9"], 2, + "en", ), ); }); @@ -872,3 +880,16 @@ describe("Home's hero text", () => { expect(text.querySelector("p.measure")).toBeNull(); }); }); + +describe("Home · the reader's language (E30)", () => { + it("asks for the page in Hungarian, so a Plex card comes back with its Hungarian title", async () => { + setLanguage("hu"); + try { + mount(); + await waitFor(() => expect(discover).toHaveBeenCalled()); + expect(discover.mock.calls[0]?.[2]).toBe("hu"); + } finally { + setLanguage("en"); + } + }); +}); diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 1c92dd08..06534a5d 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -14,6 +14,7 @@ import { Star, } from "lucide-react"; import { qk } from "../lib/queryKeys"; +import { plexLang } from "../i18n"; import { api, DISCOVER_BUDGETS, @@ -720,17 +721,19 @@ export default function Home() { }, [budget, seed, press, seen]); const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null); + // A Plex card's title is in the reader's language (E30), so the answer is cached per language. + const lang = plexLang(); const q = useQuery({ - queryKey: qk.discover(budget), - queryFn: () => api.discover(budget, 0), + queryKey: qk.discover(budget, lang), + queryFn: () => api.discover(budget, 0, lang), // Keep the previous budget's answer on screen while the new one loads. Without this the page // falls back to `isPending` and replaces itself — chips included — with a spinner, so the // control you just clicked vanishes under your cursor. (Same reason the feed does it.) placeholderData: keepPreviousData, }); const pickQ = useQuery({ - queryKey: qk.discoverPick(budget, seed, seen, press), - queryFn: () => api.discoverPick(budget, seed, seen, press), + queryKey: qk.discoverPick(budget, lang, seed, seen, press), + queryFn: () => api.discoverPick(budget, seed, seen, press, lang), enabled: seed !== 0, staleTime: 0, // Hold the previous hero while the next one is in flight. Without this the reroll's new query key @@ -931,7 +934,7 @@ export default function Home() { const moveInShelf = useCallback( (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => { if (shelfSavingRef.current) return; - const key = qk.discover(budget); + const key = qk.discover(budget, lang); // the query's own key: setQueryData matches exactly const cards = rendered; const from = cards.findIndex((c) => c.pin_id === card.pin_id); const target = shelfMoveTarget(from, to, cards.length); @@ -965,7 +968,7 @@ export default function Home() { void qc.invalidateQueries({ queryKey: qk.discover() }); }); }, - [budget, qc, t], + [budget, lang, qc, t], ); const reroll = useCallback( diff --git a/frontend/src/components/PlexCollectionView.dom.test.tsx b/frontend/src/components/PlexCollectionView.dom.test.tsx index c74a4509..e760402d 100644 --- a/frontend/src/components/PlexCollectionView.dom.test.tsx +++ b/frontend/src/components/PlexCollectionView.dom.test.tsx @@ -5,6 +5,7 @@ // expanded (lazily, on click) into its episodes in season→episode order; a show that expands to zero // episodes must NOT silently no-op but surface the `playEmpty` info notice. api.plexCollection / // api.plexShow are stubbed so the queries resolve synchronously; notify is spied to assert the notice. +import { setLanguage } from "../i18n"; import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -119,7 +120,7 @@ describe("PlexCollectionView — Play-all", () => { const onPlay = mount([card("show1", "show")]); fireEvent.click(await screen.findByText("plex.collection.playAll")); await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1)); - expect(spyShow).toHaveBeenCalledWith("show1"); + expect(spyShow).toHaveBeenCalledWith("show1", "en"); expect(onPlay).toHaveBeenCalledWith("s1e1", ["s1e1", "s1e2", "s2e1"]); }); @@ -205,3 +206,19 @@ describe("PlexCollectionView — after the sort joined the query key", () => { expect(screen.queryByText("plex.collection.playAll")).toBeNull(); }); }); + +describe("PlexCollectionView — the reader's language (E30)", () => { + it("asks for the members, and a show it expands, in Hungarian", async () => { + setLanguage("hu"); + try { + const spyShow = vi.spyOn(api, "plexShow").mockResolvedValue(showDetail([["s1e1"]])); + const onPlay = mount([card("show1", "show")]); + fireEvent.click(await screen.findByText("plex.collection.playAll")); + await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1)); + expect(vi.mocked(api.plexCollection).mock.calls[0]?.[2]).toBe("hu"); + expect(spyShow).toHaveBeenCalledWith("show1", "hu"); + } finally { + setLanguage("en"); + } + }); +}); diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 14e82b62..9e1676e1 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react"; import { qk } from "../lib/queryKeys"; +import { plexLang } from "../i18n"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { formatRuntime } from "../lib/format"; import { notifyActionError } from "../lib/actionErrors"; @@ -83,10 +84,11 @@ export default function PlexCollectionView({ // ONE key for the read and every write into it: when the sort joined the key, the watched toggle's // optimistic write kept the old spelling and landed on an entry nothing reads (setQueryData matches // exactly), so the click did nothing visible until a reload. - const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir); + const lang = plexLang(); + const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir, lang); const q = useQuery({ queryKey: detailKey, - queryFn: () => api.plexCollection(collectionId, sortPref), + queryFn: () => api.plexCollection(collectionId, sortPref, lang), // A re-sort keeps the current page on screen while the new order loads. Without it every sort // change was a key with no data yet, and the whole page — the Sort control and its open panel // with it — was swapped for a spinner, so key and direction could not be set in one visit. Only @@ -134,8 +136,8 @@ export default function PlexCollectionView({ items.map(async (c) => { if (c.type !== "show") return [c.id]; const detail = await qc.fetchQuery({ - queryKey: qk.plexShow(c.id), - queryFn: () => api.plexShow(c.id), + queryKey: qk.plexShow(c.id, lang), + queryFn: () => api.plexShow(c.id, lang), }); return showEpisodeKeys(detail); }), diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx index 61ff96c7..1bf4b876 100644 --- a/frontend/src/components/PlexPersonView.dom.test.tsx +++ b/frontend/src/components/PlexPersonView.dom.test.tsx @@ -5,6 +5,7 @@ // filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero, // the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough // and the api is mocked so the query resolves synchronously in the test. +import { setLanguage } from "../i18n"; import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -123,7 +124,7 @@ describe("PlexPersonView", () => { fireEvent.click(screen.getByText("plex.person.filterBy")); expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] }); - expect(plexPerson).toHaveBeenCalledWith("Jane Doe"); + expect(plexPerson).toHaveBeenCalledWith("Jane Doe", "en"); }); it("shows the empty message when the person has no titles", async () => { @@ -148,3 +149,33 @@ describe("PlexPersonView", () => { await screen.findByText("plex.person.empty"); }); }); + +describe("PlexPersonView — the reader's language (E30)", () => { + it("asks for the filmography in Hungarian", async () => { + setLanguage("hu"); + try { + plexPerson.mockResolvedValue({ + name: "Jane Doe", + photo: null, + also_known_as: [], + known_for: [], + movies: [card("m1", "Egy film")], + shows: [], + }); + wrap( + {}} + onOpenCard={() => {}} + onRestartCard={() => {}} + onInfoCard={() => {}} + onFilter={() => {}} + />, + ); + await screen.findByText("Egy film"); + expect(plexPerson).toHaveBeenCalledWith("Jane Doe", "hu"); + } finally { + setLanguage("en"); + } + }); +}); diff --git a/frontend/src/components/PlexPlaylistReorder.dom.test.tsx b/frontend/src/components/PlexPlaylistReorder.dom.test.tsx index 768e5744..8697e4e7 100644 --- a/frontend/src/components/PlexPlaylistReorder.dom.test.tsx +++ b/frontend/src/components/PlexPlaylistReorder.dom.test.tsx @@ -89,7 +89,7 @@ it("writes the order the server accepted into the detail cache, without waiting // staleTime showed the reorder undone, and the next drag persisted that. await waitFor(() => expect( - qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))?.items.map((i) => i.id), + qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))?.items.map((i) => i.id), ).toEqual(["2", "1", "3"]), ); }); @@ -124,7 +124,7 @@ it("refetches the detail too, so a membership change that landed meanwhile is no await waitFor(() => expect(screen.queryByText("Movie 3")).toBeNull()); await waitFor(() => expect( - qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))?.items.map((i) => i.id), + qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))?.items.map((i) => i.id), ).toEqual(["2", "1"]), ); }); diff --git a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx index 92d6a9d0..10057d3a 100644 --- a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx +++ b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx @@ -4,6 +4,7 @@ // beside them, a right-click, a long-press, Shift+F10) instead of a red X at rest; removing needs no // confirm, since the toast's Undo puts the rows back; `j`/`k` walk the visible rows and Delete takes // out the one the keyboard is on; the playlist's rename and delete sit in the header's "…". +import { setLanguage } from "../i18n"; import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -346,3 +347,16 @@ describe("PlexPlaylistView · a failed watched toggle", () => { expect(menuRows()).toContain("plex.markWatched"); }); }); + +describe("PlexPlaylistView · the reader's language (E30)", () => { + it("asks for the rows in Hungarian", async () => { + setLanguage("hu"); + try { + mount([movie("1")]); + await screen.findByText("Movie 1"); + expect(api.plexPlaylist).toHaveBeenCalledWith(5, "hu"); + } finally { + setLanguage("en"); + } + }); +}); diff --git a/frontend/src/components/comfortPin.tsx b/frontend/src/components/comfortPin.tsx index 40890ca1..7ac84b9e 100644 --- a/frontend/src/components/comfortPin.tsx +++ b/frontend/src/components/comfortPin.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pin, PinOff } from "lucide-react"; +import { plexLang } from "../i18n"; import { api, type DiscoverCard, type PinKind } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { notify } from "../lib/notifications"; @@ -104,8 +105,8 @@ export function useComfortPin(open: boolean): ComfortPin { if (open && !everOpen) setEverOpen(true); const shelf = useQuery({ - queryKey: qk.comfortPins(), - queryFn: () => api.comfortPins(), + queryKey: qk.comfortPins(plexLang()), + queryFn: () => api.comfortPins(plexLang()), enabled: everOpen, // The shelf is hand-curated and short; re-asking on every menu open would be noise. staleTime: 60_000, diff --git a/frontend/src/components/plexCards.lang.dom.test.tsx b/frontend/src/components/plexCards.lang.dom.test.tsx new file mode 100644 index 00000000..133070c0 --- /dev/null +++ b/frontend/src/components/plexCards.lang.dom.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +// +// E30: what a Plex card says, in the reader's language. An episode in a poster rail stands as its show +// (the show's poster and name, the episode on the line under it), and a season Plex named generically +// ("Season 1", "Specials") is named from its number instead. i18n here echoes the key AND its values, +// so an assertion can see which words went into the line. +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + const t = (key: string, o?: Record) => + o ? `${key}(${Object.values(o).join("|")})` : key; + return { ...actual, useTranslation: () => ({ t }) }; +}); + +import { PlexPosterCard, SeasonCard } from "./plexCards"; +import type { PlexCard, PlexSeasonDetail } from "../lib/api"; + +afterEach(cleanup); + +const watchedEpisode: PlexCard = { + id: "e5", + type: "episode", + title: "Visszaszámlálás", + thumb: "/api/plex/image/e5", + season_number: 2, + episode_number: 5, + show_title: "A 3-test-probléma", + show_id: "s9", +}; + +describe("an episode in a poster rail", () => { + it("stands as its show: the show's poster and name, the episode under it", () => { + render(); + const img = document.querySelector("img"); + expect(img?.getAttribute("src")).toBe("/api/plex/image/s9"); + expect(screen.getByText("A 3-test-probléma")).toBeTruthy(); + expect(screen.getByText("plex.episodeLine(2|5|Visszaszámlálás)")).toBeTruthy(); + }); + + it("keeps its own landscape still where the rail is landscape, or the show is unknown", () => { + const { unmount } = render(); + expect(document.querySelector("img")?.getAttribute("src")).toBe("/api/plex/image/e5"); + unmount(); + render(); + expect(document.querySelector("img")?.getAttribute("src")).toBe("/api/plex/image/e5"); + expect(screen.getByText("Visszaszámlálás")).toBeTruthy(); + }); +}); + +const season = (title: string, n: number | null): PlexSeasonDetail => ({ + id: "se", + season_number: n, + title, + thumb: "", + episode_count: 3, + status: "new", + episodes: [], +}); + +describe("a season's name", () => { + it.each([ + ["Season 1", 1, "plex.seasonTitle(1)"], + ["Specials", 0, "plex.specials"], + ["Book One: Water", 1, "Book One: Water"], + ])("Plex's %j (season %s) reads %j", (title, n, shown) => { + render(); + expect(screen.getByText(shown)).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index f29e23d1..5c1fa1d9 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -5,6 +5,7 @@ import { type ReactNode, useRef } from "react"; import { formatRuntime } from "../lib/format"; import { Carousel } from "./ui/Carousel"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; +import { seasonTitle } from "../lib/plexDetail"; import { navItemAttrs } from "../lib/listNav"; import { endsTextSelection, movedPastSlop } from "../lib/textSelection"; import { plexAddLabelKey, plexStarted } from "./usePlexCardActions"; @@ -288,7 +289,12 @@ export function PlexPosterCard({ }) { const { t } = useTranslation(); const preview = useMediaPreview(); - const landscape = wide || card.type === "episode"; + // An episode in a poster rail (Discover's "Recently watched") stands as its SHOW: the show's poster + // and name, the episode on the line under it (E30, option A). Its own 16:9 still in a poster slot + // was a cropped frame titled only "Countdown". Needs the show (`show_id`, from a leaf card). + const asShow = card.type === "episode" && !wide && !!card.show_id && !!card.show_title; + const landscape = wide || (card.type === "episode" && !asShow); + const name = asShow ? `${card.show_title} · ${card.title}` : card.title; const ep = card.type === "show" ? (next ?? null) : null; // movies/episodes play; a show opens its drill-down — unless it plays on (`next`) const isPlayable = card.type !== "show" || !!ep; @@ -320,7 +326,7 @@ export function PlexPosterCard({ dense rootProps={preview.rootProps({ ...menuProps?.(card), - ...(navIndex !== undefined && navItemAttrs(navIndex, card.title)), + ...(navIndex !== undefined && navItemAttrs(navIndex, name)), })} // content-visibility keeps off-screen posters cheap to render (smoother long-list scroll); // a carousel turns it off (index.css `.media-rail`). @@ -328,8 +334,12 @@ export function PlexPosterCard({ picture={ - {card.title} + {asShow ? card.show_title : card.title} } meta={ - epCode ? ( + asShow ? ( +
+ {t("plex.episodeLine", { + season: card.season_number ?? "?", + episode: card.episode_number ?? "?", + title: card.title, + })} +
+ ) : epCode ? (
{t("plex.nextUp", epCode)}
) : ( (!!card.year || card.rating != null) && ( @@ -383,7 +401,7 @@ export function PlexPosterCard({ /> {[card.year, corner].filter(Boolean).join(" · ")} @@ -443,6 +461,7 @@ export function SeasonCard({ navIndex?: number; }) { const { t } = useTranslation(); + const title = seasonTitle(se, t); const watched = se.status === "watched"; const m = more(se, menuButtonProps, menuOpen); return ( @@ -450,14 +469,14 @@ export function SeasonCard({ dense rootProps={{ ...menuProps?.(se), - ...(navIndex !== undefined && navItemAttrs(navIndex, se.title)), + ...(navIndex !== undefined && navItemAttrs(navIndex, title)), }} picture={ // A season opens its episode list: no Play on hover, since the click does not play. } - title={{se.title}} + title={{title}} more={m} moreLabel={t("card.menu.more")} /> diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 9f6d910f..0936e0af 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -34,7 +34,13 @@ import { Filterable, WatchedToggle, } from "./plexDetailUi"; -import { epLabel, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail"; +import { + epLabel, + seasonTitle, + tmdbBackdropUrl, + useCrossfadeBackdrop, + useDetailPrefs, +} from "../lib/plexDetail"; import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards"; import { PlexMenu } from "./PlexMenu"; import { usePlexCardActions } from "./usePlexCardActions"; @@ -136,7 +142,9 @@ export function PlexPersonView({ // lists on one screen, and the popover anchors where it opened, so a second controller would only // mean a second closed popover to keep shut (see ui/ContextMenu). const menu = useContextMenu(); - const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) }); + const lang = plexLang(); + const personKey = qk.plexPerson(name, lang); + const q = useQuery({ queryKey: personKey, queryFn: () => api.plexPerson(name, lang) }); // E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls // out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control. const { artBg, crossfade } = useDetailPrefs(); @@ -153,7 +161,7 @@ export function PlexPersonView({ const toggle = (card: PlexCard) => toggleWatchState(card, t, { optimistic: (next) => - qc.setQueryData(qk.plexPerson(name), (old) => + qc.setQueryData(personKey, (old) => old ? { ...old, @@ -402,7 +410,7 @@ export function PlexShowView({ setAddTarget({ kind: "group", ratingKeys: se.episodes.map((e) => e.id), - title: `${show.title} — ${se.title}`, + title: `${show.title} — ${seasonTitle(se, t)}`, }) : undefined; // A season's one action list (E28 C1): the rows its card shows on hover, plus copy. @@ -435,7 +443,7 @@ export function PlexShowView({ }, ] : []), - ...copy(se.title, null).map((it) => ({ ...it, separatorBefore: true })), + ...copy(seasonTitle(se, t), null).map((it) => ({ ...it, separatorBefore: true })), ]; }; @@ -750,7 +758,9 @@ export function PlexSeasonView({ - {se.title} + + {seasonTitle(se, t)} +

{t("plex.series.episodeCount", { count: se.episode_count })}

@@ -785,7 +795,7 @@ export function PlexSeasonView({ setAddTarget({ kind: "group", ratingKeys: seasonKeys, - title: `${d.show.title} — ${se.title}`, + title: `${d.show.title} — ${seasonTitle(se, t)}`, }) } icon={ListPlus} diff --git a/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx b/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx index 5aedae94..d3e3c96a 100644 --- a/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx +++ b/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx @@ -75,7 +75,7 @@ describe("usePlexPlaylistEdits · the reorder", () => { act(() => hook.result.current.commit([movie("2"), movie("1")])); await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1"])); await waitFor(() => - expect(ids(qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))!.items)).toEqual([ + expect(ids(qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))!.items)).toEqual([ "2", "1", ]), @@ -100,7 +100,11 @@ describe("usePlexPlaylistEdits · the reorder", () => { // the same `q.data`, and could never correct a stale rollback. detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never); act(() => - qc.setQueryData(qk.plexPlaylist(5), { id: 5, title: "F", items: [movie("1"), movie("3")] }), + qc.setQueryData(qk.plexPlaylist(5, "en"), { + id: 5, + title: "F", + items: [movie("1"), movie("3")], + }), ); // The view has taken the refreshed list in BEFORE the reorder fails — the race as it happens. // (react-query batches its notification, so without this wait the refresh landed only after the diff --git a/frontend/src/components/usePlexPlaylistEdits.ts b/frontend/src/components/usePlexPlaylistEdits.ts index 7f497f47..273cbfb0 100644 --- a/frontend/src/components/usePlexPlaylistEdits.ts +++ b/frontend/src/components/usePlexPlaylistEdits.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { plexLang } from "../i18n"; import { api, type PlexCard } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { notify } from "../lib/notifications"; @@ -15,9 +16,13 @@ export function usePlexPlaylistEdits(playlistId: number) { const { t } = useTranslation(); const qc = useQueryClient(); const [busy, setBusy] = useState(false); + // The rows' titles are in the reader's language (E30), so the cache entry is per language — and ONE + // key serves the read and every write below (setQueryData matches exactly). + const lang = plexLang(); + const key = qk.plexPlaylist(playlistId, lang); const q = useQuery({ - queryKey: qk.plexPlaylist(playlistId), - queryFn: () => api.plexPlaylist(playlistId), + queryKey: key, + queryFn: () => api.plexPlaylist(playlistId, lang), }); // Local optimistic copy so drag/remove are instant; re-synced whenever the query returns. const [items, setItems] = useState([]); @@ -43,7 +48,7 @@ export function usePlexPlaylistEdits(playlistId: number) { // it, and the next drag persisted an order containing it (deep-review round 5). `Playlists.tsx`'s // sibling `restoreFromCache` reads it live for the same reason. const putBack = () => { - const cur = qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId)); + const cur = qc.getQueryData<{ items: PlexCard[] }>(key); if (cur) setItems(cur.items); invalidate(); }; @@ -63,9 +68,7 @@ export function usePlexPlaylistEdits(playlistId: number) { // next drag from there persisted it, silently undoing the one the server took. The // YouTube sibling (`Playlists.tsx`) got this in round 5; this half was left behind (round // 6). One accepted write, one cache entry that matches it. - qc.setQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId), (old) => - old ? { ...old, items: next } : old, - ); + qc.setQueryData<{ items: PlexCard[] }>(key, (old) => (old ? { ...old, items: next } : old)); qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) }); // …and then REFETCH it, exactly as the sibling does. `next` is this render's membership, and // the server only reorders the keys it was given — so anything that landed meanwhile (a row @@ -112,8 +115,8 @@ export function usePlexPlaylistEdits(playlistId: number) { onClick: () => void qc .fetchQuery({ - queryKey: qk.plexPlaylist(playlistId), - queryFn: () => api.plexPlaylist(playlistId), + queryKey: key, + queryFn: () => api.plexPlaylist(playlistId, lang), staleTime: 0, }) .then((now) => { diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index ab172c69..20c5eeb5 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -124,6 +124,9 @@ "markNextWatched": "Mark S{{season}}·E{{episode}} watched", "markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched", "nextUp": "Next: S{{season}}·E{{episode}}", + "episodeLine": "S{{season}}·E{{episode}} · {{title}}", + "seasonTitle": "Season {{n}}", + "specials": "Specials", "watchedFailed": "Couldn't update the watched state — please try again", "seasons": "{{count}} seasons", "unified": { diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 0f92d39b..de19570e 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -124,6 +124,9 @@ "markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl", "markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl", "nextUp": "Következik: S{{season}}·E{{episode}}", + "episodeLine": "S{{season}}·E{{episode}} · {{title}}", + "seasonTitle": "{{n}}. évad", + "specials": "Különkiadások", "watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra", "seasons": "{{count}} évad", "unified": { diff --git a/frontend/src/lib/api/discover.dom.test.ts b/frontend/src/lib/api/discover.dom.test.ts index 960e2e82..880ba3aa 100644 --- a/frontend/src/lib/api/discover.dom.test.ts +++ b/frontend/src/lib/api/discover.dom.test.ts @@ -50,3 +50,16 @@ describe("discoverApi query strings", () => { expect(lastUrl()).not.toContain("exclude="); }); }); + +describe("the reader's language (E30)", () => { + it("asks for Hungarian Plex cards on every Home read, and leaves English implied", async () => { + await discoverApi.discover("any", 0, "hu"); + expect(lastUrl()).toContain("lang=hu"); + await discoverApi.discoverPick("any", 1, [], 0, "hu"); + expect(lastUrl()).toContain("lang=hu"); + await discoverApi.comfortPins("hu"); + expect(lastUrl()).toContain("lang=hu"); + await discoverApi.discover("any", 0, "en"); + expect(lastUrl()).not.toContain("lang="); + }); +}); diff --git a/frontend/src/lib/api/discover.ts b/frontend/src/lib/api/discover.ts index e2dfab03..c69e1118 100644 --- a/frontend/src/lib/api/discover.ts +++ b/frontend/src/lib/api/discover.ts @@ -71,8 +71,9 @@ export interface DiscoverPayload { } export const discoverApi = { - discover: (budget: DiscoverBudget, seed: number): Promise => - req(`/api/discover?budget=${budget}&seed=${seed}`), + // `lang` "hu" serves a Plex card's Hungarian title (E30); en is the server default. + discover: (budget: DiscoverBudget, seed: number, lang?: string): Promise => + req(`/api/discover?budget=${budget}&seed=${seed}${lang === "hu" ? "&lang=hu" : ""}`), // The "Mást kérek" reroll: a new hero alone, so pressing it does not re-fetch the rails under the // user's cursor. // `exclude` is the rolling list of `:` keys already shown. Without it a reroll only @@ -84,12 +85,15 @@ export const discoverApi = { seed: number, exclude: string[] = [], press = 0, + lang?: string, ): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> => req( `/api/discover/pick?budget=${budget}&seed=${seed}&start=${press}` + - (exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : ""), + (exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : "") + + (lang === "hu" ? "&lang=hu" : ""), ), - comfortPins: (): Promise<{ items: DiscoverCard[] }> => req("/api/discover/pins"), + comfortPins: (lang?: string): Promise<{ items: DiscoverCard[] }> => + req(`/api/discover/pins${lang === "hu" ? "?lang=hu" : ""}`), addComfortPin: ( kind: PinKind, ref: string, diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index ed84d057..5941e6a8 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -495,12 +495,13 @@ export const plexApi = { }, // One collection's hero + its members in the requested order (the Collection info page). The sort // vocabulary is the browse grid's, so the same Sort control means the same thing on both. - plexCollection: (rk: string, sort?: SortState): Promise => { + plexCollection: (rk: string, sort?: SortState, lang?: string): Promise => { const u = new URLSearchParams(); if (sort) { u.set("sort", sort.key); u.set("sort_dir", sort.dir); } + if (lang === "hu") u.set("lang", "hu"); const qs = u.toString(); return req(`/api/plex/collections/${encodeURIComponent(rk)}${qs ? `?${qs}` : ""}`); }, @@ -552,7 +553,8 @@ export const plexApi = { const qs = p.toString(); return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`); }, - plexPlaylist: (id: number): Promise => req(`/api/plex/playlists/${id}`), + plexPlaylist: (id: number, lang?: string): Promise => + req(`/api/plex/playlists/${id}${lang === "hu" ? "?lang=hu" : ""}`), plexCreatePlaylist: (title: string, item_rating_key?: string): Promise => req(`/api/plex/playlists`, { method: "POST", @@ -636,8 +638,8 @@ export const plexApi = { `/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`, ), // One cast/crew member's filmography: cached headshot + movies and shows (cast OR directors). - plexPerson: (name: string): Promise => - req(`/api/plex/person/${encodeURIComponent(name)}`), + plexPerson: (name: string, lang?: string): Promise => + req(`/api/plex/person/${encodeURIComponent(name)}${lang === "hu" ? "?lang=hu" : ""}`), plexSession: ( id: string, start = 0, diff --git a/frontend/src/lib/api/plexLang.dom.test.ts b/frontend/src/lib/api/plexLang.dom.test.ts new file mode 100644 index 00000000..8add679c --- /dev/null +++ b/frontend/src/lib/api/plexLang.dom.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +// +// E30: every Plex read that returns cards carries the reader's language, so the server can serve the +// Hungarian titles. The grid and the detail pages always did; these reads did not. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { plexApi } from "./plex"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({}), + text: async () => "{}", + }); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => vi.unstubAllGlobals()); + +const lastUrl = (): string => { + const calls = fetchMock.mock.calls; + return String(calls[calls.length - 1]?.[0]); +}; + +describe("Plex card reads in the reader's language", () => { + it.each([ + ["a playlist", () => plexApi.plexPlaylist(5, "hu"), () => plexApi.plexPlaylist(5, "en")], + ["a person", () => plexApi.plexPerson("Anna", "hu"), () => plexApi.plexPerson("Anna", "en")], + [ + "a collection", + () => plexApi.plexCollection("c1", { key: "title", dir: "asc" }, "hu"), + () => plexApi.plexCollection("c1", { key: "title", dir: "asc" }, "en"), + ], + ])("%s asks for Hungarian, and leaves English implied", async (_what, hu, en) => { + await hu(); + expect(lastUrl()).toContain("lang=hu"); + await en(); + expect(lastUrl()).not.toContain("lang="); + }); +}); diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts index 20d5872f..c4d2e692 100644 --- a/frontend/src/lib/plexDetail.ts +++ b/frontend/src/lib/plexDetail.ts @@ -1,5 +1,6 @@ import { useLayoutEffect } from "react"; import { usePref, useSavePref } from "./usePref"; +import type { TFunction } from "i18next"; import type { PlexCard } from "./api"; // The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display @@ -149,3 +150,19 @@ export function epLabel(ep?: PlexCard | null): string | undefined { return `S${ep.season_number} · E${ep.episode_number}`; return ep.title; } + +// Plex names seasons in the library's language ("Season 1", "Specials"), so a Hungarian reader got +// English there (E30). A generic name is rebuilt from the number in the reader's language; a season +// with a name of its own ("Book One: Water") keeps it. +const GENERIC_SEASON = /^(season \d+|specials)$/i; + +export function seasonTitle( + se: { title?: string | null; season_number?: number | null }, + t: TFunction, +): string { + const n = se.season_number; + if (se.title && !GENERIC_SEASON.test(se.title.trim())) return se.title; + if (n === 0) return t("plex.specials"); + if (n != null) return t("plex.seasonTitle", { n }); + return se.title || t("plex.playlist.seasonUnknown"); +} diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 2877880c..ec8eca20 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -125,7 +125,7 @@ export const qk = { // finish it, and it was still the answer on screen with a zeroed progress bar. discover: (...rest: unknown[]) => ["discover", ...rest], discoverPick: (...rest: unknown[]) => ["discover", "pick", ...rest], - comfortPins: () => ["comfort-pins"] as const, + comfortPins: (lang?: string) => ["comfort-pins", ...(lang ? [lang] : [])] as const, plex: () => ["plex"] as const, plexWatchLink: () => ["plex-watch-link"] as const, plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest], @@ -150,11 +150,14 @@ export const qk = { // that steers the background extraction, not part of the track's identity. plexSubtitle: (id: string, ord: number) => ["plex-subtitle", id, ord] as const, plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest], - plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const, + // `lang` as on plexShow: omit it for a prefix that matches both languages. + plexPlaylist: (playlistId: number, lang?: string) => + ["plex-playlist", playlistId, ...(lang ? [lang] : [])] as const, plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest], // facetKey is a filters-derived OBJECT segment (not a scalar), so this key is heterogeneous too. plexFacets: (...rest: unknown[]) => ["plex-facets", ...rest], plexPeopleCards: (scope: string, actors: string[]) => ["plex-people-cards", scope, actors] as const, - plexPerson: (name: string) => ["plex-person", name] as const, + plexPerson: (name: string, lang?: string) => + ["plex-person", name, ...(lang ? [lang] : [])] as const, }; From f6298d29af987d6cc0a174b0d25962d7df9be93b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 29 Sep 2026 02:25:36 +0200 Subject: [PATCH 03/23] fix(plex): the recs rail's anchor and a held recommendation are named in the reader's language (E30 S1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in the S1b UAT: 'Mert megnézted: Batman Forever' kept the English title, and the info page's TMDB recommendations kept TMDB's English titles even for titles we hold. A held recommendation is now named as the library names it, in the reader's language; one we lack keeps TMDB's title. --- backend/app/plex/tmdb.py | 18 +++++++++++++----- backend/app/routes/plex/detail.py | 4 ++-- backend/app/routes/plex/discover.py | 7 +++++-- backend/tests/test_plex_lang.py | 27 +++++++++++++++++++++++++++ backend/tests/test_plex_tmdb.py | 6 ++++-- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/backend/app/plex/tmdb.py b/backend/app/plex/tmdb.py index f373d072..c0f32ed2 100644 --- a/backend/app/plex/tmdb.py +++ b/backend/app/plex/tmdb.py @@ -21,6 +21,7 @@ from sqlalchemy.orm import Session from app import logs, sysconfig from app.config import settings from app.models import PlexItem, PlexPerson, PlexShow +from app.plex.cards import display_title log = logs.get_logger(__name__) # Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host. @@ -406,10 +407,11 @@ def resolve_library_guids( return movies, shows -def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]: +def resolve_recs(db: Session, recs: list[dict] | None, *, lang: str) -> list[dict]: """Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:`, bucketed by media_type — see resolve_library_guids) and, if so, its rating_key — so the client can deep-link - the ones we have and merely display the rest.""" + the ones we have and merely display the rest. One we have is named as the library names it, in + `lang` (E30); one we lack keeps the title TMDB gave.""" if not recs: return [] movies, shows = resolve_library_guids(db, recs) @@ -417,13 +419,19 @@ def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]: for r in recs: g = f"tmdb:{r.get('tmdb_id')}" row = shows.get(g) if r.get("media_type") == "tv" else movies.get(g) + named = {"title": display_title(row, lang)} if row is not None else {} out.append( - {**r, "in_library": row is not None, "rating_key": row.rating_key if row else None} + { + **r, + **named, + "in_library": row is not None, + "rating_key": row.rating_key if row else None, + } ) return out -def serve_block(db: Session, row) -> dict | None: +def serve_block(db: Session, row, *, lang: str) -> dict | None: """The TMDB extras for a movie/show detail response, served from the row (no live TMDB call). None until the row has been TMDB-enriched (the client then shows nothing extra).""" if row.tmdb_enriched_at is None: @@ -438,7 +446,7 @@ def serve_block(db: Session, row) -> dict | None: "crew": row.crew or [], "studios": row.studios or [], "production_countries": row.production_countries or [], - "recommendations": resolve_recs(db, row.recommendations), + "recommendations": resolve_recs(db, row.recommendations, lang=lang), "backdrops": row.backdrops or [], } diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index c0798a56..47458af5 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -211,7 +211,7 @@ def show_detail( "episode_count": show_roll["episode_count"], "collection_keys": sh.collection_keys or [], # TMDB enrichment (E5 S2), served from the row. - "tmdb": plex_tmdb.serve_block(db, sh), + "tmdb": plex_tmdb.serve_block(db, sh, lang=lang), }, "seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons], "related": related, @@ -517,5 +517,5 @@ def item_detail( "next_id": next_id, "collections": _collection_strips(db, it, user.id, lang), # TMDB enrichment (E5 S2), served from the row — movies only (episodes aren't enriched). - "tmdb": plex_tmdb.serve_block(db, it) if it.kind == "movie" else None, + "tmdb": plex_tmdb.serve_block(db, it, lang=lang) if it.kind == "movie" else None, } diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py index 5adc8f43..39b91033 100644 --- a/backend/app/routes/plex/discover.py +++ b/backend/app/routes/plex/discover.py @@ -313,7 +313,7 @@ def _because_you_watched( ) if anchor is None: return None - resolved = resolve_recs(db, anchor.recommendations) + resolved = resolve_recs(db, anchor.recommendations, lang=lang) # Preserve TMDB's recommendation order, keep only in-library hits, and drop the anchor itself. rks = [ r["rating_key"] @@ -362,7 +362,10 @@ def _because_you_watched( items.append(scards[rk]) if not items: return None - return {"anchor": {"id": anchor.rating_key, "title": anchor.title}, "items": items} + return { + "anchor": {"id": anchor.rating_key, "title": display_title(anchor, lang)}, + "items": items, + } @router.get("/discover") diff --git a/backend/tests/test_plex_lang.py b/backend/tests/test_plex_lang.py index 0790f804..15465fe1 100644 --- a/backend/tests/test_plex_lang.py +++ b/backend/tests/test_plex_lang.py @@ -230,6 +230,25 @@ def _seed_rails(db): PlexPlaylistItem(playlist_id=pl.id, item_id=ep.id, position=1), ] ) + # TMDB extras: m2 recommends m1 (held) and a title we lack; m1, watched, anchors the recs rail. + m1.guid = "tmdb:1" + m1.tmdb_enriched_at = datetime(2024, 1, 1, tzinfo=timezone.utc) + m1.recommendations = [{"tmdb_id": 2, "title": "EN Only", "media_type": "movie"}] + m2 = db.query(PlexItem).filter_by(rating_key="m2").one() + m2.guid = "tmdb:2" + m2.tmdb_enriched_at = datetime(2024, 1, 1, tzinfo=timezone.utc) + m2.recommendations = [ + {"tmdb_id": 1, "title": "English Title", "media_type": "movie"}, + {"tmdb_id": 77, "title": "Elsewhere", "media_type": "movie"}, + ] + db.add( + PlexState( + user_id=user.id, + item_id=m1.id, + status="watched", + watched_at=datetime(2024, 3, 1, tzinfo=timezone.utc), + ) + ) sysconfig.set_value(db, "plex_enabled", True) db.commit() return user, pl.id @@ -259,6 +278,14 @@ def test_every_card_rail_serves_the_readers_language(db, monkeypatch, override_d ("Magyar Cím", None), ("Kezdet", "Magyar Sorozat"), ] + # A recommendation we hold is named as the library names it; the rail's anchor too. + recs = get("/api/plex/item/m2?lang=hu")["tmdb"]["recommendations"] + assert [(r["title"], r["in_library"]) for r in recs] == [ + ("Magyar Cím", True), + ("Elsewhere", False), + ] + byw = get("/api/plex/discover?scope=both&lang=hu")["because_you_watched"] + assert byw["anchor"]["title"] == "Magyar Cím" home = get("/api/discover?lang=hu")["continue_watching"] plex = [c for c in home if c["source"] == "plex"] assert [c["title"] for c in plex] == ["Magyar Sorozat"] diff --git a/backend/tests/test_plex_tmdb.py b/backend/tests/test_plex_tmdb.py index 640fd686..319a4a55 100644 --- a/backend/tests/test_plex_tmdb.py +++ b/backend/tests/test_plex_tmdb.py @@ -255,13 +255,13 @@ def test_serve_block_none_until_enriched_and_resolves_recs(db): db.add_all([sequel, fresh]) db.commit() - assert tmdb.serve_block(db, fresh) is None # not enriched yet + assert tmdb.serve_block(db, fresh, lang="en") is None # not enriched yet tmdb.apply_tmdb_detail(fresh, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie")) fresh.tmdb_enriched_at = datetime.now(timezone.utc) db.commit() - block = tmdb.serve_block(db, fresh) + block = tmdb.serve_block(db, fresh, lang="en") assert block is not None assert block["budget"] == 6000000 assert block["keywords"] == ["mafia", "crime family"] @@ -409,6 +409,7 @@ def test_resolve_recs_matches_by_media_type_not_bare_id(db): {"tmdb_id": 500, "title": "Movie 500", "media_type": "movie"}, {"tmdb_id": 900, "title": "Show 900", "media_type": "tv"}, ], + lang="en", ) } assert miss[500]["in_library"] is False and miss[500]["rating_key"] is None @@ -423,6 +424,7 @@ def test_resolve_recs_matches_by_media_type_not_bare_id(db): {"tmdb_id": 900, "media_type": "movie"}, {"tmdb_id": 500, "media_type": "tv"}, ], + lang="en", ) } assert hit[900]["in_library"] is True and hit[900]["rating_key"] == "m900" From f3a85916ec5894315601c4954f62e982c3c54084 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 29 Sep 2026 21:21:19 +0200 Subject: [PATCH 04/23] feat(i18n): the server's closed English vocabularies named in the reader's language (E30 S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/vocab + the `vocab` locale area: TMDB crew jobs and departments, auto-tags (D1 A: the data stays English, the catalog is named at its one source, useTags), built-in download profiles, YouTube topics; held key-for-key to the backend's own lists by backendIds.contract. - Countries by ISO code: TMDB parse stores {code, name}; rows stored before are coded from TMDB's own country list in one request (run_enrich); Intl names them, historic codes (YU, SU, XI…) from the catalog. - Player tracks named from the language tag, forced/SDH flags and codec tail, not Plex's server-language displayTitle; no invented "Audio N" on the server. - Download lifecycle notices (shared / expiring / expired / evicted) named from data; the share notice carries the title. - Scheduler: the job result rides along as data and is named per key (dashboard + run notice). - Invite status named; a render crash's notice no longer shows the engine's English message. - Fix: YouTube links …/wiki/Humour, the tagger looked for "Humor" — Comedy never came from a topic. --- backend/app/plex/tmdb.py | 52 +++++- backend/app/routes/downloads.py | 9 +- backend/app/routes/plex/detail.py | 29 ++- backend/app/scheduler.py | 46 ++++- backend/app/sync/autotag.py | 4 +- backend/tests/test_autotag_topics.py | 24 +++ backend/tests/test_download_share_notice.py | 41 +++++ backend/tests/test_plex_detail.py | 49 +++++ backend/tests/test_plex_tmdb.py | 71 +++++++- backend/tests/test_scheduler.py | 44 +++++ frontend/src/components/AdminUsers.tsx | 6 +- frontend/src/components/ChannelAbout.tsx | 32 ++-- frontend/src/components/ChannelPage.tsx | 9 +- frontend/src/components/Channels.tsx | 3 +- frontend/src/components/DownloadDialog.tsx | 3 +- .../ErrorBoundary.message.dom.test.tsx | 39 ++++ frontend/src/components/ErrorBoundary.tsx | 4 +- .../NotificationsPanel.menu.dom.test.tsx | 34 ++++ .../src/components/NotificationsPanel.tsx | 27 ++- frontend/src/components/PlexInfo.dom.test.tsx | 11 +- frontend/src/components/PlexInfo.tsx | 7 +- .../components/PlexPersonView.dom.test.tsx | 3 +- frontend/src/components/PlexPlayer.tsx | 43 +++-- frontend/src/components/ProfileEditor.tsx | 3 +- .../src/components/Scheduler.dom.test.tsx | 23 +++ .../components/Scheduler.menu.dom.test.tsx | 1 + frontend/src/components/Scheduler.tsx | 3 + frontend/src/components/Sidebar.tsx | 3 +- frontend/src/components/TagManager.tsx | 3 +- frontend/src/components/VideoInfoCard.tsx | 7 +- .../src/components/adminInvites.dom.test.tsx | 3 + frontend/src/components/plexViews.tsx | 5 +- frontend/src/i18n/backendIds.contract.test.ts | 58 ++++++ frontend/src/i18n/locales/en/inbox.json | 12 ++ frontend/src/i18n/locales/en/scheduler.json | 67 ++++++- frontend/src/i18n/locales/en/settings.json | 6 +- frontend/src/i18n/locales/en/vocab.json | 169 ++++++++++++++++++ frontend/src/i18n/locales/hu/inbox.json | 12 ++ frontend/src/i18n/locales/hu/scheduler.json | 67 ++++++- frontend/src/i18n/locales/hu/settings.json | 6 +- frontend/src/i18n/locales/hu/vocab.json | 169 ++++++++++++++++++ frontend/src/lib/api/admin.ts | 7 +- frontend/src/lib/api/plex.ts | 21 ++- frontend/src/lib/jobResult.dom.test.ts | 41 +++++ frontend/src/lib/jobResult.ts | 22 +++ frontend/src/lib/useActiveFilters.ts | 3 +- frontend/src/lib/useTags.dom.test.tsx | 50 ++++++ frontend/src/lib/useTags.ts | 20 +++ frontend/src/lib/vocab.dom.test.ts | 115 ++++++++++++ frontend/src/lib/vocab.ts | 83 +++++++++ 50 files changed, 1475 insertions(+), 94 deletions(-) create mode 100644 backend/tests/test_autotag_topics.py create mode 100644 backend/tests/test_download_share_notice.py create mode 100644 frontend/src/components/ErrorBoundary.message.dom.test.tsx create mode 100644 frontend/src/i18n/locales/en/vocab.json create mode 100644 frontend/src/i18n/locales/hu/vocab.json create mode 100644 frontend/src/lib/jobResult.dom.test.ts create mode 100644 frontend/src/lib/jobResult.ts create mode 100644 frontend/src/lib/useTags.dom.test.tsx create mode 100644 frontend/src/lib/useTags.ts create mode 100644 frontend/src/lib/vocab.dom.test.ts create mode 100644 frontend/src/lib/vocab.ts diff --git a/backend/app/plex/tmdb.py b/backend/app/plex/tmdb.py index c0f32ed2..afba061a 100644 --- a/backend/app/plex/tmdb.py +++ b/backend/app/plex/tmdb.py @@ -12,6 +12,7 @@ the row. The enrich batch + scheduler entry live here too so the whole TMDB inte import time from datetime import datetime, timezone +from typing import cast import httpx from sqlalchemy import func, select @@ -125,6 +126,11 @@ class TmdbClient: """Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find.""" return self._get(f"/find/{external_id}", {"external_source": source}) + def countries(self) -> list[dict] | None: + """TMDB's country list: `[{iso_3166_1, english_name, native_name}]` (a JSON array, which + `_get`'s dict annotation does not describe).""" + return cast("list[dict] | None", self._get("/configuration/countries")) + def fetch_person(self, person_id: int) -> dict | None: """One person's bio + combined credits (E5 S6), in a single append_to_response request.""" return self._get(f"/person/{person_id}", {"append_to_response": "combined_credits"}) @@ -255,8 +261,12 @@ def parse_tmdb(payload: dict, media_type: str) -> dict: c.get("name") for c in (payload.get("production_companies") or []) if c.get("name") ][:8] or None, + # The ISO code beside TMDB's English name (E30): the client names the country in the reader's + # language from the code; the name is the fallback for a code it cannot name. "production_countries": [ - c.get("name") for c in (payload.get("production_countries") or []) if c.get("name") + {"code": c.get("iso_3166_1") or None, "name": c["name"]} + for c in (payload.get("production_countries") or []) + if c.get("name") ] or None, "recommendations": recs or None, @@ -357,13 +367,49 @@ def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int: return processed +def _country(c: dict | str) -> dict: + """One stored production country as served: `{code, name}`. A row enriched before E30 stored the + bare English name; until `backfill_country_codes` reaches it, it serves with no code (the client + shows the name).""" + return c if isinstance(c, dict) else {"code": None, "name": c} + + +def backfill_country_codes(db: Session, tmdb: TmdbClient) -> int: + """Give the countries stored before E30 (bare English names) their ISO codes, from TMDB's own + country list — the names came from that list, so they match it exactly. One request, and only + while such a row is left: re-fetching each title instead would take the enrichment batch days. + A name the list does not hold keeps no code. Returns the number of rows rewritten.""" + stale = [ + row + for model in (PlexItem, PlexShow) + for row in db.execute( + select(model).where(func.jsonb_typeof(model.production_countries[0]) == "string") + ).scalars() + ] + if not stale: + return 0 + codes = { + c["english_name"]: c["iso_3166_1"] + for c in tmdb.countries() or [] + if c.get("english_name") and c.get("iso_3166_1") + } + for row in stale: + row.production_countries = [ + {"code": codes.get(c), "name": c} if isinstance(c, str) else c + for c in row.production_countries or [] + ] + db.commit() + return len(stale) + + def run_enrich(db: Session) -> dict: """Scheduler entry point. No-op (never an error) when the TMDB key isn't configured.""" if not sysconfig.get_str(db, "tmdb_api_key"): return {"enriched": 0, "skipped": "tmdb_api_key not set"} with TmdbClient(db) as tmdb: + coded = backfill_country_codes(db, tmdb) n = enrich_batch(db, tmdb, settings.tmdb_enrich_batch) - return {"enriched": n} + return {"enriched": n, **({"countries_coded": coded} if coded else {})} def resolve_library_guids( @@ -445,7 +491,7 @@ def serve_block(db: Session, row, *, lang: str) -> dict | None: "keywords": row.keywords or [], "crew": row.crew or [], "studios": row.studios or [], - "production_countries": row.production_countries or [], + "production_countries": [_country(c) for c in row.production_countries or []], "recommendations": resolve_recs(db, row.recommendations, lang=lang), "backdrops": row.backdrops or [], } diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index d96a95fd..99d2affa 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -917,7 +917,14 @@ def share_download( user_id=recipient.id, type="download_shared", title=(job.display_name or "A download") + " was shared with you", - data={"kind": "download_shared", "from": user.email, "job_id": job_id}, + # The client names the notice in the reader's language from `data` (E30); the title is the + # English fallback only. + data={ + "kind": "download_shared", + "from": user.email, + "job_id": job_id, + "title": job.display_name, + }, commit=False, ) db.commit() diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index 47458af5..db6cadb9 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -1,5 +1,6 @@ """Plex show/item detail: the show drill-down + the per-item player payload (markers, cast, nav).""" +import re import threading import time from concurrent.futures import ThreadPoolExecutor @@ -342,6 +343,26 @@ def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]: return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None) +# The technical tail of Plex's track title — "English (AC3 5.1)" → "AC3 5.1". +_TRACK_TAIL = re.compile(r"\(([^()]+)\)\s*$") + + +def _track_naming(s: dict) -> dict: + """What the player names a track from (E30). Plex's `displayTitle` is in the SERVER's language + ("English (AC3 5.1)", "magyar Forced", "中文 (EAC3 Stereo)"), so it is sent only as the fallback + `label`; the client builds the name in the reader's language from the language tag, the + forced / SDH flags and the codec tail. No title and no language → no label (the client numbers it).""" + title = s.get("displayTitle") or s.get("language") or None + tail = _TRACK_TAIL.search(title) if title else None + return { + "label": title, + "lang_tag": s.get("languageTag") or None, + "forced": bool(s.get("forced")), + "hearing_impaired": bool(s.get("hearingImpaired")), + "detail": tail.group(1).strip() if tail else None, + } + + @router.get("/item/{rating_key}") def item_detail( rating_key: str, @@ -387,9 +408,7 @@ def item_detail( audio_streams.append( { "ord": len(audio_streams), - "label": s.get("displayTitle") - or s.get("language") - or f"Audio {len(audio_streams) + 1}", + **_track_naming(s), "language": s.get("languageTag") or s.get("language"), "default": bool(s.get("selected") or s.get("default")), } @@ -399,9 +418,7 @@ def item_detail( subtitle_streams.append( { "ord": len(subtitle_streams), - "label": s.get("displayTitle") - or s.get("language") - or f"Subtitle {len(subtitle_streams) + 1}", + **_track_naming(s), "language": s.get("languageTag") or s.get("language"), "codec": codec, # Text subs (srt/ass/…) can be served as WebVTT tracks; image subs (PGS/VobSub) diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 156fbe21..c66ad770 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -115,7 +115,9 @@ def _record(name: str, **fields) -> None: _activity.setdefault(name, {}).update(fields) -def _notify_done(db, actor_id: int, job_id: str, status: str, summary: str | None) -> None: +def _notify_done( + db, actor_id: int, job_id: str, status: str, summary: str | None, result: dict | None = None +) -> None: """Post a completion notice to the triggering admin's inbox (manual runs only). Best effort — a notification failure must never mask the job's own outcome.""" try: @@ -125,7 +127,7 @@ def _notify_done(db, actor_id: int, job_id: str, status: str, summary: str | Non type="scheduler", title=f"{job_id}: {status}", # English fallback; UI renders translated from data body=summary, - data={"job_id": job_id, "status": status, "summary": summary}, + data={"job_id": job_id, "status": status, "summary": summary, "result": result}, ) except Exception: db.rollback() @@ -192,25 +194,41 @@ def _job(name: str, fn) -> None: status="skipped", last_finished=_now_iso(), last_result="paused", + result=_PAUSED, progress=None, ) if actor_id is not None: - _notify_done(db, actor_id, name, "skipped", "sync paused") + _notify_done(db, actor_id, name, "skipped", "sync paused", _PAUSED) _audit_run(db, actor_id, name, "skipped", "sync paused", None) return with progress.bind(sink): result = fn(db) summary = _summarize(result) + data = _result_data(result) log.info("job %s -> %s", name, result) - _record(name, status="ok", last_finished=_now_iso(), last_result=summary, progress=None) + _record( + name, + status="ok", + last_finished=_now_iso(), + last_result=summary, + result=data, + progress=None, + ) if actor_id is not None: - _notify_done(db, actor_id, name, "ok", summary) + _notify_done(db, actor_id, name, "ok", summary, data) _audit_run(db, actor_id, name, "ok", summary, result) except Exception as exc: db.rollback() log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name)) err = str(exc) or exc.__class__.__name__ - _record(name, status="error", last_finished=_now_iso(), last_error=err, progress=None) + _record( + name, + status="error", + last_finished=_now_iso(), + last_error=err, + result=None, + progress=None, + ) if actor_id is not None: _notify_done(db, actor_id, name, "error", err) _audit_run(db, actor_id, name, "error", err, None) @@ -221,6 +239,21 @@ def _job(name: str, fn) -> None: db.close() +# A skipped run's result, in the same shape as a job's own `{"skipped": …}`. +_PAUSED = {"skipped": "sync paused"} + + +def _result_data(result) -> dict | None: + """A job's result as the client names it in the reader's language (E30): the dict itself (counts, + a `skipped` / `reason` word, a nested dict per phase), `enrich`'s bare count as one. The one-line + `_summarize` is the English fallback — and what the audit log keeps.""" + if isinstance(result, dict): + return result or None + if isinstance(result, int) and not isinstance(result, bool): + return {"enriched": result} + return None + + def _summarize(result) -> str | None: """Compact one-line rendering of a job's return value for the dashboard.""" if result is None: @@ -259,6 +292,7 @@ def scheduler_snapshot() -> dict: "last_started": a.get("last_started"), "last_finished": a.get("last_finished"), "last_result": a.get("last_result"), + "result": a.get("result"), "last_error": a.get("last_error"), "progress": a.get("progress"), } diff --git a/backend/app/sync/autotag.py b/backend/app/sync/autotag.py index 471bebf7..39ce4ac9 100644 --- a/backend/app/sync/autotag.py +++ b/backend/app/sync/autotag.py @@ -115,7 +115,9 @@ _TOPIC_SLUGS = { "Tourism": "Travel", "Fashion": "Fashion & Beauty", "Physical_attractiveness": "Fashion & Beauty", - "Humor": "Comedy", + # YouTube links the British page (…/wiki/Humour); the key was "Humor", so no channel ever got Comedy + # from its topic (E30). + "Humour": "Comedy", "Entertainment": "Entertainment", "Film": "Film", "Television_program": "TV", diff --git a/backend/tests/test_autotag_topics.py b/backend/tests/test_autotag_topics.py new file mode 100644 index 00000000..db220d1c --- /dev/null +++ b/backend/tests/test_autotag_topics.py @@ -0,0 +1,24 @@ +"""A YouTube topic URL's slug → the auto-tag it gives (pure). The slugs are the ones YouTube actually +links (measured on the owner's channels, 2026-09-29): a key spelled differently from YouTube's page +never matches, silently — "Humor" sat in the map for months while YouTube links …/wiki/Humour.""" + +import pytest + +from app.sync.autotag import map_topic_slug + + +@pytest.mark.parametrize( + ("slug", "tag"), + [ + ("Humour", "Comedy"), + ("Video_game_culture", "Gaming"), + ("Electronic_music", "Music"), + ("Association_football", "Sports"), + ("Knowledge", "Education"), + ("Physical_fitness", "Health & Fitness"), + ("Television_program", "TV"), + ("Lifestyle_(sociology)", None), # YouTube's catch-all: deliberately no tag + ], +) +def test_a_youtube_topic_slug_gives_its_tag(slug, tag): + assert map_topic_slug(slug) == tag diff --git a/backend/tests/test_download_share_notice.py b/backend/tests/test_download_share_notice.py new file mode 100644 index 00000000..39fab1cc --- /dev/null +++ b/backend/tests/test_download_share_notice.py @@ -0,0 +1,41 @@ +"""E30: the "shared with you" notice carries the download's title in its data, so the client can name +the notice in the reader's language. Its stored title is an English sentence ("Cats was shared with +you") — the only place the title lived, so a Hungarian reader got the English. Needs the DB.""" + +from sqlalchemy import select + +from app.models import DownloadJob, MediaAsset, Notification + + +def test_a_share_notice_carries_the_title_and_the_sharer(db, make_user, as_user): + owner = make_user("owner@example.com") + friend = make_user("friend@example.com") + asset = MediaAsset( + source_kind="url", source_ref="share-e30", format_sig="sig", status="ready", ref_count=1 + ) + db.add(asset) + db.flush() + job = DownloadJob( + user_id=owner.id, + asset_id=asset.id, + source_kind="url", + source_ref="share-e30", + status="done", + queue_pos=1, + profile_snapshot={}, + display_name="Cats", + ) + db.add(job) + db.commit() + + r = as_user(owner).post(f"/api/downloads/{job.id}/share", json={"email": "friend@example.com"}) + assert r.status_code == 200, r.text + + notice = db.execute(select(Notification).where(Notification.user_id == friend.id)).scalar_one() + assert notice.type == "download_shared" + assert notice.data == { + "kind": "download_shared", + "from": "owner@example.com", + "job_id": job.id, + "title": "Cats", + } diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py index 2e524a0d..bf18f9c5 100644 --- a/backend/tests/test_plex_detail.py +++ b/backend/tests/test_plex_detail.py @@ -352,3 +352,52 @@ def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch, override_dep): assert show.imdb_id == "tt0068646" # persisted for next time assert show.people_enriched_at is not None assert [c["name"] for c in show.full_cast] == ["Marlon Brando", "Al Pacino"] + + +def test_item_detail_names_tracks_from_their_language_not_plexs_title( + db, monkeypatch, override_dep +): + """E30: Plex titles a track in the Plex SERVER's language ("English (AC3 5.1)", "magyar Forced", + "中文 (EAC3 Stereo)"), so a Hungarian reader got a mix. The route sends what the client names it + from instead — the language tag, the forced / SDH flags, the codec tail — keeping Plex's title + only as the fallback, and no invented English "Audio 2" for a track with neither.""" + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="T")) + db.commit() + user = _user(db, "tracks@example.com") + db.commit() + streams = [ + {"streamType": 2, "displayTitle": "English (AC3 5.1)", "languageTag": "en-US"}, + {"streamType": 2, "displayTitle": "中文 (EAC3 Stereo)", "languageTag": "zh"}, + {"streamType": 2}, + { + "streamType": 3, + "displayTitle": "magyar Forced", + "languageTag": "hu", + "forced": True, + "codec": "srt", + }, + { + "streamType": 3, + "displayTitle": "English SDH", + "languageTag": "en", + "hearingImpaired": True, + "codec": "srt", + }, + ] + meta = {**RICH_META, "Media": [{"Part": [{"Stream": streams}]}]} + monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(meta)) + body = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1").json() + + naming = ("label", "lang_tag", "forced", "hearing_impaired", "detail") + assert [tuple(a[k] for k in naming) for a in body["audio_streams"]] == [ + ("English (AC3 5.1)", "en-US", False, False, "AC3 5.1"), + ("中文 (EAC3 Stereo)", "zh", False, False, "EAC3 Stereo"), + (None, None, False, False, None), + ] + assert [tuple(s[k] for k in naming) for s in body["subtitle_streams"]] == [ + ("magyar Forced", "hu", True, False, None), + ("English SDH", "en", False, True, None), + ] diff --git a/backend/tests/test_plex_tmdb.py b/backend/tests/test_plex_tmdb.py index 319a4a55..e09335ee 100644 --- a/backend/tests/test_plex_tmdb.py +++ b/backend/tests/test_plex_tmdb.py @@ -49,7 +49,7 @@ MOVIE_PAYLOAD = { ], }, "production_companies": [{"name": "Paramount"}, {"name": "Alfran"}], - "production_countries": [{"name": "United States of America"}], + "production_countries": [{"iso_3166_1": "US", "name": "United States of America"}], "recommendations": { "results": [ { @@ -126,7 +126,8 @@ def test_parse_movie_pulls_all_fields(): }, ] # the Gaffer is filtered; the two Coppola jobs survive (dedup is by name+job) assert d["studios"] == ["Paramount", "Alfran"] - assert d["production_countries"] == ["United States of America"] + # E30: the code beside the English name, so the client can name the country in the reader's language. + assert d["production_countries"] == [{"code": "US", "name": "United States of America"}] assert [r["tmdb_id"] for r in d["recommendations"]] == [240, 999] assert d["backdrops"] == ["/bd1.jpg", "/bd2.jpg"] # cast + crew names → tmdb person ids for the plex_people foundation @@ -429,3 +430,69 @@ def test_resolve_recs_matches_by_media_type_not_bare_id(db): } assert hit[900]["in_library"] is True and hit[900]["rating_key"] == "m900" assert hit[500]["in_library"] is True and hit[500]["rating_key"] == "s500" + + +# ---- E30: countries by ISO code ----------------------------------------------------------------- + + +class FakeCountries(FakeTmdb): + """TMDB's /configuration/countries, counting the requests.""" + + def __init__(self): + super().__init__() + self.calls = 0 + + def countries(self): + self.calls += 1 + return [ + {"iso_3166_1": "US", "english_name": "United States of America"}, + {"iso_3166_1": "YU", "english_name": "Yugoslavia"}, + ] + + +def test_countries_stored_before_codes_get_them_from_tmdbs_list(db): + lib = _lib(db) + old = PlexItem( + rating_key="m1", + library_id=lib.id, + kind="movie", + title="Old", + production_countries=["Yugoslavia", "Atlantis"], + tmdb_enriched_at=datetime.now(timezone.utc), + ) + show_lib = _lib(db, key="2", kind="show") + show = PlexShow( + rating_key="s1", + library_id=show_lib.id, + title="Show", + production_countries=["United States of America"], + ) + new = PlexItem( + rating_key="m2", + library_id=lib.id, + kind="movie", + title="New", + production_countries=[{"code": "US", "name": "United States of America"}], + ) + db.add_all([old, show, new]) + db.commit() + + # Served before the backfill: the bare name, with no code — never a crash on the old shape. + assert tmdb.serve_block(db, old, lang="hu")["production_countries"] == [ + {"code": None, "name": "Yugoslavia"}, + {"code": None, "name": "Atlantis"}, + ] + + fake = FakeCountries() + assert tmdb.backfill_country_codes(db, fake) == 2 + db.refresh(old) + db.refresh(show) + assert old.production_countries == [ + {"code": "YU", "name": "Yugoslavia"}, + {"code": None, "name": "Atlantis"}, # not in TMDB's list → no code, the name kept + ] + assert show.production_countries == [{"code": "US", "name": "United States of America"}] + + # Nothing left to code: no request at all. + assert tmdb.backfill_country_codes(db, fake) == 0 + assert fake.calls == 1 diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index 9a974226..ab77e44d 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -2,7 +2,10 @@ through (at the top of _job): holding _activity_lock across the check-and-set means exactly one caller can hold a job's "running" claim at a time, and it's claimable again once released.""" +from sqlalchemy import select + from app import scheduler +from app.models import Notification _JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs @@ -23,3 +26,44 @@ def test_claim_is_exclusive_until_released(): # ...and now it can be claimed again. assert scheduler._claim_running(_JOB) is True _release() + + +# ---- E30: the result as data ------------------------------------------------------------------- + + +def _snapshot_job(job_id): + return next(j for j in scheduler.scheduler_snapshot()["jobs"] if j["id"] == job_id) + + +def test_a_run_keeps_its_result_as_data_for_the_readers_language(db, make_user): + """The dashboard and the manual-run notice showed `_summarize`'s English one-liner ("new=3, + failed=0", a Python repr for a nested phase). The result rides along as data, so the client names + it; the one-liner stays as the fallback and the audit text.""" + admin = make_user("sched@example.com", role="admin") + token = scheduler._manual_actor.set(admin.id) + try: + scheduler._job("rss_poll", lambda _db: {"new": 3, "failed": 0, "fallback": 1}) + scheduler._job("enrich", lambda _db: 5) # enrich returns a bare count + finally: + scheduler._manual_actor.reset(token) + + rss = _snapshot_job("rss_poll") + assert rss["result"] == {"new": 3, "failed": 0, "fallback": 1} + assert rss["last_result"] == "new=3, failed=0, fallback=1" + assert _snapshot_job("enrich")["result"] == {"enriched": 5} + + notices = db.execute( + select(Notification).where(Notification.user_id == admin.id).order_by(Notification.id) + ).scalars() + assert [n.data["result"] for n in notices] == [ + {"new": 3, "failed": 0, "fallback": 1}, + {"enriched": 5}, + ] + + +def test_a_failed_run_drops_the_previous_result(db): + scheduler._job("rss_poll", lambda _db: {"new": 1}) + scheduler._job("rss_poll", lambda _db: (_ for _ in ()).throw(RuntimeError("boom"))) + rss = _snapshot_job("rss_poll") + assert rss["last_error"] == "boom" + assert rss["result"] is None diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 20157888..300d3027 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -728,7 +728,11 @@ function AdminInvites() {
{i.email} - {i.status} + {t( + i.status === "approved" + ? "settings.invites.status.approved" + : "settings.invites.status.denied", + )} {/* Only an approved row with NOBODY behind it yet: this button means "the door is open, come in", which is meaningless to someone already inside — and half the diff --git a/frontend/src/components/ChannelAbout.tsx b/frontend/src/components/ChannelAbout.tsx index bd0331f6..64050d86 100644 --- a/frontend/src/components/ChannelAbout.tsx +++ b/frontend/src/components/ChannelAbout.tsx @@ -3,35 +3,27 @@ import { ExternalLink } from "lucide-react"; import type { ChannelDetail } from "../lib/api"; import { linkify } from "../lib/linkify"; import { Badge } from "./ui/Badge"; +import { languageName, regionName, vocab } from "../lib/vocab"; // --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) --- -function displayName(code: string, lang: string, type: "region" | "language"): string { - try { - return ( - new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? - code - ); - } catch { - return code; - } -} // YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play". function parseKeywords(s: string): string[] { return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean); } -// topicCategories are Wikipedia URLs (…/wiki/Music) → a readable label, de-duplicated. -function topicLabels(urls: string[]): string[] { +// topicCategories are Wikipedia URLs (…/wiki/Music) → the page slug ("Pop_music"), de-duplicated. The +// slug is the vocabulary word (E30): named in the reader's language, else shown with spaces. +function topicSlugs(urls: string[]): string[] { const seen = new Set(); for (const url of urls) { const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url; - let label = seg; + let slug = seg; try { - label = decodeURIComponent(seg); + slug = decodeURIComponent(seg); } catch { /* keep raw */ } - label = label.replace(/_/g, " ").trim(); - if (label) seen.add(label); + slug = slug.trim(); + if (slug) seen.add(slug); } return [...seen]; } @@ -43,7 +35,7 @@ function topicLabels(urls: string[]): string[] { */ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) { const { t, i18n } = useTranslation(); - const topics = ch.topic_categories?.length ? topicLabels(ch.topic_categories) : []; + const topics = ch.topic_categories?.length ? topicSlugs(ch.topic_categories) : []; const keywords = ch.keywords ? parseKeywords(ch.keywords) : []; const hasAboutDetails = !!ch.country || !!ch.default_language || topics.length > 0 || keywords.length > 0; @@ -86,14 +78,14 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) { aria-hidden /> )} - {displayName(ch.country, i18n.language, "region")} + {regionName(t, ch.country, i18n.language)}
)} {ch.default_language && (
{t("channel.language")} - {displayName(ch.default_language, i18n.language, "language")} + {languageName(ch.default_language, i18n.language, ch.default_language)}
)} {topics.length > 0 && ( @@ -102,7 +94,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
{/* Future search epic: these topic chips become clickable filters. */} {topics.map((tp) => ( - {tp} + {vocab(t, "topic", tp, tp.replace(/_/g, " "))} ))}
diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 4e436f9f..02a6f02c 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useLiveQuery } from "../lib/useLiveQuery"; import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll"; import { qk } from "../lib/queryKeys"; @@ -42,6 +42,7 @@ import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; import { IconLink } from "./ui/LinkButton"; import { PageHeading } from "./ui/Headings"; +import { useTags } from "../lib/useTags"; // A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the // channel's videos (the catalog filtered to this channel). For an un-subscribed channel it @@ -230,11 +231,7 @@ export default function ChannelPage({ // invalidates the manager list so a tag added here shows there too. // Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an // unsubscribed/demo channel page never shows the control, so the request would be thrown away. - const tagsQuery = useQuery({ - queryKey: qk.tags(), - queryFn: api.tags, - enabled: !!ch?.subscribed && !me.is_demo, - }); + const tagsQuery = useTags({ enabled: !!ch?.subscribed && !me.is_demo }); const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const { toggleTag, createAndAttachTag } = useChannelTagActions({ optimisticToggle: (id, tagId, attach) => diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 14c2e9c5..e6859372 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -54,6 +54,7 @@ import { useNavigationActions } from "./navigationContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useWizard } from "./wizardContext"; import { useMe } from "../lib/useMe"; +import { useTags } from "../lib/useTags"; export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked"; @@ -118,7 +119,7 @@ export default function Channels() { }; const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels }); - const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); + const tagsQuery = useTags(); const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels }); const [tagManagerOpen, setTagManagerOpen] = useState(false); diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index 2ecf8f6f..8594101e 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -12,6 +12,7 @@ const ProfileEditor = lazy(() => import("./ProfileEditor")); import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors"; import { navigateTo } from "../lib/nav"; +import { profileName } from "../lib/vocab"; import FilterSelect from "./ui/FilterSelect"; import { Input } from "./ui/Input"; @@ -75,7 +76,7 @@ export default function DownloadDialog({ hideLabel selected={chosen == null ? [] : [String(chosen)]} onChange={([v]) => v && setProfileId(Number(v))} - options={profiles.map((p) => ({ value: String(p.id), label: p.name }))} + options={profiles.map((p) => ({ value: String(p.id), label: profileName(t, p) }))} /> - {configured && ( - - )} + {configured && ( + + )} + {!configured && !compact &&

{t("messages.setupWarn")}

} ); diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index d394b8cd..35a5f466 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -93,7 +93,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column (c.last_video_at ? new Date(c.last_video_at).getTime() : 0), render: (c) => , diff --git a/frontend/src/components/ui/SelectTrigger.tsx b/frontend/src/components/ui/SelectTrigger.tsx index 3d8f16d7..be7edc6b 100644 --- a/frontend/src/components/ui/SelectTrigger.tsx +++ b/frontend/src/components/ui/SelectTrigger.tsx @@ -33,7 +33,7 @@ export const SelectTrigger = forwardRef< ref={ref} type="button" className={clsx( - "glass-hover inline-flex shrink-0 items-center gap-1.5 border text-left text-sm transition touch-target", + "glass-hover inline-flex max-w-full shrink-0 items-center gap-1.5 border text-left text-sm whitespace-nowrap transition touch-target", context === "toolbar" ? "glass-card h-7 rounded-full px-3" : "h-8 w-full rounded-lg bg-transparent px-2.5", @@ -43,17 +43,23 @@ export const SelectTrigger = forwardRef< {...rest} > {icon} - {label != null && {label}:} + {label != null && {label}:} {/* The toolbar value is a row, whatever the caller puts in it: an is display:block under the preflight, and in a plain span it broke the value onto its own line (the saved-view chip, - E28). The form value is text that truncates, which a flex box would not do. */} + E28). The form value is text that truncates, which a flex box would not do — nor would a bare + text node in the toolbar row, so a text value gets its own truncating span: in a narrow place + (the playlists rail) a long value ("Rendezés: Egyéni sorrend") used to wrap out of the pill. */} - {children} + {context === "toolbar" && typeof children === "string" ? ( + {children} + ) : ( + children + )} {trailing} ({ {/* Only a sortable header is a control. A non-sortable one used to render a focusable no-op button, which put unlabeled dead stops in the tab order — two of them per playlist table, whose grip and actions headers are empty. */} + {/* min-w-6 as well as min-h-6: a short header ("Ki", Hungarian "Who") was a 12px + target beside its funnel, under the 24px floor. */} {col.sortable ? ( onToggleSort(col.key)} - className="inline-flex min-h-6 items-center gap-1 hover:text-fg cursor-pointer touch-target" + className="inline-flex min-h-6 min-w-6 items-center gap-1 hover:text-fg cursor-pointer touch-target" > {col.header} {isSorted && From 2819049133bc6b539f7c0fa836eb428bec33013a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 30 Sep 2026 01:08:29 +0200 Subject: [PATCH 12/23] feat(audit): job runs and config changes read in the reader's language (E30 S5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit log showed the server's fixed English lines ("rss_poll: ok — new=5, failed=0", "log_level = DEBUG", "Background sync paused") to a Hungarian reader. The details are rebuilt from the row's data on the client: a job run names the job and its result as the inbox does, a config change names the field and its values (on/off, grouped counts, "default"), a pause/resume adds nothing the action does not say. A failed run keeps its error text (diagnostic). parseSummary reads enrich's bare count ("50") back as `enriched`, as the server's `_result_data` names it — the audit and old notices keep only the one-liner. No backend change; old rows render too. --- frontend/src/components/AuditLog.tsx | 2 +- .../src/components/auditColumns.dom.test.tsx | 94 +++++++++++++++++++ frontend/src/components/auditColumns.tsx | 50 +++++++++- frontend/src/i18n/locales/en/audit.json | 4 + frontend/src/i18n/locales/hu/audit.json | 6 +- frontend/src/lib/jobResult.dom.test.ts | 3 +- frontend/src/lib/jobResult.ts | 8 +- 7 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/auditColumns.dom.test.tsx diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index fda79334..b034905b 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -33,7 +33,7 @@ export default function AuditLog() { .map((a) => ({ value: a, label: t(`auditActions.${a}`, a) })) .sort((a, b) => compareText(a.label, b.label, lang)); }, [rows, t, lang]); - const columns = useMemo(() => auditColumns(t, actionOptions), [t, actionOptions]); + const columns = useMemo(() => auditColumns(t, lang, actionOptions), [t, lang, actionOptions]); const clear = useMutation({ mutationFn: () => api.clearAudit(), diff --git a/frontend/src/components/auditColumns.dom.test.tsx b/frontend/src/components/auditColumns.dom.test.tsx new file mode 100644 index 00000000..e9d4bde6 --- /dev/null +++ b/frontend/src/components/auditColumns.dom.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +// +// E30: an audit row whose server summary is a fixed English line ("rss_poll: ok — new=5, failed=0", +// "log_level = DEBUG", "Background sync paused") reads in the reader's language, rebuilt from the row's +// data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it. +import { afterEach, describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import i18n, { setLanguage } from "../i18n"; +import type { AuditRow } from "../lib/api"; +import { auditColumns } from "./auditColumns"; + +afterEach(() => setLanguage("en")); + +const row = (over: Partial): AuditRow => ({ + id: 1, + created_at: null, + actor: null, + action: "job_run", + target_type: null, + target_id: null, + summary: null, + before: null, + after: null, + ...over, +}); + +function details(r: AuditRow): string { + setLanguage("hu"); + const t = i18n.getFixedT("hu"); + const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!; + const { container } = render(<>{col.render!(r)}); + return [...container.querySelectorAll(".wrap-break-word")].map((e) => e.textContent).join(" | "); +} + +describe("an audit row's details", () => { + it("name a job run and its result in the reader's language", () => { + const r = row({ + target_type: "job", + target_id: "rss_poll", + summary: "rss_poll: ok — new=5, failed=0", + after: { status: "ok", summary: "new=5, failed=0" }, + }); + expect(details(r)).toBe("RSS-lekérdezés (új feltöltések) kész | új videó: 5 · sikertelen: 0"); + }); + + it("keep a failed run's error text, which is diagnostic", () => { + const r = row({ + target_type: "job", + target_id: "enrich", + after: { status: "error", summary: "HTTP 403: quotaExceeded" }, + }); + expect(details(r)).toBe( + "Nem sikerült lefuttatni: Adatdúsítás + élő frissítés | HTTP 403: quotaExceeded", + ); + }); + + it("name a config change's field and values", () => { + const set = row({ + action: "config_set", + target_type: "config", + target_id: "plex_enabled", + summary: "plex_enabled = True", + before: { value: false }, + after: { value: true }, + }); + expect(details(set)).toMatch(/ \| Ki → Be$/); + expect(details(set)).not.toContain("plex_enabled"); + const reset = row({ + action: "config_reset", + target_type: "config", + target_id: "download_upload_max_bytes", + summary: "download_upload_max_bytes → default", + before: { value: 5242880 }, + }); + expect(details(reset)).toMatch(/ \| 5\s242\s880 → alapérték$/); + const secret = row({ + action: "config_set", + target_type: "config", + target_id: "youtube_api_key", + summary: "youtube_api_key = (secret updated)", + }); + expect(details(secret)).toMatch(/ \| titok frissítve/); + }); + + it("add nothing to a pause, whose action already says it", () => { + expect(details(row({ action: "sync_pause", summary: "Background sync paused" }))).toBe(""); + }); + + it("keep a summary that is data", () => { + expect(details(row({ action: "invite_add", summary: "someone@example.com" }))).toBe( + "someone@example.com", + ); + }); +}); diff --git a/frontend/src/components/auditColumns.tsx b/frontend/src/components/auditColumns.tsx index be92a2da..8fc643e9 100644 --- a/frontend/src/components/auditColumns.tsx +++ b/frontend/src/components/auditColumns.tsx @@ -1,5 +1,7 @@ import type { TFunction } from "i18next"; import type { AuditRow } from "../lib/api"; +import { formatNumber } from "../lib/format"; +import { jobResultText, parseSummary } from "../lib/jobResult"; import type { Column } from "./ui/DataTable"; import { RelativeTime } from "./ui/RelativeTime"; @@ -12,6 +14,47 @@ function fmt(v: unknown): string { return v == null || v === "" ? "—" : String(v); } +/** A logged config value in the reader's language: a switch reads on/off, a count is grouped. */ +function configValue(t: TFunction, v: unknown, lang: string): string { + if (typeof v === "boolean") return t(v ? "audit.on" : "audit.off"); + if (typeof v === "number") return formatNumber(v, lang); + return fmt(v); +} + +/** The details of the rows whose server `summary` is a fixed English line (E30), rebuilt from the row's + * data in the reader's language: a job run names the job and its result (as the inbox does), a config + * change names the field and its values, a pause/resume says nothing the action has not. `undefined` = + * not one of these; the generic summary + change rendering applies. */ +function localDetails( + t: TFunction, + row: AuditRow, + lang: string, +): { head: string | null; sub: string | null } | undefined { + const after = row.after ?? {}; + const before = row.before ?? {}; + if (row.action === "job_run" && row.target_type === "job" && row.target_id) { + const job = t(`scheduler.jobs.${row.target_id}`, row.target_id); + const failed = after.status === "error"; + const summary = typeof after.summary === "string" ? after.summary : null; + // A nested phase (a Python dict repr) or an error text does not parse, and shows as it was. + const result = failed ? null : parseSummary(summary); + return { + head: t(failed ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { job }), + sub: result ? jobResultText(t, result, lang) : summary, + }; + } + if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) { + const head = t(`config.fields.${row.target_id}.label`, row.target_id); + // A secret's value is never logged: there is no before/after to show. + if (!row.before && !row.after) return { head, sub: t("audit.secretUpdated") }; + const to = + row.action === "config_reset" ? t("audit.default") : configValue(t, after.value, lang); + return { head, sub: `${configValue(t, before.value, lang)} → ${to}` }; + } + if (row.action === "sync_pause" || row.action === "sync_resume") return { head: null, sub: null }; + return undefined; +} + /** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */ function changeText(row: AuditRow): string | null { const { before, after } = row; @@ -32,6 +75,7 @@ function changeText(row: AuditRow): string | null { * the data (built by the page) so the Action column's select filter only offers real values. */ export function auditColumns( t: TFunction, + lang: string, actionOptions: { value: string; label: string }[], ): Column[] { return [ @@ -72,13 +116,15 @@ export function auditColumns( key: "details", header: t("audit.cols.details"), render: (r) => { - const change = changeText(r); + const local = localDetails(t, r, lang); + const summary = local ? local.head : r.summary; + const change = local ? local.sub : changeText(r); return ( // wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell // sizes to its longest row, so one long summary/change stretched the whole table past the // card (the row rules bled into the page). Wrapping keeps every row inside the container.
- {r.summary &&
{r.summary}
} + {summary &&
{summary}
} {change &&
{change}
}
); diff --git a/frontend/src/i18n/locales/en/audit.json b/frontend/src/i18n/locales/en/audit.json index 1d599c19..ca9c367d 100644 --- a/frontend/src/i18n/locales/en/audit.json +++ b/frontend/src/i18n/locales/en/audit.json @@ -9,6 +9,10 @@ "cleared": "Cleared {{count}} audit entries", "clearFailed": "Couldn't clear the audit log", "truncated": "Showing the {{returned}} most recent of {{total}} entries.", + "on": "On", + "off": "Off", + "default": "default", + "secretUpdated": "secret updated (the value is never logged)", "cols": { "when": "When", "actor": "Who", diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index 18d00352..7785dd19 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -1,5 +1,5 @@ { - "title": "Auditnapló", + "title": "Eseménynapló", "intro": "Az admin-műveletek és az ütemező futás-összegzéseinek csak-hozzáfűző naplója — ki, mit, mikor. Művelet-szinten naplózva, soha nem elemenként.", "empty": "Még nincs naplóbejegyzés.", "system": "Rendszer", @@ -9,6 +9,10 @@ "cleared": "{{count}} naplóbejegyzés törölve", "clearFailed": "Nem sikerült üríteni a naplót", "truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.", + "on": "Be", + "off": "Ki", + "default": "alapérték", + "secretUpdated": "titok frissítve (az értéke sosem kerül a naplóba)", "cols": { "when": "Mikor", "actor": "Ki", diff --git a/frontend/src/lib/jobResult.dom.test.ts b/frontend/src/lib/jobResult.dom.test.ts index 2ac733b3..948c6967 100644 --- a/frontend/src/lib/jobResult.dom.test.ts +++ b/frontend/src/lib/jobResult.dom.test.ts @@ -55,12 +55,13 @@ describe("an older notice's one-liner", () => { enriched: 0, skipped: "tmdb_api_key not set", }); + // enrich's bare count, as the server's `_result_data` names it + expect(parseSummary("42")).toEqual({ enriched: 42 }); }); it("does not when a phase is nested, or it is no summary at all", () => { expect(parseSummary("recent={'channels_processed': 0}, deep={'videos_added': 1}")).toBeNull(); expect(parseSummary("sync paused")).toBeNull(); - expect(parseSummary("42")).toBeNull(); expect(parseSummary(null)).toBeNull(); }); }); diff --git a/frontend/src/lib/jobResult.ts b/frontend/src/lib/jobResult.ts index f06959b8..0854e8c4 100644 --- a/frontend/src/lib/jobResult.ts +++ b/frontend/src/lib/jobResult.ts @@ -3,11 +3,13 @@ import type { JobResult } from "./api"; import { formatNumber } from "./format"; import { vocabKey } from "./vocab"; -/** A notice from before the result rode along as data holds only the server's one-liner: `_summarize`'s - * "new=3, failed=0" (backend/app/scheduler.py). A flat one reads back into a result; a nested phase - * (a Python dict repr) or anything else does not, and stays as it was. */ +/** A notice from before the result rode along as data — and every audit row — holds only the server's + * one-liner: `_summarize`'s "new=3, failed=0" (backend/app/scheduler.py). A flat one reads back into a + * result, and `enrich`'s bare count ("50") into `{enriched: 50}` as `_result_data` names it; a nested + * phase (a Python dict repr) or anything else does not, and stays as it was. */ export function parseSummary(summary: string | null | undefined): JobResult | null { if (!summary || /[{}[\]]/.test(summary)) return null; + if (/^\d+$/.test(summary)) return { enriched: Number(summary) }; const out: JobResult = {}; for (const part of summary.split(", ")) { const m = /^(\w+)=(.+)$/.exec(part); From 705377c797791bd59ad48fd887bba16e9b093f9b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 30 Sep 2026 02:19:54 +0200 Subject: [PATCH 13/23] =?UTF-8?q?feat(plex):=20the=20people=20are=20local?= =?UTF-8?q?=20=E2=80=94=20bios=20and=20portraits=20fetched=20ahead,=20not?= =?UTF-8?q?=20on=20the=20click=20(E30=20S6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A person's TMDB bio was fetched only when their page was first opened, off the request path, so the first open showed no bio and the page never asked again (2 of 18,202 linked people were ever enriched). - people_enrich scheduler job (every 10 min, 300 per run): linked people's bios from TMDB and their portraits into the image cache, the most-credited people first. A failed TMDB lookup ends the batch. - Portraits are resized through Plex's photo transcoder to a 320 box (~15 KB instead of ~170 KB; the person-image proxy uses the same cache key, and a full-size original is removed as its small twin lands). - The person response says `enriching` while a bio is on its way (a person the job hasn't reached yet). - "Known for" is ranked by vote count, without guest visits as themselves (talk shows had crowded out the films: Tom Cruise had 14 of 15), with directed works, from a 40-deep pool; the page shows up to 15. Migration 0093 un-stamps enriched people so the job refetches them. - A phased job result (backfill, people_enrich) now counts as a change for the audit trail; before, an automatic backfill run that added videos was never audited. - HU: the watch reconcile job reads "egyeztetés" (a leftover from the S5 sweep). --- .../0093_plex_person_known_for_votes.py | 29 ++ backend/app/config.py | 5 + backend/app/models.py | 7 +- backend/app/plex/imgcache.py | 22 ++ backend/app/plex/people.py | 126 +++++++- backend/app/plex/tmdb.py | 99 +++++-- backend/app/routes/plex/people.py | 29 +- backend/app/routes/plex/playback.py | 31 +- backend/app/scheduler.py | 29 +- backend/tests/test_plex_people_enrich.py | 278 ++++++++++++++++++ backend/tests/test_plex_person.py | 1 + backend/tests/test_plex_person_bio.py | 3 +- frontend/src/i18n/locales/en/scheduler.json | 11 +- frontend/src/i18n/locales/hu/scheduler.json | 15 +- frontend/src/lib/api/plex.ts | 7 +- 15 files changed, 637 insertions(+), 55 deletions(-) create mode 100644 backend/alembic/versions/0093_plex_person_known_for_votes.py create mode 100644 backend/tests/test_plex_people_enrich.py diff --git a/backend/alembic/versions/0093_plex_person_known_for_votes.py b/backend/alembic/versions/0093_plex_person_known_for_votes.py new file mode 100644 index 00000000..2e3659d1 --- /dev/null +++ b/backend/alembic/versions/0093_plex_person_known_for_votes.py @@ -0,0 +1,29 @@ +"""re-fetch linked people — "Known for" ranked by votes, without guest visits (E30 S6) + +Revision ID: 0093_plex_person_known_for_votes +Revises: 0092_plex_person_biography_hu +Create Date: 2026-09-30 + +A person's "Known for" pool was their credits ranked by TMDB popularity, so talk-show visits as +themselves crowded out the films (Tom Cruise: 14 of 15 were talk shows, none in the library). The pool is +now ranked by vote count, without guest visits, with directed works, and deeper. Already-enriched people +are un-stamped so the people_enrich job fetches them again; their biography stays on the row meanwhile. +""" + +from alembic import op + +revision: str = "0093_plex_person_known_for_votes" +down_revision: str | None = "0092_plex_person_biography_hu" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.execute( + "UPDATE plex_people SET tmdb_enriched_at = NULL " + "WHERE tmdb_person_id IS NOT NULL AND tmdb_enriched_at IS NOT NULL" + ) + + +def downgrade() -> None: + pass # the watermark only says "fetched"; a refetch under the old ranking is not worth undoing to diff --git a/backend/app/config.py b/backend/app/config.py index 1ae64b41..6869bf32 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -366,6 +366,11 @@ class Settings(BaseSettings): tmdb_enrich_interval_min: int = 120 # Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited). tmdb_enrich_batch: int = 100 + # How often the people job (E30 S6) fetches a batch of person bios from TMDB and portraits through Plex, + # in minutes, and how many of each per run: ~18k linked people at 300 per 10 min is about ten hours + # once, then only the newcomers. + people_enrich_interval_min: int = 10 + people_enrich_batch: int = 300 @property def allowed_email_set(self) -> set[str]: diff --git a/backend/app/models.py b/backend/app/models.py index b116f2c6..91b268c0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1417,7 +1417,8 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin): top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the - row); this table is ONLY the headshot cache.""" + row); this table is the people's own facts: the headshot link, the TMDB link and the TMDB bio + (the headshot FILE lives in the image cache, filled by the people_enrich job).""" __tablename__ = "plex_people" @@ -1427,8 +1428,8 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin): # TMDB person id, linked by name from a title's TMDB credits (E5 S2 lays the foundation; the /person # biography fetch + person-page render land in S6). NULL until a TMDB-enriched title names this person. tmdb_person_id: Mapped[int | None] = mapped_column(Integer) - # --- TMDB person enrichment (E5 S6): fetched from /person/{tmdb_person_id} the first time the person - # page is opened, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a + # --- TMDB person enrichment (E5 S6): fetched from /person/{tmdb_person_id} by the people_enrich job + # (E30 S6), or by the first person-page open the job hasn't reached, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a # no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id, # media_type, title}], resolved to library rows by guid at serve time (like recommendations). --- biography: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py index 76f20b56..1053e714 100644 --- a/backend/app/plex/imgcache.py +++ b/backend/app/plex/imgcache.py @@ -56,6 +56,28 @@ def read(key: str) -> tuple[bytes, str] | None: return None +def has(key: str) -> bool: + """A cached, non-empty file exists for `key` (a prefetch's cheap check — no read).""" + for ext, _ in _EXT_CT: + try: + if (CACHE_DIR / f"{key}.{ext}").stat().st_size > 0: + return True + # silent-ok: a miss, as in read(). + except OSError: + pass + return False + + +def drop(key: str) -> None: + """Remove `key`'s file, whatever its extension (a superseded variant). Best-effort.""" + for ext, _ in _EXT_CT: + try: + (CACHE_DIR / f"{key}.{ext}").unlink(missing_ok=True) + # silent-ok: a leftover original only costs disk; the small twin is already written. + except OSError: + pass + + def write(key: str, data: bytes, ct: str) -> None: ext = _CT_EXT.get((ct or "").split(";")[0].strip(), "jpg") try: diff --git a/backend/app/plex/people.py b/backend/app/plex/people.py index 9db11f90..88e6012e 100644 --- a/backend/app/plex/people.py +++ b/backend/app/plex/people.py @@ -3,13 +3,21 @@ or show (cast_names + the people_text search blob + the plex_people headshot cac orphaned people. Used by BOTH the info-page lazy enrichment (routes.plex) and the background rich re-sync (plex.sync), so the two paths never diverge.""" -from urllib.parse import quote +import hashlib +from typing import Literal +from urllib.parse import quote, unquote +import httpx from sqlalchemy import select, text from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session +from app import logs from app.models import PlexPerson +from app.plex import imgcache +from app.plex.client import PlexClient, PlexError, PlexNotConfigured + +log = logs.get_logger(__name__) # Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted) # so the user's browser makes no third-party request (privacy + self-host CSP friendliness). @@ -113,3 +121,119 @@ def prune_orphan_people(db: Session) -> int: ) db.commit() return res.rowcount or 0 + + +# How often each person is credited across movies, shows and episodes (cast OR director) — the order the +# people_enrich job works in, so the faces met most often are local first. +_CREDIT_COUNTS_SQL = """ + SELECT ref, count(*) AS n FROM ( + SELECT jsonb_array_elements_text(cast_names) FROM plex_items WHERE jsonb_typeof(cast_names) = 'array' + UNION ALL SELECT jsonb_array_elements_text(directors) FROM plex_items WHERE jsonb_typeof(directors) = 'array' + UNION ALL SELECT jsonb_array_elements_text(cast_names) FROM plex_shows WHERE jsonb_typeof(cast_names) = 'array' + UNION ALL SELECT jsonb_array_elements_text(directors) FROM plex_shows WHERE jsonb_typeof(directors) = 'array' + ) AS refs(ref) GROUP BY ref +""" +_NEEDS = { + # linked to TMDB, bio never fetched + "bio": "p.tmdb_person_id IS NOT NULL AND p.tmdb_enriched_at IS NULL", + # has a Plex headshot (whether it is cached is the disk's answer, not the row's) + "portrait": "p.photo_thumb IS NOT NULL", +} + + +def people_by_credits( + db: Session, need: Literal["bio", "portrait"], limit: int | None +) -> list[int]: + """plex_people ids that `need` something, the most-credited first (ties by id).""" + rows = db.execute( + text( + f"SELECT p.id FROM plex_people p LEFT JOIN ({_CREDIT_COUNTS_SQL}) c ON c.ref = p.name " + f"WHERE {_NEEDS[need]} ORDER BY coalesce(c.n, 0) DESC, p.id" + + (" LIMIT :limit" if limit is not None else "") + ), + {"limit": limit} if limit is not None else {}, + ) + return [r[0] for r in rows] + + +# --- Portraits (E30 S6) --------------------------------------------------------------------------- +# A Plex CDN headshot is a full-size original (~170 KB on average, measured). Plex's photo transcoder +# fits it into this box (~13 KB), which covers the largest place a portrait is drawn (the person page's +# 6rem avatar) at 2x density and a raised text size. The box is part of the cache key, like a poster's: +# a new box re-caches instead of serving a stale size, and the full-size original under the old key is +# removed as its small twin lands. +PORTRAIT_BOX = 320 + + +def _portrait_keys(url: str) -> tuple[str, str]: + base = "person_" + hashlib.sha1(url.encode()).hexdigest() + return f"{base}_{PORTRAIT_BOX}", base + + +def portrait_cached(url: str) -> tuple[bytes, str] | None: + return imgcache.read(_portrait_keys(url)[0]) + + +def cache_portrait(plex: PlexClient | None, url: str) -> tuple[bytes, str]: + """Fetch one CDN headshot — resized through Plex when a Plex client is given, else the original + straight from the public CDN — and cache it. Raises PlexError / httpx.HTTPError on a failed fetch.""" + key, legacy = _portrait_keys(url) + if plex is not None: + data, ct = plex.image_bytes(url, PORTRAIT_BOX, PORTRAIT_BOX) + else: + with httpx.Client(timeout=10.0, follow_redirects=True) as c: + r = c.get(url) + r.raise_for_status() + data, ct = r.content, r.headers.get("content-type", "image/jpeg") + imgcache.write(key, data, ct) + if plex is not None: + imgcache.drop(legacy) + return data, ct + + +def cdn_url(photo_thumb: str) -> str | None: + """The CDN url inside a stored proxied `/person-image?u=` value (see person_thumb_url).""" + _, _, q = photo_thumb.partition("?u=") + url = unquote(q) + return url if url.startswith(PERSON_IMG_HOST) else None + + +def warm_portraits(db: Session, limit: int) -> dict: + """Cache up to `limit` not-yet-cached headshots, the most-credited people first (E30 S6), so a + person's face comes from local disk. Needs Plex (the resize); without it, nothing is prefetched — + full-size originals for every person would cost gigabytes. A failed fetch is skipped; one failing + Plex ends the run.""" + try: + plex = PlexClient(db) + # silent-ok: Plex off is a setting, not a failure; the run's result says "disabled". + except PlexNotConfigured: + return {"cached": 0, "skipped": "disabled"} + photos: dict[int, str] = { + pid: thumb + for pid, thumb in db.execute( + select(PlexPerson.id, PlexPerson.photo_thumb).where(PlexPerson.photo_thumb.is_not(None)) + ).tuples() + if thumb + } + done = failed = 0 + with plex: + for pid in people_by_credits(db, "portrait", None): + if done + failed >= limit: + break + url = cdn_url(photos.get(pid) or "") + if url is None or imgcache.has(_portrait_keys(url)[0]): + continue + try: + cache_portrait(plex, url) + done += 1 + except (PlexError, httpx.HTTPError) as exc: + failed += 1 + log.warning( + "a portrait could not be cached", + extra=logs.event( + "plex.people.portrait_failed", person_id=pid, reason=type(exc).__name__ + ), + ) + if failed >= 5: + break + return {"cached": done, **({"failed": failed} if failed else {})} diff --git a/backend/app/plex/tmdb.py b/backend/app/plex/tmdb.py index 8ff8d09d..bd980516 100644 --- a/backend/app/plex/tmdb.py +++ b/backend/app/plex/tmdb.py @@ -10,6 +10,7 @@ One-parser rule (mirrors app/plex/detail.py): `parse_tmdb` is the pure parser, ` the row. The enrich batch + scheduler entry live here too so the whole TMDB integration is one module. """ +import re import time from datetime import datetime, timezone from typing import cast @@ -23,6 +24,7 @@ from app import logs, sysconfig from app.config import settings from app.models import PlexItem, PlexPerson, PlexShow from app.plex.cards import display_title +from app.plex.people import people_by_credits log = logs.get_logger(__name__) # Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host. @@ -315,8 +317,8 @@ def _link_people(db: Session, links: dict[str, int]) -> None: def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int: - """Enrich up to `limit` not-yet-TMDB-enriched titles (movies first, shows fill the remainder — - mirrors _enrich_people_batch). A transient TmdbError leaves the watermark NULL for retry; a + """Enrich up to `limit` not-yet-TMDB-enriched titles (movies first, shows fill the + remainder). A transient TmdbError leaves the watermark NULL for retry; a definitive no-match (unresolvable guid / TMDB 404) still stamps the watermark so it isn't re-fetched every run. One commit at the end. Returns the number processed (matched OR definitively unmatched).""" movies = ( @@ -501,23 +503,51 @@ def serve_block(db: Session, row, *, lang: str) -> dict | None: # --- Person enrichment (E5 S6) ------------------------------------------------------------------- -# A person's bio + birth facts + a "Known for" credit pool, fetched once (the first person-page open) -# and served from the plex_people row. `known_for` stores the top combined-credits ids; the person +# A person's bio + birth facts + a "Known for" credit pool, fetched once — by the people_enrich job, or +# by the first person-page open if the job has not reached them yet — and served from the plex_people row. `known_for` stores the top combined-credits ids; the person # endpoint resolves them to library rows by guid at serve time (like recommendations). -_KNOWN_FOR_MAX = 15 +# The candidate pool kept per person. Only the titles that are in the library are shown (at most +# routes.plex.people._KNOWN_FOR_SHOWN), so the pool is deeper than the rail: a star's 15 most-voted +# credits are often not the 15 a given library holds. +_KNOWN_FOR_MAX = 40 +# TMDB genre ids of the shows a person visits as a guest (News, Reality, Talk). Ranked by popularity, +# talk-show visits crowded out the films (Tom Cruise: 14 of 15 were talk shows; E30 S6). +_GUEST_GENRES = {10763, 10764, 10767} +# A credit in which the person plays themselves ("Self", "Himself - Guest", "Herself (archive footage)"). +_SELF_CREDIT = re.compile(r"^\s*(self|himself|herself|themselves)\b", re.IGNORECASE) +# The crew jobs that make a title the person's own work (a director is known for what they directed). +_KNOWN_FOR_CREW_JOBS = {"Director", "Creator"} + + +def _known_for_credit(c: dict, crew: bool) -> bool: + if crew: + return c.get("job") in _KNOWN_FOR_CREW_JOBS + if _SELF_CREDIT.match(c.get("character") or ""): + return False + return not _GUEST_GENRES.intersection(c.get("genre_ids") or []) def parse_person(payload: dict) -> dict: """TMDB /person/{id} (+combined_credits) → the persisted bio fields. `known_for` = the person's - most-popular movie/tv credits (deduped by id), top `_KNOWN_FOR_MAX`, resolved to library later.""" - credits = (payload.get("combined_credits") or {}).get("cast") or [] + most-voted movie/tv credits — roles and directed works, not guest visits as themselves — deduped, + the top `_KNOWN_FOR_MAX`, resolved to library rows at serve time. Ranked by vote count, as TMDB's + own "Known for" is: popularity is this week's attention, which a talk-show visit wins.""" + combined = payload.get("combined_credits") or {} + credits = [c for c in combined.get("cast") or [] if _known_for_credit(c, crew=False)] + [ + c for c in combined.get("crew") or [] if _known_for_credit(c, crew=True) + ] # Dedup on (id, media_type), not the bare id: TMDB movie ids and tv ids are independent sequences # (id 603 can be both a movie and a show — see resolve_library_guids), so a same-id movie+show pair # is two distinct credits and both must survive. seen: set[tuple[int, str]] = set() known: list[dict] = [] - for c in sorted(credits, key=lambda c: c.get("popularity") or 0, reverse=True): + ranked = sorted( + credits, + key=lambda c: (c.get("vote_count") or 0, c.get("popularity") or 0), + reverse=True, + ) + for c in ranked: tid, mt = c.get("id"), c.get("media_type") if not tid or mt not in ("movie", "tv") or (tid, mt) in seen: continue @@ -557,11 +587,22 @@ def apply_person(row: PlexPerson, parsed: dict) -> None: row.tmdb_enriched_at = datetime.now(timezone.utc) +def _fetch_person_into(tmdb: TmdbClient, person: PlexPerson) -> None: + """Fetch one person from TMDB and write the row (not committed). A 404 (deleted person) stamps the + watermark with empty fields so it isn't fetched again. Raises TmdbError on a transient failure, + leaving the row untouched and `tmdb_enriched_at` NULL for a retry.""" + payload = tmdb.fetch_person(cast(int, person.tmdb_person_id)) + if payload is None: + person.tmdb_enriched_at = datetime.now(timezone.utc) + else: + apply_person(person, parse_person(payload)) + + def enrich_person(db: Session, person: PlexPerson) -> bool: - """Lazily fetch + persist one person's TMDB bio (first person-page open), committing the write so it - survives regardless of the read path. No-op (False) without a key or a `tmdb_person_id`; a transient - error leaves `tmdb_enriched_at` NULL so the next open retries. A 404 (deleted person) stamps the - watermark with empty fields so we don't refetch it every open.""" + """Lazily fetch + persist one person's TMDB bio (a person page opened before the background job + reached them), committing the write so it survives regardless of the read path. No-op (False) + without a key or a `tmdb_person_id`; a transient error leaves `tmdb_enriched_at` NULL so the next + open retries.""" if not person.tmdb_person_id or not sysconfig.get_str(db, "tmdb_api_key"): return False try: @@ -569,7 +610,7 @@ def enrich_person(db: Session, person: PlexPerson) -> bool: # longer blocks a serve; still cap the outbound TMDB call with a short timeout so a hanging TMDB # doesn't tie up a threadpool worker for the default 30s. with TmdbClient(db, timeout=8.0) as tmdb: - payload = tmdb.fetch_person(person.tmdb_person_id) + _fetch_person_into(tmdb, person) except TmdbError as exc: # False leaves the person un-enriched and unstamped, so it is retried — but the page in # front of the user right now shows an empty biography with nothing to say why. @@ -582,9 +623,33 @@ def enrich_person(db: Session, person: PlexPerson) -> bool: ), ) return False - if payload is None: - person.tmdb_enriched_at = datetime.now(timezone.utc) - else: - apply_person(person, parse_person(payload)) db.commit() return True + + +def enrich_people_batch(db: Session, tmdb: TmdbClient, limit: int) -> dict: + """Fetch up to `limit` linked, not-yet-enriched people (E30 S6), the most-credited first, so a + person page reads its bio from the row instead of waiting on TMDB. Each person is committed as it + lands: a page open during the run already sees it. A transient TmdbError ends the batch (TMDB is + degraded; hammering it helps nobody) and the rest wait for the next run.""" + ids = people_by_credits(db, "bio", limit) + done = 0 + for pid in ids: + person = db.get(PlexPerson, pid) + # A page open may have enriched this person since the select: skip, don't fetch twice. + if person is None or person.tmdb_enriched_at is not None: + continue + try: + _fetch_person_into(tmdb, person) + except TmdbError as exc: + db.rollback() + log.warning( + "the TMDB person batch stopped on a failed lookup", + extra=logs.event( + "plex.tmdb.people_batch_failed", person_id=pid, reason=type(exc).__name__ + ), + ) + return {"enriched": done, "failed": 1} + db.commit() + done += 1 + return {"enriched": done} diff --git a/backend/app/routes/plex/people.py b/backend/app/routes/plex/people.py index d5d46481..2b4440b2 100644 --- a/backend/app/routes/plex/people.py +++ b/backend/app/routes/plex/people.py @@ -8,7 +8,7 @@ from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.collation import collated -from app import logs +from app import logs, sysconfig from app.auth import current_user from app.db import SessionLocal, get_db from app.models import ( @@ -26,6 +26,10 @@ router = APIRouter(prefix="/api/plex", tags=["plex"]) log = logs.get_logger(__name__) +# How many "Known for" titles the person page shows (the stored pool is deeper; see plex.tmdb). +_KNOWN_FOR_SHOWN = 15 + + def _resolve_known_for( db: Session, known_for: list | None, @@ -34,8 +38,9 @@ def _resolve_known_for( show_lib_ids: list[int], lang: str, ) -> list[dict]: - """The person's TMDB "Known for" credits (top by popularity) that are IN our library, as cards, in - popularity order. Matched by the canonical guid `tmdb:`; out-of-library credits are dropped.""" + """The person's TMDB "Known for" credits (most-voted first) that are IN our library, as cards, in + that order, at most _KNOWN_FOR_SHOWN. Matched by the canonical guid `tmdb:`; out-of-library + credits are dropped.""" if not known_for: return [] # Match each credit to a library row by guid, bucketed by media_type (movie ids and tv ids collide @@ -55,7 +60,7 @@ def _resolve_known_for( out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new"), lang=lang)) elif g in movies: out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang)) - return out + return out[:_KNOWN_FOR_SHOWN] @router.get("/people/cards") @@ -174,19 +179,24 @@ def person_detail( E5 S6: the first time a page is opened, the person's TMDB bio + "Known for" credits are lazily fetched (from the S2-linked `tmdb_person_id`) and persisted, then served from the row on every open. The fetch runs OFF the request path (a background task); the first open serves whatever's in the row - (bio possibly still null) and the bio lands from the row by the next open. + with `enriching` set, and the page asks again until the bio lands. E30 S6: the people_enrich job + fetches everyone ahead of time, so this lazy path is only for a person the job hasn't reached yet. """ lang = norm_lang(lang) person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first() # Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path. # Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded) # can't pile background tasks onto the shared threadpool. - if ( + # `enriching` tells the page the bio is on its way (E30 S6): it asks again shortly instead of showing + # an empty page until the next open. True while a fetch is pending, including one an earlier open + # already reserved. + enriching = ( person is not None - and person.tmdb_person_id + and bool(person.tmdb_person_id) and person.tmdb_enriched_at is None - and _should_schedule_enrich(person.id) - ): + and bool(sysconfig.get_str(db, "tmdb_api_key")) + ) + if enriching and person is not None and _should_schedule_enrich(person.id): background_tasks.add_task(_enrich_person_bg, person.id) movie_lib_ids, show_lib_ids = content_libs(db) @@ -253,6 +263,7 @@ def person_detail( "known_for_department": person.known_for_department if person else None, "also_known_as": (person.also_known_as if person else None) or [], "known_for": known_for, + "enriching": enriching, "movies": movies, "shows": shows, } diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index 635c2b47..d39ad6e7 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -1,6 +1,5 @@ """Plex playback: image/subtitle proxies, HLS streaming, and watch-state writes.""" -import hashlib import threading from datetime import datetime, timedelta, timezone @@ -214,24 +213,30 @@ def _resume_push_superseded(user_id: int, item_id: int, intent: datetime) -> boo @router.get("/person-image") def person_image(u: str, request: Request) -> Response: - """Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed). - Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch.""" + """Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed), + resized through Plex and cached (plex.people.cache_portrait; the people_enrich job fills the same + cache ahead of time). Signed-session auth; like /image, the DB session is released before the fetch.""" _require_session(request) if not u.startswith(plex_people.PERSON_IMG_HOST): raise refused(400, "image_host") - cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest() - hit = imgcache.read(cache_key) + hit = plex_people.portrait_cached(u) if hit: return imgcache.response(*hit) try: - with httpx.Client(timeout=10.0, follow_redirects=True) as c: - r = c.get(u) - r.raise_for_status() - except httpx.HTTPError: - raise refused(502, "image_fetch_failed") - ct = r.headers.get("content-type", "image/jpeg") - imgcache.write(cache_key, r.content, ct) - return imgcache.response(r.content, ct) + with SessionLocal() as db: + plex: PlexClient | None = PlexClient(db) + # silent-ok: Plex off is a setting; without its resize the original comes straight from the CDN. + except PlexNotConfigured: + plex = None + try: + if plex is None: + data, ct = plex_people.cache_portrait(None, u) + else: + with plex: + data, ct = plex_people.cache_portrait(plex, u) + except (PlexError, httpx.HTTPError): + raise refused(502, "image_fetch_failed") from None + return imgcache.response(data, ct) def _push_resume_guarded( diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index c66ad770..ca6b4257 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -69,6 +69,7 @@ JOB_INTERVALS: dict[str, int] = { "plex_watch_sync": settings.plex_watch_sync_interval_min, "plex_watch_reconcile": settings.plex_watch_reconcile_interval_min, "tmdb_enrich": settings.tmdb_enrich_interval_min, + "people_enrich": settings.people_enrich_interval_min, "audit_gc": settings.audit_gc_minutes, "log_gc": settings.log_gc_minutes, } @@ -142,7 +143,12 @@ def _run_changed(result) -> bool: if result is None: return False if isinstance(result, dict): - return any(isinstance(v, (int, float)) and v for v in result.values()) + # A phased job (backfill's recent/deep, people_enrich's bios/portraits) nests a result per + # phase: a count inside a phase is a change too. + return any( + _run_changed(v) if isinstance(v, dict) else isinstance(v, (int, float)) and bool(v) + for v in result.values() + ) if isinstance(result, (int, float)): return result != 0 return bool(result) @@ -403,6 +409,26 @@ def _tmdb_enrich_job() -> None: _job("tmdb_enrich", run_tmdb_enrich) +def _people_enrich_job() -> None: + # Make the people local (E30 S6): a batch of person bios from TMDB (the most-credited first) and a + # batch of portraits resized through Plex into the image cache, so a person page and a cast strip + # never wait on an outside service. Each half is a no-op without its key / without Plex. + def work(db): + from app import sysconfig + from app.plex import people as plex_people + from app.plex import tmdb as plex_tmdb + + batch = settings.people_enrich_batch + if sysconfig.get_str(db, "tmdb_api_key"): + with plex_tmdb.TmdbClient(db) as tmdb: + bios = plex_tmdb.enrich_people_batch(db, tmdb, batch) + else: + bios = {"skipped": "tmdb_api_key not set"} + return {"bios": bios, "portraits": plex_people.warm_portraits(db, batch)} + + _job("people_enrich", work) + + def _audit_gc_job() -> None: # Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep # forever). Pure DB, no quota. @@ -449,6 +475,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = { "plex_watch_sync": _plex_watch_sync_job, "plex_watch_reconcile": _plex_watch_reconcile_job, "tmdb_enrich": _tmdb_enrich_job, + "people_enrich": _people_enrich_job, "audit_gc": _audit_gc_job, "log_gc": _log_gc_job, } diff --git a/backend/tests/test_plex_people_enrich.py b/backend/tests/test_plex_people_enrich.py new file mode 100644 index 00000000..b866b688 --- /dev/null +++ b/backend/tests/test_plex_people_enrich.py @@ -0,0 +1,278 @@ +"""E30 S6: the people are made local ahead of time. The people_enrich job fetches linked people's TMDB +bios (the most-credited first) and caches their portraits resized through Plex; the person page says +when a bio is still on its way (`enriching`), so it can ask again instead of staying empty. +DB lane (Postgres); the portrait cache runs in a temp directory. +""" + +from datetime import datetime, timezone + +import pytest + +from app import scheduler +from app.models import PlexItem, PlexLibrary, PlexPerson, User +from app.plex import imgcache +from app.plex import people as plex_people +from app.plex import tmdb as plex_tmdb + +CDN = plex_people.PERSON_IMG_HOST + "people/" +DONE = datetime(2024, 1, 1, tzinfo=timezone.utc) + + +def _movie(db, lib, key, cast): + db.add(PlexItem(rating_key=key, library_id=lib.id, kind="movie", title=key, cast_names=cast)) + + +def _library(db): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + return lib + + +class _Tmdb: + """A TmdbClient stand-in: records the ids asked for, fails on `fail_on`.""" + + def __init__(self, fail_on=None): + self.asked: list[int] = [] + self.fail_on = fail_on + + def fetch_person(self, pid): + self.asked.append(pid) + if pid == self.fail_on: + raise plex_tmdb.TmdbError("429") + return {"biography": f"Bio {pid}."} + + +def test_the_batch_takes_the_most_credited_linked_people_first(db): + lib = _library(db) + _movie(db, lib, "a", ["Often", "Sometimes"]) + _movie(db, lib, "b", ["Often", "Sometimes"]) + _movie(db, lib, "c", ["Often", "Unlinked"]) + db.add_all( + [ + PlexPerson(name="Sometimes", tmdb_person_id=2), + PlexPerson(name="Often", tmdb_person_id=1), + PlexPerson(name="Unlinked"), # no TMDB id: nothing to fetch + PlexPerson(name="Done", tmdb_person_id=3, tmdb_enriched_at=DONE), + PlexPerson(name="Rare", tmdb_person_id=4), # credited nowhere: last + ] + ) + db.commit() + tmdb = _Tmdb() + assert plex_tmdb.enrich_people_batch(db, tmdb, 2) == {"enriched": 2} # type: ignore[arg-type] + assert tmdb.asked == [1, 2] + db.expire_all() + often = db.query(PlexPerson).filter_by(name="Often").one() + assert often.biography == "Bio 1." and often.tmdb_enriched_at is not None + assert db.query(PlexPerson).filter_by(name="Rare").one().tmdb_enriched_at is None # next run + + +def test_a_failed_lookup_ends_the_batch_and_keeps_what_landed(db): + db.add_all([PlexPerson(name="A", tmdb_person_id=1), PlexPerson(name="B", tmdb_person_id=2)]) + db.add(PlexPerson(name="C", tmdb_person_id=3)) + db.commit() + tmdb = _Tmdb(fail_on=2) + assert plex_tmdb.enrich_people_batch(db, tmdb, 10) == {"enriched": 1, "failed": 1} # type: ignore[arg-type] + assert tmdb.asked == [1, 2] # C is not asked: TMDB is struggling + db.expire_all() + stamped = {p.name: p.tmdb_enriched_at is not None for p in db.query(PlexPerson)} + assert stamped == {"A": True, "B": False, "C": False} + + +class _Plex: + def __init__(self, fail=False): + self.asked: list[tuple[str, int | None]] = [] + self.fail = fail + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def image_bytes(self, url, width=None, height=None): + self.asked.append((url, width)) + if self.fail: + raise plex_people.PlexError("down") + return b"small", "image/jpeg" + + +@pytest.fixture +def cache_dir(tmp_path, monkeypatch): + monkeypatch.setattr(imgcache, "CACHE_DIR", tmp_path) + return tmp_path + + +def test_portraits_are_cached_small_and_replace_the_full_size_original(db, cache_dir, monkeypatch): + lib = _library(db) + _movie(db, lib, "a", ["Often"]) + _movie(db, lib, "b", ["Often", "Cached"]) + db.add_all( + [ + PlexPerson(name="Often", photo_thumb=plex_people.person_thumb_url(CDN + "often.jpg")), + PlexPerson(name="Cached", photo_thumb=plex_people.person_thumb_url(CDN + "cached.jpg")), + PlexPerson(name="Faceless"), + ] + ) + db.commit() + small, legacy = plex_people._portrait_keys(CDN + "often.jpg") + imgcache.write(legacy, b"a big original", "image/jpeg") # from before E30 S6 + imgcache.write(plex_people._portrait_keys(CDN + "cached.jpg")[0], b"x", "image/jpeg") + plex = _Plex() + monkeypatch.setattr(plex_people, "PlexClient", lambda d: plex) + + assert plex_people.warm_portraits(db, 10) == {"cached": 1} + assert plex.asked == [ + (CDN + "often.jpg", plex_people.PORTRAIT_BOX) + ] # resized, the cached one skipped + assert imgcache.read(small) == (b"small", "image/jpeg") + assert not imgcache.has(legacy) + + +def test_portraits_wait_for_plex(db, cache_dir, monkeypatch): + db.add(PlexPerson(name="P", photo_thumb=plex_people.person_thumb_url(CDN + "p.jpg"))) + db.commit() + + def not_configured(d): + raise plex_people.PlexNotConfigured() + + monkeypatch.setattr(plex_people, "PlexClient", not_configured) + assert plex_people.warm_portraits(db, 10) == {"cached": 0, "skipped": "disabled"} + assert list(cache_dir.iterdir()) == [] # no full-size originals behind Plex's back + + +def test_a_failing_plex_ends_the_portrait_run(db, cache_dir, monkeypatch): + db.add_all( + [ + PlexPerson(name=f"P{i}", photo_thumb=plex_people.person_thumb_url(CDN + f"{i}.jpg")) + for i in range(8) + ] + ) + db.commit() + plex = _Plex(fail=True) + monkeypatch.setattr(plex_people, "PlexClient", lambda d: plex) + assert plex_people.warm_portraits(db, 100) == {"cached": 0, "failed": 5} + assert len(plex.asked) == 5 + + +def test_a_phase_count_is_a_change_worth_an_audit_row(): + assert scheduler._run_changed({"bios": {"enriched": 3}, "portraits": {"cached": 0}}) + assert scheduler._run_changed({"recent": {"videos_added": 2}, "deep": {}}) + assert not scheduler._run_changed({"bios": {"skipped": "x"}, "portraits": {"cached": 0}}) + + +def test_the_person_page_says_a_bio_is_on_its_way(db, monkeypatch, override_dep): + from fastapi.testclient import TestClient + + import app.state as state + from app.auth import current_user + from app.db import get_db + from app.main import app + from app.routes.plex import people + + db.add_all( + [ + PlexPerson(name="Pending", tmdb_person_id=1), + PlexPerson(name="Known", tmdb_person_id=2, tmdb_enriched_at=DONE, biography="Bio."), + PlexPerson(name="Nobody"), + ] + ) + user = User(email="enriching@example.com") + db.add(user) + db.commit() + monkeypatch.setattr(state, "_configured_cache", True) + override_dep(get_db, db.info["request"]) + override_dep(current_user, lambda: user) + monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: None) + key = {"value": "key"} + monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: key["value"]) + + def enriching(name): + resp = TestClient(app).get(f"/api/plex/person/{name}") + assert resp.status_code == 200, resp.text + return resp.json()["enriching"] + + assert enriching("Pending") is True + assert enriching("Known") is False + assert enriching("Nobody") is False + key["value"] = "" # no TMDB key: nothing will ever arrive, so the page must not wait + assert enriching("Pending") is False + + +def test_known_for_is_the_most_voted_roles_not_guest_visits(): + payload = { + "combined_credits": { + "cast": [ + # Popular this week, but a visit as himself: not what he is known for. + { + "id": 1, + "media_type": "tv", + "name": "Late Show", + "character": "Himself - Guest", + "popularity": 900, + "vote_count": 50, + }, + { + "id": 2, + "media_type": "tv", + "name": "Talk Hour", + "character": "", + "genre_ids": [10767], + "popularity": 800, + "vote_count": 400, + }, + { + "id": 3, + "media_type": "movie", + "title": "Small Film", + "character": "Bob", + "popularity": 90, + "vote_count": 300, + }, + { + "id": 4, + "media_type": "movie", + "title": "Big Film", + "character": "Hero", + "popularity": 10, + "vote_count": 9000, + }, + ], + "crew": [ + { + "id": 5, + "media_type": "movie", + "title": "His Own Film", + "job": "Director", + "vote_count": 1000, + }, + { + "id": 6, + "media_type": "movie", + "title": "Thanked In", + "job": "Thanks", + "vote_count": 99999, + }, + ], + } + } + kf = plex_tmdb.parse_person(payload)["known_for"] + assert [k["title"] for k in kf] == ["Big Film", "His Own Film", "Small Film"] + + +def test_the_page_shows_at_most_fifteen_known_for_titles(db): + from app.routes.plex import people + + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + for i in range(1, 21): # TMDB ids start at 1 + db.add( + PlexItem( + rating_key=f"k{i}", library_id=lib.id, kind="movie", title=f"T{i}", guid=f"tmdb:{i}" + ) + ) + db.commit() + pool = [{"tmdb_id": i, "media_type": "movie", "title": f"T{i}"} for i in range(1, 21)] + cards = people._resolve_known_for(db, pool, 0, [lib.id], [], "en") + assert [c["title"] for c in cards] == [f"T{i}" for i in range(1, 16)] diff --git a/backend/tests/test_plex_person.py b/backend/tests/test_plex_person.py index c0748e51..9ce49958 100644 --- a/backend/tests/test_plex_person.py +++ b/backend/tests/test_plex_person.py @@ -133,6 +133,7 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch, override_dep): "place_of_birth": None, "known_for_department": None, "also_known_as": [], + "enriching": False, "known_for": [], "movies": [], "shows": [], diff --git a/backend/tests/test_plex_person_bio.py b/backend/tests/test_plex_person_bio.py index 58e6c08b..c52e570b 100644 --- a/backend/tests/test_plex_person_bio.py +++ b/backend/tests/test_plex_person_bio.py @@ -191,11 +191,12 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, overri scheduled: list[int] = [] monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) + monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: "key") people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Lazy P") assert resp.status_code == 200, resp.text - assert resp.json()["biography"] is None # not enriched yet — the bio lands by the next open + assert resp.json()["biography"] is None # not enriched yet — the page asks again (`enriching`) assert len(scheduled) == 1 # the enrich was scheduled off the request path diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index d4bbf772..4afbfb0c 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -77,7 +77,8 @@ "plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.", "audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded.", "log_gc": "Deletes stored log events older than the retention window (Configuration → Diagnostics). Runs hourly so nothing outlives the promised limit by more than an hour.", - "tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again." + "tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again.", + "people_enrich": "Makes the people in the Plex library local: fetches each person's biography from TMDB and caches their portrait, resized through Plex, the most-credited people first. A person page and a cast strip then open from the app's own data. The TMDB half does nothing without a TMDB API key, the portrait half nothing without Plex. If it stops, a person page fetches its own bio when opened, a moment later." }, "jobs": { "rss_poll": "RSS poll (new uploads)", @@ -96,7 +97,8 @@ "plex_watch_reconcile": "Plex watch reconcile (full)", "tmdb_enrich": "TMDB enrichment (Plex)", "audit_gc": "Audit-log cleanup", - "log_gc": "Log cleanup" + "log_gc": "Log cleanup", + "people_enrich": "People details and portraits (Plex)" }, "queue": { "recentPending": "Channels to sync", @@ -169,7 +171,10 @@ "sessions_closed": "sessions closed", "sessions_reaped": "sessions cleaned up", "recent": "recent", - "deep": "deep" + "deep": "deep", + "bios": "biographies", + "portraits": "portraits", + "cached": "cached" }, "skip": { "disabled": "disabled", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index e57194a3..721ca1cb 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -73,11 +73,12 @@ "explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.", "download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.", "plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infóoldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő szinkron után jelennek meg.", - "plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes reconcile-ok között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.", + "plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes egyeztetések között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.", "plex_watch_reconcile": "A megtekintési állapot teljes egyeztetése, amely minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben levett megtekintve jel nem tükröződik vissza.", "audit_gc": "Törli a beállított megőrzési időnél (Konfiguráció → Eseménynapló) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul.", "log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.", - "tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül." + "tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.", + "people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később." }, "jobs": { "rss_poll": "RSS-lekérdezés (új feltöltések)", @@ -93,10 +94,11 @@ "download_gc": "Letöltések takarítása", "plex_sync": "Plex-könyvtár szinkron", "plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)", - "plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)", + "plex_watch_reconcile": "Plex megtekintés-egyeztetés (teljes)", "tmdb_enrich": "TMDB-dúsítás (Plex)", "audit_gc": "Eseménynapló-takarítás", - "log_gc": "Naplótakarítás" + "log_gc": "Naplótakarítás", + "people_enrich": "Személyadatok és portrék (Plex)" }, "queue": { "recentPending": "Szinkronra váró csatorna", @@ -169,7 +171,10 @@ "sessions_closed": "lezárt munkamenet", "sessions_reaped": "eltakarított munkamenet", "recent": "friss", - "deep": "mély" + "deep": "mély", + "bios": "életrajzok", + "portraits": "portrék", + "cached": "elmentve" }, "skip": { "disabled": "kikapcsolva", diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 9f3ec200..ea60bdbc 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -239,9 +239,12 @@ export interface PlexPersonCard { export interface PlexPersonDetail { name: string; photo?: string | null; // cached headshot, or null (placeholder) - // TMDB bio (E5 S6), lazily fetched on first open, then served from the row. Null when the person has - // no linked tmdb_person_id (never appeared in a TMDB-enriched title) or TMDB has no data. + // TMDB bio (E5 S6), fetched ahead of time by the people_enrich job (E30 S6) and served from the row. + // Null when the person has no linked tmdb_person_id (never appeared in a TMDB-enriched title), TMDB + // has none, or it is still on its way (`enriching`). biography?: string | null; + // The bio is being fetched right now (a person the job hasn't reached yet): ask again shortly. + enriching?: boolean; birthday?: string | null; // "YYYY-MM-DD" deathday?: string | null; place_of_birth?: string | null; From 63d01babe54c01837751c3e4c1f8a19ef64594ea Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 30 Sep 2026 02:19:54 +0200 Subject: [PATCH 14/23] feat(plex): the person page in two columns; a bio on its way holds its place (E30 S6) The biography sat at reading width in the left half of a full-width panel with nothing on the right. Now the person (headshot, facts, the bio clamped to eight lines with More / Less when it is cut) is on the left and "Known for" on the right; with fewer than four titles the rail takes only its posters' width. One column where the panel is narrow (200% zoom, the largest text size). While the server fetches a bio (`enriching`), the page shows a paragraph skeleton (a new "text" shape of LoadingState) and asks again every 1.5 s for up to 20 s. The view is keyed by the person's name, so another person starts fresh. --- frontend/src/components/PlexBrowse.tsx | 2 + .../components/PlexPersonView.dom.test.tsx | 65 +++++ frontend/src/components/plexViews.tsx | 231 ++++++++++++------ frontend/src/components/ui/Skeleton.tsx | 16 +- frontend/src/i18n/locales/en/plex.json | 5 +- frontend/src/i18n/locales/hu/plex.json | 5 +- 6 files changed, 244 insertions(+), 80 deletions(-) diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 33f7fae7..d71ff5e7 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -468,7 +468,9 @@ export default function PlexBrowse() { if (sub.view.kind === "person") { const personName = sub.view.name; return ( + // Keyed by the name: another person is a fresh page (their own bio wait, a collapsed bio). { } }); }); + +describe("PlexPersonView — a bio on its way (E30 S6)", () => { + const view = ( + {}} + onOpenCard={() => {}} + onRestartCard={() => {}} + onInfoCard={() => {}} + onFilter={() => {}} + /> + ); + const person = (over: object) => ({ + name: "Jane Doe", + photo: null, + also_known_as: [], + known_for: [], + movies: [card("m1", "Movie One")], + shows: [], + ...over, + }); + + it("holds the bio's place and asks again until it lands", async () => { + plexPerson.mockReset(); + plexPerson + .mockResolvedValueOnce(person({ biography: null, enriching: true })) + .mockResolvedValue(person({ biography: "Landed bio." })); + wrap(view); + await screen.findByText("Movie One"); + expect(screen.getByRole("status").textContent).toContain("plex.person.bioLoading"); + await screen.findByText("Landed bio.", undefined, { timeout: 3000 }); + expect(screen.queryByRole("status")).toBeNull(); + expect(plexPerson).toHaveBeenCalledTimes(2); + }); + + it("does not ask again for a person whose bio is not coming", async () => { + plexPerson.mockReset(); + plexPerson.mockResolvedValue(person({ biography: null, enriching: false })); + wrap(view); + await screen.findByText("Movie One"); + await new Promise((r) => setTimeout(r, 1800)); + expect(plexPerson).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("offers More only when the bio is cut, and Less once open", async () => { + const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400); + const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160); + try { + plexPerson.mockReset(); + plexPerson.mockResolvedValue(person({ biography: "A long life.\n\nMany films." })); + wrap(view); + fireEvent.click(await screen.findByText("plex.person.more")); + expect(screen.getByText("plex.person.less").getAttribute("aria-expanded")).toBe("true"); + } finally { + tall.mockRestore(); + box.mockRestore(); + } + cleanup(); + plexPerson.mockResolvedValue(person({ biography: "Short." })); + wrap(view); + await screen.findByText("Short."); + expect(screen.queryByText("plex.person.more")).toBeNull(); // jsdom: nothing is cut + }); +}); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 20c4af6a..3f7d3d09 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useState } from "react"; +import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -119,6 +119,53 @@ export function PlexInfoView({ ); } +// How often, and for how long, a person page asks again for a bio that is on its way (E30 S6). The +// background fetch takes a second or two; twenty seconds covers a slow TMDB without waiting on a dead one. +const BIO_POLL_MS = 1500; +// From this many "Known for" titles the rail gets half the hero; fewer (about one person in five) get +// just their posters' width, so a lone poster doesn't sit in an empty half (E30 S6, measured). +const KNOWN_FOR_HALF = 4; +const BIO_WAIT_MS = 20_000; + +/** A person's biography, clamped to a few lines with a More / Less toggle — offered only when the + * text is actually cut (measured, so a short bio carries no dead button). */ +function PersonBio({ text }: { text: string }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const [clipped, setClipped] = useState(false); + const ref = useRef(null); + useLayoutEffect(() => { + const el = ref.current; + if (!el || expanded) return; + // Re-measured on a resize: the text-size slider and the window both change where the cut falls. + const measure = () => setClipped(el.scrollHeight > el.clientHeight + 1); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [text, expanded]); + return ( +
+

+ {text} +

+ {(clipped || expanded) && ( + + )} +
+ ); +} + // --- Actor detail: a cast/crew member's filmography (movies + shows in one place) --------------- // The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it // shows the person's headshot + name, a "filter the library by them" action, and their titles as two @@ -146,7 +193,18 @@ export function PlexPersonView({ const menu = useContextMenu(); const lang = plexLang(i18n.language); const personKey = qk.plexPerson(name, lang); - const q = useQuery({ queryKey: personKey, queryFn: () => api.plexPerson(name, lang) }); + // A person the people_enrich job hasn't reached yet gets their bio fetched when the page opens (E30 + // S6); the answer says so (`enriching`) and the page asks again until it lands — for a while, not + // forever: past BIO_WAIT_MS a TMDB that is down stops being waited on (the next open tries again). + const [openedAt] = useState(() => Date.now()); + const q = useQuery({ + queryKey: personKey, + queryFn: () => api.plexPerson(name, lang), + refetchInterval: (query) => + query.state.data?.enriching && query.state.dataUpdatedAt - openedAt < BIO_WAIT_MS + ? BIO_POLL_MS + : false, + }); // E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls // out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control. const { artBg, crossfade } = useDetailPrefs(); @@ -230,83 +288,106 @@ export function PlexPersonView({ ) : (
- {/* Hero: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */} -
-
-
- {d.photo ? ( - - ) : ( -
- + {/* Hero (E30 S6, owner pick "A"): the person — headshot, facts, a clamped bio — on the left, + what they are known for on the right, so the bio doesn't sit in a full-width panel with an + empty half beside it. One column where the panel is narrow. */} +
+
= KNOWN_FOR_HALF + ? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]" + : d.known_for.length > 0 + ? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_auto]" + : undefined + } + > +
+
+
+ {d.photo ? ( + + ) : ( +
+ +
+ )}
- )} -
-
- {d.name} - {d.known_for_department && ( -

- {vocab(t, "department", d.known_for_department)} -

- )} -

- {t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })} -

-
- {(d.birthday || d.place_of_birth) && ( -

- {t("plex.person.born")}:{" "} - {[formatDate(d.birthday, lang), d.place_of_birth].filter(Boolean).join(" · ")} +

+ {d.name} + {d.known_for_department && ( +

+ {vocab(t, "department", d.known_for_department)} +

+ )} +

+ {t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}

- )} - {d.deathday && ( -

- {t("plex.person.died")}:{" "} - {formatDate(d.deathday, lang)} -

- )} - {d.also_known_as.length > 0 && ( -

- {t("plex.person.aka")}:{" "} - {d.also_known_as.slice(0, 4).join(", ")} -

- )} +
+ {(d.birthday || d.place_of_birth) && ( +

+ {t("plex.person.born")}:{" "} + {[formatDate(d.birthday, lang), d.place_of_birth] + .filter(Boolean) + .join(" · ")} +

+ )} + {d.deathday && ( +

+ {t("plex.person.died")}:{" "} + {formatDate(d.deathday, lang)} +

+ )} + {d.also_known_as.length > 0 && ( +

+ {t("plex.person.aka")}:{" "} + {d.also_known_as.slice(0, 4).join(", ")} +

+ )} +
+ onFilter({ actors: [d.name] })} className="font-medium"> + {t("plex.person.filterBy", { name: d.name })} + +
- onFilter({ actors: [d.name] })} className="font-medium"> - {t("plex.person.filterBy", { name: d.name })} - -
-
- {d.biography && ( -

- {d.biography} -

- )} -
- - {/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */} - {d.known_for.length > 0 && ( -
- - {d.known_for.map((c, i) => ( -
- onOpenCard(c)} - onToggleWatched={toggle} - {...cardMenu(c)} + {d.biography ? ( + + ) : ( + d.enriching && + q.dataUpdatedAt - openedAt < BIO_WAIT_MS && ( + -
- ))} -
-
- )} + ) + )} +
+ + {/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */} + {d.known_for.length > 0 && ( +
+ + {d.known_for.map((c, i) => ( +
+ onOpenCard(c)} + onToggleWatched={toggle} + {...cardMenu(c)} + /> +
+ ))} +
+
+ )} +
+
{empty ? (

{t("plex.person.empty")}

diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx index a6a06ab3..2919db0e 100644 --- a/frontend/src/components/ui/Skeleton.tsx +++ b/frontend/src/components/ui/Skeleton.tsx @@ -3,7 +3,8 @@ import { SURFACE } from "./surfaces"; // B8 (docs/DESIGN.md): a first load of a grid, list or table shows the SHAPE of what is coming, so // nothing jumps when the data lands. One block primitive (a tonal fill, the Badge's 8% of the text -// colour, pulsing unless the user asked for less motion) and the four page shapes built from it. The +// colour, pulsing unless the user asked for less motion) and the page shapes built from it (plus a +// paragraph's, for prose that arrives after its page). The // shapes are shown through `LoadingState shape=…`, which owns the 300 ms reveal and the status role. export function Skeleton({ className }: { className?: string }) { @@ -15,9 +16,9 @@ export function Skeleton({ className }: { className?: string }) { ); } -export type SkeletonShape = "grid" | "poster" | "list" | "table"; +export type SkeletonShape = "grid" | "poster" | "list" | "table" | "text"; -const COUNT: Record = { grid: 8, poster: 12, list: 6, table: 8 }; +const COUNT: Record = { grid: 8, poster: 12, list: 6, table: 8, text: 5 }; /** The placeholder for a page's first load, by the shape of its content. */ export function SkeletonShapes({ shape }: { shape: SkeletonShape }) { @@ -46,6 +47,15 @@ export function SkeletonShapes({ shape }: { shape: SkeletonShape }) { ))}
); + if (shape === "text") + // A paragraph still on its way (a biography): lines at prose height, the last one short. + return ( +
+ {n.map((i) => ( + + ))} +
+ ); if (shape === "list") return (
diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 20c5eeb5..35e6bd99 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -352,7 +352,10 @@ "born": "Born", "died": "Died", "aka": "Also known as", - "knownFor": "Known for" + "knownFor": "Known for", + "bioLoading": "Loading the biography", + "more": "More", + "less": "Less" }, "view": { "label": "View", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 938a34fb..eee078dc 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -352,7 +352,10 @@ "born": "Született", "died": "Elhunyt", "aka": "Más néven", - "knownFor": "Ismert szerepei" + "knownFor": "Ismert szerepei", + "bioLoading": "Az életrajz betöltése", + "more": "Több", + "less": "Kevesebb" }, "view": { "label": "Nézet", From ca94a716d8208d85d82d93942f2c4ace50717afc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 30 Sep 2026 02:19:54 +0200 Subject: [PATCH 15/23] =?UTF-8?q?docs(design):=20composition=20and=20the?= =?UTF-8?q?=20reader=20=E2=80=94=20a=20chapter=20and=20a=20Conformance=20s?= =?UTF-8?q?tep=20(E30=20S6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rules covered each piece; nothing asked whether a screen works as a whole, and the person page passed every rule with half its panel empty. New chapter: the space is used, one focal point, worst real data, where am I, a click answers at once (no outside service on open), zoom and text size first-class (the owner reads zoomed in). Conformance step 5 writes one composition line per screenshot and checks 200% zoom and the largest text size. Anti-patterns and a decision-log row to match. HU: the download worker setting reads "Párhuzamos letöltések" (a leftover from the S5 sweep). --- docs/DESIGN.md | 39 ++++++++++++++++++++++++ frontend/src/i18n/locales/hu/config.json | 4 +-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f98d2aa3..2350d0d1 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -38,6 +38,11 @@ runs this check before it is called done (E28 S3g): which the measure cannot trace to a component) and the playlist table's own rows. 4. **Seen.** A screenshot of every changed state, thin edges at 1:1; the tablet widths (1024 × 768, a narrow split screen) when the layout can reflow. Phones are the E29 epic's, not this check's. +5. **Judged as a whole** (E30 S6). For each screenshot, one written line on its composition: where the empty + space is, what hugs one side, what the eye lands on first. Then the screen at 200% browser zoom and at + the largest text size. Both are held to [Composition and the reader](#composition-and-the-reader--decided-e30-s6). + A rule that holds is not the same as a screen that works: the person page passed every rule above with + half its panel empty. ## The design lab @@ -435,6 +440,36 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen # - **Reading measure.** Prose (a synopsis, a biography, a channel's description) is capped at ~65 characters a line (the `measure` utility) wherever its container can be wider. [WCAG 1.4.8] +## Composition and the reader — _Decided_ (E30 S6) + +The rules above say how each piece looks; these say whether the screen works as a whole, for the one +reader it is built for. Found when the person page passed every rule with its biography in the left half +of a full-width panel and nothing on the right. + +- **The space is used.** A narrow element in a wide container (prose at `measure`, a form, a short list) + comes with a decision about the rest of the width: a second column with related content (the person + page: facts and bio left, "Known for" right), a container sized to its content, or a layout that stacks. + A band of nothing wider than about a third of a panel, beside content that stops short, is a finding. + [Material layout, Gestalt proximity] +- **One focal point, then an order.** A screen has one thing the eye lands on first (the page heading, the + hero, the player), then the sections, then the metadata. A heading level keeps one look across screens; + a second focal point competing with the first is a finding. [Nielsen #8, HIG hierarchy] +- **Worst real data, not the demo row.** A screen is judged with the longest title, the Hungarian label, + zero items, the largest list, a missing picture and a missing field. None of them clips, overlaps or + leaves a hole where a value was. [Nielsen #9, WCAG 1.4.10] +- **Where am I.** The browser tab names the place and the object in it ("Nicolas Cage · Plex · Siftlode"), + the rail marks the place, and Back returns to the same view at the same scroll position with focus + where it was. [Nielsen #1, WCAG 2.4.2] +- **A click answers at once.** A control shows its pressed or changed state within ~100ms (an optimistic + update, a spinner in the control); what a screen opens with is the app's own data — an outside service + (TMDB, YouTube) is fetched ahead of time by a job, not on the click, so a page never waits on it. + Late content keeps its place (B8). [Nielsen #1, RAIL] +- **Zoom and text size are first-class.** The owner reads with the browser zoomed and a large text size. + Every screen holds at 200% browser zoom on a desktop window and at the largest Settings text size at + 1024 wide: nothing clipped or overlapping, no sideways page scroll, every control reachable and at + least its rung, text wrapping rather than cut. Measured, not assumed, on every UI change. [WCAG 1.4.4, + 1.4.10, 1.4.12] + ## Interaction rules — _Decided_ - **Keyboard:** everything works by keyboard, in visual order, with no traps. A shortcut is a row in @@ -561,6 +596,9 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen # - A click handler on text that swallows the user's text selection. - A colour literal, a px font size, a px-frozen box. - An empty state shown for an error. +- Half a panel left empty beside content that stops short. +- A page that waits on an outside service when the user opens it. +- A screen checked only at 100% zoom and the default text size. ## Decision log @@ -635,6 +673,7 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen # | 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line | | 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture | | 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift | +| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety | | 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places | | 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) | | 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart | diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index b1b8448a..8f6d4344 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -149,8 +149,8 @@ "hint": "„Plex” = Csatorna/Évad ÉÉÉÉ/… fastruktúra .nfo + poszter fájlokkal (Plexben indexelhető); „Egy mappa” = minden egyetlen mappában." }, "download_worker_concurrency": { - "label": "Worker párhuzamosság", - "hint": "Hány letöltést dolgoz fel a worker párhuzamosan." + "label": "Párhuzamos letöltések", + "hint": "Hány letöltést dolgoz fel egyszerre a letöltő folyamat." }, "sponsorblock_default": { "label": "SponsorBlock alapból", From 59e6cc027dfd734855e45f4e5b0a83b151853fd2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 30 Sep 2026 02:27:28 +0200 Subject: [PATCH 16/23] fix(plex): a short "Known for" rail sits right beside the text (E30 S6) With one to three titles (about one person in five) the rail sat at the far edge of the hero with a gap between it and the bio. The person's column is now capped at the bio's reading measure and a short rail follows it directly, so the leftover is the panel's end (owner pick "b"). Four or more keep half the hero. --- .../components/PlexPersonView.dom.test.tsx | 37 +++++++++++++++++++ frontend/src/components/plexViews.tsx | 11 ++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx index 931ef8de..11b5630e 100644 --- a/frontend/src/components/PlexPersonView.dom.test.tsx +++ b/frontend/src/components/PlexPersonView.dom.test.tsx @@ -251,3 +251,40 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => { expect(screen.queryByText("plex.person.more")).toBeNull(); // jsdom: nothing is cut }); }); + +describe("PlexPersonView — the hero's columns (E30 S6)", () => { + const heroGrid = async (knownFor: number) => { + plexPerson.mockReset(); + plexPerson.mockResolvedValue({ + name: "Jane Doe", + photo: null, + biography: "Bio.", + also_known_as: [], + known_for: Array.from({ length: knownFor }, (_, i) => card(`k${i}`, `Known ${i}`)), + movies: [card("m1", "Movie One")], + shows: [], + }); + const { container, unmount } = wrap( + {}} + onOpenCard={() => {}} + onRestartCard={() => {}} + onInfoCard={() => {}} + onFilter={() => {}} + />, + ); + await screen.findByText("Movie One"); + const cls = container.querySelector('section[class~="@container"] > div')!.className; + unmount(); + return cls; + }; + + it("gives a full rail half the hero and puts a short one right beside the text", async () => { + expect(await heroGrid(4)).toContain("@3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"); + const short = await heroGrid(1); + expect(short).toContain("@3xl:grid-cols-[auto_auto]"); + expect(short).toContain("@3xl:justify-start"); + expect(await heroGrid(0)).not.toContain("grid"); + }); +}); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 3f7d3d09..47eb2755 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -122,8 +122,9 @@ export function PlexInfoView({ // How often, and for how long, a person page asks again for a bio that is on its way (E30 S6). The // background fetch takes a second or two; twenty seconds covers a slow TMDB without waiting on a dead one. const BIO_POLL_MS = 1500; -// From this many "Known for" titles the rail gets half the hero; fewer (about one person in five) get -// just their posters' width, so a lone poster doesn't sit in an empty half (E30 S6, measured). +// From this many "Known for" titles the rail gets half the hero. Fewer (about one person in five) sit +// right beside the text, at their posters' width, and what is left over is the panel's end — not a hole +// between the text and a lone poster at the far edge (E30 S6, owner pick "b"). const KNOWN_FOR_HALF = 4; const BIO_WAIT_MS = 20_000; @@ -297,11 +298,13 @@ export function PlexPersonView({ d.known_for.length >= KNOWN_FOR_HALF ? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]" : d.known_for.length > 0 - ? "grid gap-6 @3xl:grid-cols-[minmax(0,1fr)_auto]" + ? "grid gap-6 @3xl:grid-cols-[auto_auto] @3xl:justify-start" : undefined } > -
+ {/* The person's column is at most the bio's reading measure (text-sm, as the bio is), so a + short rail can sit right beside it. */} +
{d.photo ? ( From ab963c81d2a0174d5d020a433470b0992202f8ff Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 30 Sep 2026 03:26:57 +0200 Subject: [PATCH 17/23] =?UTF-8?q?fix(plex,audit):=20E30=20review=20round?= =?UTF-8?q?=201=20=E2=80=94=20the=20people=20job=20keeps=20going,=20HU=20g?= =?UTF-8?q?enres=20everywhere,=20results=20as=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - people_enrich: a failed TMDB lookup or a dead headshot is skipped, not the end of the run (a dead file is marked and left for a week; three failures in a row end it); portraits wait for the Plex module switch; a no-Plex original keeps its own cache key, so the resize still replaces it; the job entry lives in the plex domain (run_people_enrich) - person-image takes only a headshot-shaped CDN url (no query variants of one file) - every synced row gets Hungarian genres, and a HU genre filter matches the word as picked too; migration 0094 fills the rows no overlay reached - a job run's audit row carries its result as data; audit_clear / demo_reset store their counts - one credit-column list for the prune and the enrichment order; _overlay_pass typed - tests: batch skip + stop, HU bio stored, dead headshots, module switch, the person-image route, the job body, genre filter + facet write + twin fallback, tagline/summary fallbacks, audit data, the reader's alphabet at every remaining collated() call site --- .../0094_plex_genres_hu_everywhere.py | 45 ++++ backend/app/plex/imgcache.py | 24 +++ backend/app/plex/people.py | 118 ++++++---- backend/app/plex/query.py | 12 +- backend/app/plex/sync.py | 19 +- backend/app/plex/tmdb.py | 37 +++- backend/app/routes/admin.py | 6 +- backend/app/routes/audit.py | 8 +- backend/app/routes/plex/playback.py | 9 +- backend/app/scheduler.py | 30 +-- backend/tests/test_collation.py | 93 ++++++++ backend/tests/test_plex_lang.py | 32 ++- backend/tests/test_plex_overlay.py | 36 ++++ backend/tests/test_plex_people_enrich.py | 201 +++++++++++++++--- backend/tests/test_scheduler.py | 19 ++ 15 files changed, 571 insertions(+), 118 deletions(-) create mode 100644 backend/alembic/versions/0094_plex_genres_hu_everywhere.py diff --git a/backend/alembic/versions/0094_plex_genres_hu_everywhere.py b/backend/alembic/versions/0094_plex_genres_hu_everywhere.py new file mode 100644 index 00000000..5e9c47f0 --- /dev/null +++ b/backend/alembic/versions/0094_plex_genres_hu_everywhere.py @@ -0,0 +1,45 @@ +"""Hungarian genres on every Plex row, not only on overlaid ones (E30 review) + +Revision ID: 0094_plex_genres_hu_everywhere +Revises: 0093_plex_person_known_for_votes +Create Date: 2026-09-30 + +The HU genre facets and filter read `genres_hu`, which only the overlay pass wrote — so a library with +no HU twin, or a guid-less row, offered English genres a Hungarian filter could never match. The sync +now writes `genres_hu` for every row (plex/sync.py `_apply_facet_fields`); this fills the rows it has +not reached yet, from their own English list through the same map. +""" + +import json + +import sqlalchemy as sa +from alembic import op + +from app.plex.genres import hu_genres + +revision: str = "0094_plex_genres_hu_everywhere" +down_revision: str | None = "0093_plex_person_known_for_votes" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + conn = op.get_bind() + for table in ("plex_items", "plex_shows"): + rows = conn.execute( + sa.text( + f"SELECT id, genres FROM {table} " + "WHERE genres_hu IS NULL AND jsonb_typeof(genres) = 'array'" + ) + ).fetchall() + updates = [ + {"id": rid, "g": json.dumps(hu)} for rid, genres in rows if (hu := hu_genres(genres)) + ] + if updates: + conn.execute( + sa.text(f"UPDATE {table} SET genres_hu = CAST(:g AS jsonb) WHERE id = :id"), updates + ) + + +def downgrade() -> None: + pass # derived from `genres`; the sync writes the same values again diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py index 1053e714..130c241d 100644 --- a/backend/app/plex/imgcache.py +++ b/backend/app/plex/imgcache.py @@ -8,6 +8,9 @@ few hundred MB at most for a big library — acceptable, matching the download d The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see ``subtitles.py``). """ +import time +from datetime import timedelta + from fastapi import Response from sqlalchemy.orm import Session @@ -78,6 +81,27 @@ def drop(key: str) -> None: pass +def mark_miss(key: str) -> None: + """Remember that `key`'s source could not be fetched (an empty `.miss` file; its mtime is when), so + a prefetch can leave a dead source alone for a while. Best-effort.""" + try: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + (CACHE_DIR / f"{key}.miss").touch() + # silent-ok: without the mark the dead source is only asked for again on the next run. + except OSError: + pass + + +def missed_within(key: str, age: timedelta) -> bool: + """`key` was marked as a miss less than `age` ago.""" + try: + mtime = (CACHE_DIR / f"{key}.miss").stat().st_mtime + # silent-ok: no mark (the usual case). + except OSError: + return False + return time.time() - mtime < age.total_seconds() + + def write(key: str, data: bytes, ct: str) -> None: ext = _CT_EXT.get((ct or "").split(";")[0].strip(), "jpg") try: diff --git a/backend/app/plex/people.py b/backend/app/plex/people.py index 88e6012e..0e1e6d61 100644 --- a/backend/app/plex/people.py +++ b/backend/app/plex/people.py @@ -4,6 +4,8 @@ orphaned people. Used by BOTH the info-page lazy enrichment (routes.plex) and th re-sync (plex.sync), so the two paths never diverge.""" import hashlib +import re +from datetime import timedelta from typing import Literal from urllib.parse import quote, unquote @@ -12,7 +14,7 @@ from sqlalchemy import select, text from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session -from app import logs +from app import logs, sysconfig from app.models import PlexPerson from app.plex import imgcache from app.plex.client import PlexClient, PlexError, PlexNotConfigured @@ -97,14 +99,23 @@ def enrich_row_people(db: Session, row, cast: list[dict]) -> bool: return dirty +# Every credit reference: one person name per (movie/show, credit column). The ONE list of credit +# columns, so the orphan prune and the enrichment order never disagree about who is credited. +_CREDIT_COLUMNS = [ + (table, col) for table in ("plex_items", "plex_shows") for col in ("cast_names", "directors") +] + + +def _credit_refs_sql(union: Literal["UNION", "UNION ALL"]) -> str: + return f" {union} ".join( + f"SELECT jsonb_array_elements_text({col}) FROM {table} WHERE jsonb_typeof({col}) = 'array'" + for table, col in _CREDIT_COLUMNS + ) + + # The person names still referenced by ANY movie/show (cast OR directors). plex_people rows outside # this set are orphans left by a library removal. -_REFERENCED_PEOPLE_SQL = """ - SELECT jsonb_array_elements_text(cast_names) FROM plex_items WHERE jsonb_typeof(cast_names) = 'array' - UNION SELECT jsonb_array_elements_text(directors) FROM plex_items WHERE jsonb_typeof(directors) = 'array' - UNION SELECT jsonb_array_elements_text(cast_names) FROM plex_shows WHERE jsonb_typeof(cast_names) = 'array' - UNION SELECT jsonb_array_elements_text(directors) FROM plex_shows WHERE jsonb_typeof(directors) = 'array' -""" +_REFERENCED_PEOPLE_SQL = _credit_refs_sql("UNION") def prune_orphan_people(db: Session) -> int: @@ -125,14 +136,9 @@ def prune_orphan_people(db: Session) -> int: # How often each person is credited across movies, shows and episodes (cast OR director) — the order the # people_enrich job works in, so the faces met most often are local first. -_CREDIT_COUNTS_SQL = """ - SELECT ref, count(*) AS n FROM ( - SELECT jsonb_array_elements_text(cast_names) FROM plex_items WHERE jsonb_typeof(cast_names) = 'array' - UNION ALL SELECT jsonb_array_elements_text(directors) FROM plex_items WHERE jsonb_typeof(directors) = 'array' - UNION ALL SELECT jsonb_array_elements_text(cast_names) FROM plex_shows WHERE jsonb_typeof(cast_names) = 'array' - UNION ALL SELECT jsonb_array_elements_text(directors) FROM plex_shows WHERE jsonb_typeof(directors) = 'array' - ) AS refs(ref) GROUP BY ref -""" +_CREDIT_COUNTS_SQL = ( + f"SELECT ref, count(*) AS n FROM ({_credit_refs_sql('UNION ALL')}) AS refs(ref) GROUP BY ref" +) _NEEDS = { # linked to TMDB, bio never fetched "bio": "p.tmdb_person_id IS NOT NULL AND p.tmdb_enriched_at IS NULL", @@ -160,34 +166,49 @@ def people_by_credits( # A Plex CDN headshot is a full-size original (~170 KB on average, measured). Plex's photo transcoder # fits it into this box (~13 KB), which covers the largest place a portrait is drawn (the person page's # 6rem avatar) at 2x density and a raised text size. The box is part of the cache key, like a poster's: -# a new box re-caches instead of serving a stale size, and the full-size original under the old key is -# removed as its small twin lands. +# a new box re-caches instead of serving a stale size. Only a resized image is ever written under it — +# an original fetched without Plex keeps its own key, and goes once its small twin lands. PORTRAIT_BOX = 320 +# A headshot Plex answered with an error for (a dead CDN file) is not asked for again this long, so a +# few dead faces never hold up the rest of the prefetch. +PORTRAIT_RETRY = timedelta(days=7) +# The one shape a Plex CDN headshot has (…/7/people/.jpg): no query, no fragment. The +# person-image proxy takes nothing else, so it cannot be made to cache endless variants of one file +# (the image cache has no eviction). +_PORTRAIT_URL = re.compile(re.escape(PERSON_IMG_HOST) + r"[A-Za-z0-9_./-]+") + + +def is_portrait_url(url: str) -> bool: + return _PORTRAIT_URL.fullmatch(url) is not None def _portrait_keys(url: str) -> tuple[str, str]: + """(the resized portrait's key, the full-size original's key).""" base = "person_" + hashlib.sha1(url.encode()).hexdigest() return f"{base}_{PORTRAIT_BOX}", base def portrait_cached(url: str) -> tuple[bytes, str] | None: - return imgcache.read(_portrait_keys(url)[0]) + """The cached portrait: the resized one, else an original kept from a time without Plex.""" + small, original = _portrait_keys(url) + return imgcache.read(small) or imgcache.read(original) def cache_portrait(plex: PlexClient | None, url: str) -> tuple[bytes, str]: - """Fetch one CDN headshot — resized through Plex when a Plex client is given, else the original - straight from the public CDN — and cache it. Raises PlexError / httpx.HTTPError on a failed fetch.""" - key, legacy = _portrait_keys(url) - if plex is not None: - data, ct = plex.image_bytes(url, PORTRAIT_BOX, PORTRAIT_BOX) - else: + """Fetch one CDN headshot and cache it. With a Plex client it is resized (the box key; the original + is removed); without one the original comes straight from the public CDN and is cached under its own + key, so the job still resizes it once Plex is there. Raises PlexError / httpx.HTTPError.""" + small, original = _portrait_keys(url) + if plex is None: with httpx.Client(timeout=10.0, follow_redirects=True) as c: r = c.get(url) r.raise_for_status() data, ct = r.content, r.headers.get("content-type", "image/jpeg") - imgcache.write(key, data, ct) - if plex is not None: - imgcache.drop(legacy) + imgcache.write(original, data, ct) + return data, ct + data, ct = plex.image_bytes(url, PORTRAIT_BOX, PORTRAIT_BOX) + imgcache.write(small, data, ct) + imgcache.drop(original) return data, ct @@ -195,17 +216,23 @@ def cdn_url(photo_thumb: str) -> str | None: """The CDN url inside a stored proxied `/person-image?u=` value (see person_thumb_url).""" _, _, q = photo_thumb.partition("?u=") url = unquote(q) - return url if url.startswith(PERSON_IMG_HOST) else None + return url if is_portrait_url(url) else None + + +# Plex not answering this many headshots in a row ends a run: it is Plex that is failing, not the files. +_PLEX_DOWN_AFTER = 3 def warm_portraits(db: Session, limit: int) -> dict: """Cache up to `limit` not-yet-cached headshots, the most-credited people first (E30 S6), so a - person's face comes from local disk. Needs Plex (the resize); without it, nothing is prefetched — - full-size originals for every person would cost gigabytes. A failed fetch is skipped; one failing - Plex ends the run.""" + person's face comes from local disk. Needs Plex (the resize) and the Plex module on; without them + nothing is prefetched — full-size originals for every person would cost gigabytes. A headshot Plex + answers with an error for is marked and left for PORTRAIT_RETRY; Plex not answering ends the run.""" + if not sysconfig.get_bool(db, "plex_enabled"): + return {"cached": 0, "skipped": "disabled"} try: plex = PlexClient(db) - # silent-ok: Plex off is a setting, not a failure; the run's result says "disabled". + # silent-ok: no Plex server set up is a setting, not a failure; the run's result says "disabled". except PlexNotConfigured: return {"cached": 0, "skipped": "disabled"} photos: dict[int, str] = { @@ -215,25 +242,34 @@ def warm_portraits(db: Session, limit: int) -> dict: ).tuples() if thumb } - done = failed = 0 + done = dead = down = 0 with plex: for pid in people_by_credits(db, "portrait", None): - if done + failed >= limit: + if done + dead >= limit or down >= _PLEX_DOWN_AFTER: break url = cdn_url(photos.get(pid) or "") - if url is None or imgcache.has(_portrait_keys(url)[0]): + if url is None: + continue + key = _portrait_keys(url)[0] + if imgcache.has(key) or imgcache.missed_within(key, PORTRAIT_RETRY): continue try: cache_portrait(plex, url) - done += 1 - except (PlexError, httpx.HTTPError) as exc: - failed += 1 + done, down = done + 1, 0 + except PlexError as exc: + # An error status is Plex's answer about this one file; no answer is Plex itself. + if isinstance(exc.__cause__, httpx.HTTPStatusError): + dead += 1 + imgcache.mark_miss(key) + else: + down += 1 log.warning( "a portrait could not be cached", extra=logs.event( - "plex.people.portrait_failed", person_id=pid, reason=type(exc).__name__ + "plex.people.portrait_failed", + person_id=pid, + reason=type(exc.__cause__).__name__, ), ) - if failed >= 5: - break + failed = dead + down return {"cached": done, **({"failed": failed} if failed else {})} diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 2bcf6366..636d34f1 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -182,13 +182,15 @@ 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] + if lang == "hu": + # The HU column holds one Hungarian word per genre (plex/genres.py), so a filter saved while + # it offered "Action" is read as "Akció" too. The word as picked still matches as well: a row + # the sync has not reached since keeps its English (or Plex's mixed) list until it does. + gc = [or_(*(gcol.contains([w]) for w in dict.fromkeys((hu_genre(g), g)))) for g in gsel] + else: + gc = [gcol.contains([g]) for g in gsel] conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc)) crs = split_csv(p.get("content_ratings")) if crs: diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index b8e05dfd..90f358b9 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -15,6 +15,7 @@ dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are item-detail/player path (P2), not here. """ +from collections.abc import Iterable from datetime import datetime, timezone from sqlalchemy import func, select @@ -449,6 +450,10 @@ def _apply_facet_fields(row, meta: dict) -> None: row.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255] row.originally_available_at = _date(meta.get("originallyAvailableAt")) row.genres = _tags(meta, "Genre") + # Every row has Hungarian genres, not only one with an HU overlay twin (the overlay pass, which runs + # after this, puts the twin's own list over it): the HU genre facets and filter read this column, + # and an English word there could never match a filter read in Hungarian. + row.genres_hu = hu_genres(row.genres) row.directors = _tags(meta, "Director") # The listing carries only the top ~20 cast, so re-applying it would DOWNGRADE an already-enriched # full cast (up to 24, grown by the rich backfill/info-page enrich), silently breaking the @@ -702,13 +707,19 @@ def _sync_overlay( def _overlay_pass( - db: Session, plex: PlexClient, lib: PlexLibrary, item_type: int, rows, stats: dict -): + db: Session, + plex: PlexClient, + lib: PlexLibrary, + item_type: int, + rows: Iterable[PlexItem] | Iterable[PlexShow], + stats: dict, +) -> None: """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] = {} + by_guid: dict[str, list[PlexItem | PlexShow]] = {} for r in rows: - by_guid.setdefault(r.guid, []).append(r) + if r.guid: # the passes select guid-bearing rows only + 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 diff --git a/backend/app/plex/tmdb.py b/backend/app/plex/tmdb.py index bd980516..0f0896c9 100644 --- a/backend/app/plex/tmdb.py +++ b/backend/app/plex/tmdb.py @@ -24,7 +24,7 @@ from app import logs, sysconfig from app.config import settings from app.models import PlexItem, PlexPerson, PlexShow from app.plex.cards import display_title -from app.plex.people import people_by_credits +from app.plex.people import people_by_credits, warm_portraits log = logs.get_logger(__name__) # Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host. @@ -417,6 +417,19 @@ def run_enrich(db: Session) -> dict: return {"enriched": n, **({"countries_coded": coded} if coded else {})} +def run_people_enrich(db: Session) -> dict: + """Scheduler entry point of people_enrich (E30 S6): a batch of person bios (the most-credited + first) and a batch of portraits. Each half is a no-op — never an error — without its TMDB key / + without Plex.""" + batch = settings.people_enrich_batch + if sysconfig.get_str(db, "tmdb_api_key"): + with TmdbClient(db) as tmdb: + bios = enrich_people_batch(db, tmdb, batch) + else: + bios = {"skipped": "tmdb_api_key not set"} + return {"bios": bios, "portraits": warm_portraits(db, batch)} + + def resolve_library_guids( db: Session, entries: list[dict] | None, @@ -627,14 +640,21 @@ def enrich_person(db: Session, person: PlexPerson) -> bool: return True +# Lookups failing this many people in a row end a people batch: TMDB itself is degraded, and hammering +# it helps nobody. One person failing alone is skipped, so an id TMDB keeps refusing never stalls the rest. +_PEOPLE_FAILS_IN_A_ROW = 3 + + def enrich_people_batch(db: Session, tmdb: TmdbClient, limit: int) -> dict: """Fetch up to `limit` linked, not-yet-enriched people (E30 S6), the most-credited first, so a person page reads its bio from the row instead of waiting on TMDB. Each person is committed as it - lands: a page open during the run already sees it. A transient TmdbError ends the batch (TMDB is - degraded; hammering it helps nobody) and the rest wait for the next run.""" + lands: a page open during the run already sees it. A failed lookup is skipped (left unstamped for the + next run); _PEOPLE_FAILS_IN_A_ROW of them in a row end the batch.""" ids = people_by_credits(db, "bio", limit) - done = 0 + done = failed = in_a_row = 0 for pid in ids: + if in_a_row >= _PEOPLE_FAILS_IN_A_ROW: + break person = db.get(PlexPerson, pid) # A page open may have enriched this person since the select: skip, don't fetch twice. if person is None or person.tmdb_enriched_at is not None: @@ -643,13 +663,14 @@ def enrich_people_batch(db: Session, tmdb: TmdbClient, limit: int) -> dict: _fetch_person_into(tmdb, person) except TmdbError as exc: db.rollback() + failed, in_a_row = failed + 1, in_a_row + 1 log.warning( - "the TMDB person batch stopped on a failed lookup", + "a TMDB person lookup in the batch failed", extra=logs.event( "plex.tmdb.people_batch_failed", person_id=pid, reason=type(exc).__name__ ), ) - return {"enriched": done, "failed": 1} + continue db.commit() - done += 1 - return {"enriched": done} + done, in_a_row = done + 1, 0 + return {"enriched": done, **({"failed": failed} if failed else {})} diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index b58ff724..a24c0326 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -834,7 +834,11 @@ def reset_demo(admin: User = Depends(admin_user), db: Session = Depends(get_db)) demo = get_or_create_demo_user(db) seeded = reset_demo_state(db, demo) audit.record( - db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded" + db, + admin.id, + AuditAction.DEMO_RESET, + summary=f"{seeded} sample playlists re-seeded", + after={"playlists_seeded": seeded}, # the count as data: named in the reader's language ) db.commit() return {"ok": True, "reset": True, "playlists_seeded": seeded} diff --git a/backend/app/routes/audit.py b/backend/app/routes/audit.py index d9a907b9..1c007231 100644 --- a/backend/app/routes/audit.py +++ b/backend/app/routes/audit.py @@ -66,6 +66,12 @@ def list_audit( def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: """Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence).""" n = db.execute(delete(AuditLog)).rowcount or 0 - audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries") + audit.record( + db, + admin.id, + AuditAction.AUDIT_CLEAR, + summary=f"Cleared {n} audit entries", + after={"cleared": n}, # the count as data: the log names it in the reader's language + ) db.commit() return {"ok": True, "cleared": n} diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index d39ad6e7..562973a4 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -213,11 +213,12 @@ def _resume_push_superseded(user_id: int, item_id: int, intent: datetime) -> boo @router.get("/person-image") def person_image(u: str, request: Request) -> Response: - """Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed), - resized through Plex and cached (plex.people.cache_portrait; the people_enrich job fills the same - cache ahead of time). Signed-session auth; like /image, the DB session is released before the fetch.""" + """Proxy a cast/crew photo from Plex's public metadata CDN (only a headshot-shaped URL on its host; + no token needed), resized through Plex and cached (plex.people.cache_portrait; the people_enrich job + fills the same cache ahead of time). Signed-session auth; like /image, the DB session is released + before the fetch.""" _require_session(request) - if not u.startswith(plex_people.PERSON_IMG_HOST): + if not plex_people.is_portrait_url(u): raise refused(400, "image_host") hit = plex_people.portrait_cached(u) if hit: diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index ca6b4257..feaf43fd 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -18,6 +18,7 @@ from app.downloads.gc import run_download_gc from app.models import SchedulerSetting, User from app.plex.sync import sync as run_plex_sync from app.plex.tmdb import run_enrich as run_tmdb_enrich +from app.plex.tmdb import run_people_enrich from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync from app.notifications import create_notification from app.state import is_sync_paused @@ -160,6 +161,9 @@ def _audit_run(db, actor_id, job_id, status, summary, result) -> None: they changed something, so routine no-op polls don't spam the trail. Best-effort.""" if actor_id is None and status == "ok" and not _run_changed(result): return + # The result as data too (E30): the audit log names it in the reader's language, as the inbox + # does; `summary` stays the English line of the row's own summary column. + data = _result_data(result) try: audit.record( db, @@ -168,7 +172,7 @@ def _audit_run(db, actor_id, job_id, status, summary, result) -> None: target_type="job", target_id=job_id, summary=f"{job_id}: {status}" + (f" — {summary}" if summary else ""), - after={"status": status, "summary": summary}, + after={"status": status, "summary": summary, **({"result": data} if data else {})}, ) db.commit() except Exception: @@ -205,7 +209,7 @@ def _job(name: str, fn) -> None: ) if actor_id is not None: _notify_done(db, actor_id, name, "skipped", "sync paused", _PAUSED) - _audit_run(db, actor_id, name, "skipped", "sync paused", None) + _audit_run(db, actor_id, name, "skipped", "sync paused", _PAUSED) return with progress.bind(sink): result = fn(db) @@ -252,7 +256,7 @@ _PAUSED = {"skipped": "sync paused"} def _result_data(result) -> dict | None: """A job's result as the client names it in the reader's language (E30): the dict itself (counts, a `skipped` / `reason` word, a nested dict per phase), `enrich`'s bare count as one. The one-line - `_summarize` is the English fallback — and what the audit log keeps.""" + `_summarize` is the English fallback.""" if isinstance(result, dict): return result or None if isinstance(result, int) and not isinstance(result, bool): @@ -410,23 +414,9 @@ def _tmdb_enrich_job() -> None: def _people_enrich_job() -> None: - # Make the people local (E30 S6): a batch of person bios from TMDB (the most-credited first) and a - # batch of portraits resized through Plex into the image cache, so a person page and a cast strip - # never wait on an outside service. Each half is a no-op without its key / without Plex. - def work(db): - from app import sysconfig - from app.plex import people as plex_people - from app.plex import tmdb as plex_tmdb - - batch = settings.people_enrich_batch - if sysconfig.get_str(db, "tmdb_api_key"): - with plex_tmdb.TmdbClient(db) as tmdb: - bios = plex_tmdb.enrich_people_batch(db, tmdb, batch) - else: - bios = {"skipped": "tmdb_api_key not set"} - return {"bios": bios, "portraits": plex_people.warm_portraits(db, batch)} - - _job("people_enrich", work) + # Make the people local (E30 S6): a batch of person bios from TMDB and a batch of portraits resized + # through Plex, so a person page and a cast strip never wait on an outside service. + _job("people_enrich", run_people_enrich) def _audit_gc_job() -> None: diff --git a/backend/tests/test_collation.py b/backend/tests/test_collation.py index f1d0ba42..2558be23 100644 --- a/backend/tests/test_collation.py +++ b/backend/tests/test_collation.py @@ -207,3 +207,96 @@ def test_channel_discovery_keeps_that_order_after_enriching(db, monkeypatch, ove _seed_discovery(db, user, synced=False) r = _client(db, user, monkeypatch, override_dep).get("/api/channels/discovery") assert [c["title"] for c in r.json()] == HU + + +# --- The other lists the server puts in the reader's alphabet (E30 review): each call site, once. --- + + +def _others(db, n_offset: int = 0) -> list[User]: + users = [ + User(email=f"{w.lower()}@example.com", display_name=w) for w in WORDS + ] # emails and names in WORDS' order + db.add_all(users) + db.flush() + return users + + +def test_the_share_and_message_recipients_are_in_the_readers_alphabet( + db, monkeypatch, override_dep +): + from app.models import MessageKey + + user = _user(db, "hu") + others = _others(db) + db.add_all( + MessageKey( + user_id=u.id, + public_key="k", + wrapped_private_key="w", + salt="s", + wrap_iv="i", + key_check="c", + ) + for u in others + ) + db.commit() + client = _client(db, user, monkeypatch, override_dep) + emails = [r["email"] for r in client.get("/api/downloads/recipients").json()] + assert emails == [f"{w.lower()}@example.com" for w in HU] + names = [r["name"] for r in client.get("/api/messages/users").json()] + assert names == HU + + +def test_the_blocked_channels_are_in_the_readers_alphabet(db, monkeypatch, override_dep): + from app.models import BlockedChannel + + user = _user(db, "hu") + db.add_all(Channel(id=f"UC{i}", title=w) for i, w in enumerate(WORDS)) + db.flush() + db.add_all(BlockedChannel(user_id=user.id, channel_id=f"UC{i}") for i in range(len(WORDS))) + db.commit() + r = _client(db, user, monkeypatch, override_dep).get("/api/channels/blocked/list") + assert [c["title"] for c in r.json()] == HU + + +def test_the_plex_libraries_and_collections_are_in_the_readers_alphabet( + db, monkeypatch, override_dep +): + user = _user(db, "hu") + libs = [ + PlexLibrary(plex_key=str(i), title=w, kind="movie", enabled=True) + for i, w in enumerate(WORDS) + ] + db.add_all(libs) + db.flush() + db.add_all( + PlexCollection(rating_key=f"c{i}", library_id=libs[0].id, title=w, child_count=1) + for i, w in enumerate(WORDS) + ) + db.commit() + client = _client(db, user, monkeypatch, override_dep) + assert [lib["title"] for lib in client.get("/api/plex/libraries").json()["libraries"]] == HU + cols = client.get("/api/plex/collections").json()["collections"] + assert [c["title"] for c in cols] == HU + + +def test_a_persons_titles_of_one_year_are_in_the_readers_alphabet(db, monkeypatch, override_dep): + user = _user(db, "hu") + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all( + PlexItem( + rating_key=f"m{i}", + library_id=lib.id, + kind="movie", + title=f"EN {i}", + title_hu=w, + year=2000, + cast_names=["Anna"], + ) + for i, w in enumerate(WORDS) + ) + db.commit() + body = _client(db, user, monkeypatch, override_dep).get("/api/plex/person/Anna?lang=hu").json() + assert [m["title"] for m in body["movies"]] == HU diff --git a/backend/tests/test_plex_lang.py b/backend/tests/test_plex_lang.py index 15465fe1..00da79c9 100644 --- a/backend/tests/test_plex_lang.py +++ b/backend/tests/test_plex_lang.py @@ -13,6 +13,7 @@ from app.auth import current_user from app.db import get_db from app.main import app from app import sysconfig +from app.plex.cards import display_summary, display_title from app.models import ( PlexCollection, PlexItem, @@ -57,7 +58,9 @@ def _seed(db): library_id=lib.id, kind="movie", title="EN Only", - genres=["Comedy"], # no HU twin + summary="English only.", + tagline="English only tagline.", + genres=["Comedy"], # no HU twin, never reached by a sync since: no genres_hu year=2002, added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), people_enriched_at=datetime(2024, 1, 2, tzinfo=timezone.utc), @@ -125,6 +128,33 @@ def test_a_genre_filter_saved_in_english_still_matches_in_hungarian(db, monkeypa assert _titles(resp) == {"Magyar Cím"} +def test_a_genre_the_hungarian_list_offers_in_english_still_matches(db, monkeypatch, override_dep): + """E30 review: a row the sync has not given Hungarian genres yet is listed under its English word + (the facets read coalesce(genres_hu, genres)); filtering by that word must find it.""" + user = _seed(db) + facets = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=movie&lang=hu") + assert "Comedy" in {g["value"] for g in facets.json()["genres"]} + resp = _get( + db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&lang=hu&genres=Comedy" + ) + assert _titles(resp) == {"EN Only"} + + +def test_a_title_without_a_hungarian_twin_reads_in_english(db, monkeypatch, override_dep): + user = _seed(db) + body = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m2?lang=hu").json() + assert (body["title"], body["summary"], body["tagline"]) == ( + "EN Only", + "English only.", + "English only tagline.", + ) + row = PlexItem(title="T", summary="S") + assert (display_title(row, "hu"), display_summary(row, "hu")) == ("T", "S") + row.title_hu, row.summary_hu = "HT", "HS" + assert (display_title(row, "hu"), display_summary(row, "hu")) == ("HT", "HS") + assert (display_title(row, "en"), display_summary(row, "en")) == ("T", "S") + + 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") diff --git a/backend/tests/test_plex_overlay.py b/backend/tests/test_plex_overlay.py index ae8fab37..4d1706e8 100644 --- a/backend/tests/test_plex_overlay.py +++ b/backend/tests/test_plex_overlay.py @@ -334,6 +334,42 @@ def test_the_overlay_gives_every_row_one_hungarian_word_per_genre(db): 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" diff --git a/backend/tests/test_plex_people_enrich.py b/backend/tests/test_plex_people_enrich.py index b866b688..c59cfeb9 100644 --- a/backend/tests/test_plex_people_enrich.py +++ b/backend/tests/test_plex_people_enrich.py @@ -6,6 +6,7 @@ DB lane (Postgres); the portrait cache runs in a temp directory. from datetime import datetime, timezone +import httpx import pytest from app import scheduler @@ -32,13 +33,13 @@ def _library(db): class _Tmdb: """A TmdbClient stand-in: records the ids asked for, fails on `fail_on`.""" - def __init__(self, fail_on=None): + def __init__(self, fail_on=()): self.asked: list[int] = [] - self.fail_on = fail_on + self.fail_on = set(fail_on) def fetch_person(self, pid): self.asked.append(pid) - if pid == self.fail_on: + if pid in self.fail_on: raise plex_tmdb.TmdbError("429") return {"biography": f"Bio {pid}."} @@ -67,22 +68,52 @@ def test_the_batch_takes_the_most_credited_linked_people_first(db): assert db.query(PlexPerson).filter_by(name="Rare").one().tmdb_enriched_at is None # next run -def test_a_failed_lookup_ends_the_batch_and_keeps_what_landed(db): +def test_a_failed_lookup_is_skipped_and_the_rest_still_land(db): db.add_all([PlexPerson(name="A", tmdb_person_id=1), PlexPerson(name="B", tmdb_person_id=2)]) db.add(PlexPerson(name="C", tmdb_person_id=3)) db.commit() - tmdb = _Tmdb(fail_on=2) - assert plex_tmdb.enrich_people_batch(db, tmdb, 10) == {"enriched": 1, "failed": 1} # type: ignore[arg-type] - assert tmdb.asked == [1, 2] # C is not asked: TMDB is struggling + tmdb = _Tmdb(fail_on={2}) + assert plex_tmdb.enrich_people_batch(db, tmdb, 10) == {"enriched": 2, "failed": 1} # type: ignore[arg-type] + assert tmdb.asked == [1, 2, 3] # B refused: skipped, not the end of the batch db.expire_all() stamped = {p.name: p.tmdb_enriched_at is not None for p in db.query(PlexPerson)} - assert stamped == {"A": True, "B": False, "C": False} + assert stamped == {"A": True, "B": False, "C": True} # B is tried again next run + + +def test_failures_in_a_row_end_the_batch(db): + db.add_all([PlexPerson(name=f"P{i}", tmdb_person_id=i) for i in range(1, 7)]) + db.commit() + tmdb = _Tmdb(fail_on={1, 2, 3, 4, 5, 6}) # TMDB is down + assert plex_tmdb.enrich_people_batch(db, tmdb, 10) == {"enriched": 0, "failed": 3} # type: ignore[arg-type] + assert tmdb.asked == [1, 2, 3] + + +def test_the_batch_stores_the_hungarian_biography(db): + db.add(PlexPerson(name="A", tmdb_person_id=1)) + db.commit() + + class _WithHu(_Tmdb): + def fetch_person(self, pid): + return { + "biography": "English.", + "translations": { + "translations": [{"iso_639_1": "hu", "data": {"biography": "Magyar."}}] + }, + } + + assert plex_tmdb.enrich_people_batch(db, _WithHu(), 10) == {"enriched": 1} # type: ignore[arg-type] + db.expire_all() + person = db.query(PlexPerson).one() + assert (person.biography, person.biography_hu) == ("English.", "Magyar.") class _Plex: - def __init__(self, fail=False): + """A PlexClient stand-in. `down`: Plex does not answer; `dead`: urls Plex answers with an error.""" + + def __init__(self, down=False, dead=()): self.asked: list[tuple[str, int | None]] = [] - self.fail = fail + self.down = down + self.dead = set(dead) def __enter__(self): return self @@ -92,8 +123,14 @@ class _Plex: def image_bytes(self, url, width=None, height=None): self.asked.append((url, width)) - if self.fail: - raise plex_people.PlexError("down") + if self.down: + raise plex_people.PlexError("down") from httpx.ConnectError("refused") + if url in self.dead: + req = httpx.Request("GET", url) + status = httpx.HTTPStatusError( + "404", request=req, response=httpx.Response(404, request=req) + ) + raise plex_people.PlexError("404") from status return b"small", "image/jpeg" @@ -103,7 +140,26 @@ def cache_dir(tmp_path, monkeypatch): return tmp_path -def test_portraits_are_cached_small_and_replace_the_full_size_original(db, cache_dir, monkeypatch): +@pytest.fixture +def plex_on(monkeypatch): + """The Plex module switched on (the job's gate), with `plex` as its client.""" + state = {"enabled": True, "plex": _Plex()} + monkeypatch.setattr(plex_people.sysconfig, "get_bool", lambda d, k: state["enabled"]) + monkeypatch.setattr(plex_people, "PlexClient", lambda d: state["plex"]) + return state + + +def _faces(db, n): + db.add_all( + [ + PlexPerson(name=f"P{i}", photo_thumb=plex_people.person_thumb_url(CDN + f"{i}.jpg")) + for i in range(n) + ] + ) + db.commit() + + +def test_portraits_are_cached_small_and_replace_the_full_size_original(db, cache_dir, plex_on): lib = _library(db) _movie(db, lib, "a", ["Often"]) _movie(db, lib, "b", ["Often", "Cached"]) @@ -115,23 +171,21 @@ def test_portraits_are_cached_small_and_replace_the_full_size_original(db, cache ] ) db.commit() - small, legacy = plex_people._portrait_keys(CDN + "often.jpg") - imgcache.write(legacy, b"a big original", "image/jpeg") # from before E30 S6 + small, original = plex_people._portrait_keys(CDN + "often.jpg") + imgcache.write(original, b"a big original", "image/jpeg") # cached without Plex imgcache.write(plex_people._portrait_keys(CDN + "cached.jpg")[0], b"x", "image/jpeg") - plex = _Plex() - monkeypatch.setattr(plex_people, "PlexClient", lambda d: plex) + plex = plex_on["plex"] assert plex_people.warm_portraits(db, 10) == {"cached": 1} assert plex.asked == [ (CDN + "often.jpg", plex_people.PORTRAIT_BOX) ] # resized, the cached one skipped assert imgcache.read(small) == (b"small", "image/jpeg") - assert not imgcache.has(legacy) + assert not imgcache.has(original) -def test_portraits_wait_for_plex(db, cache_dir, monkeypatch): - db.add(PlexPerson(name="P", photo_thumb=plex_people.person_thumb_url(CDN + "p.jpg"))) - db.commit() +def test_portraits_wait_for_plex(db, cache_dir, plex_on, monkeypatch): + _faces(db, 1) def not_configured(d): raise plex_people.PlexNotConfigured() @@ -141,18 +195,99 @@ def test_portraits_wait_for_plex(db, cache_dir, monkeypatch): assert list(cache_dir.iterdir()) == [] # no full-size originals behind Plex's back -def test_a_failing_plex_ends_the_portrait_run(db, cache_dir, monkeypatch): - db.add_all( - [ - PlexPerson(name=f"P{i}", photo_thumb=plex_people.person_thumb_url(CDN + f"{i}.jpg")) - for i in range(8) - ] - ) - db.commit() - plex = _Plex(fail=True) - monkeypatch.setattr(plex_people, "PlexClient", lambda d: plex) - assert plex_people.warm_portraits(db, 100) == {"cached": 0, "failed": 5} - assert len(plex.asked) == 5 +def test_portraits_wait_for_the_plex_module_to_be_on(db, cache_dir, plex_on): + _faces(db, 1) + plex_on["enabled"] = False # switched off, its server still set up + assert plex_people.warm_portraits(db, 10) == {"cached": 0, "skipped": "disabled"} + assert plex_on["plex"].asked == [] + + +def test_plex_not_answering_ends_the_portrait_run(db, cache_dir, plex_on): + _faces(db, 8) + plex_on["plex"] = _Plex(down=True) + assert plex_people.warm_portraits(db, 100) == {"cached": 0, "failed": 3} + assert len(plex_on["plex"].asked) == 3 + assert not list(cache_dir.glob("*.miss")) # Plex was down, the files are not to blame + + +def test_dead_headshots_are_left_alone_and_the_rest_still_land(db, cache_dir, plex_on): + _faces(db, 8) + dead = {CDN + f"{i}.jpg" for i in range(6)} # more dead files than any failure limit + plex_on["plex"] = _Plex(dead=dead) + assert plex_people.warm_portraits(db, 100) == {"cached": 2, "failed": 6} + + plex_on["plex"] = again = _Plex(dead=dead) + assert plex_people.warm_portraits(db, 100) == {"cached": 0} + assert again.asked == [] # dead ones marked, live ones cached: nothing to ask for + + +def test_the_person_image_route(db, cache_dir, monkeypatch): + from fastapi.testclient import TestClient + + import app.state as state + from app.main import app + from app.routes.plex import playback + + monkeypatch.setattr(state, "_configured_cache", True) + monkeypatch.setattr(playback, "_require_session", lambda request: 1) + client = TestClient(app) + url = CDN + "face.jpg" + small, original = plex_people._portrait_keys(url) + + def get(u): + return client.get("/api/plex/person-image", params={"u": u}) + + # Only a headshot-shaped url on the CDN host: no query variants of one file, no other host. + for bad in (url + "?n=1", url + "#x", "https://example.com/people/a.jpg"): + assert get(bad).status_code == 400, bad + + # No Plex: the original from the CDN, kept under its own key (the job resizes it later). + def not_configured(d): + raise plex_people.PlexNotConfigured() + + class _Cdn: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get(self, u): + return httpx.Response( + 200, + content=b"original", + headers={"content-type": "image/jpeg"}, + request=httpx.Request("GET", u), + ) + + monkeypatch.setattr(playback, "PlexClient", not_configured) + monkeypatch.setattr(plex_people.httpx, "Client", _Cdn) + resp = get(url) + assert (resp.status_code, resp.content) == (200, b"original") + assert imgcache.has(original) and not imgcache.has(small) + + # With Plex: resized, and it replaces the original. + monkeypatch.setattr(playback, "PlexClient", lambda d: _Plex()) + imgcache.drop(original) + resp = get(url) + assert (resp.status_code, resp.content) == (200, b"small") + assert imgcache.has(small) + assert get(url).content == b"small" # the cache answers now + + monkeypatch.setattr(playback, "PlexClient", lambda d: _Plex(down=True)) + assert get(CDN + "other.jpg").status_code == 502 + + +def test_the_people_job_runs_its_halves_without_a_tmdb_key(db, cache_dir, plex_on, monkeypatch): + _faces(db, 1) + monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "") + assert plex_tmdb.run_people_enrich(db) == { + "bios": {"skipped": "tmdb_api_key not set"}, + "portraits": {"cached": 1}, + } def test_a_phase_count_is_a_change_worth_an_audit_row(): diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index ab77e44d..f77cd6c8 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -67,3 +67,22 @@ def test_a_failed_run_drops_the_previous_result(db): rss = _snapshot_job("rss_poll") assert rss["last_error"] == "boom" assert rss["result"] is None + + +def test_a_job_runs_audit_row_carries_its_result_as_data(db): + """E30 review: the audit log names a run's result in the reader's language, as the inbox does — + from data, not from the English one-liner (a nested phase there was a Python dict repr).""" + from app.models import AuditLog, User + + admin = User(email="auditrun@example.com") + db.add(admin) + db.commit() + scheduler._audit_run(db, None, "people_enrich", "ok", "x", {"bios": {"enriched": 2}}) + scheduler._audit_run(db, admin.id, "rss_poll", "skipped", "sync paused", scheduler._PAUSED) + scheduler._audit_run(db, admin.id, "rss_poll", "error", "boom", None) + rows = db.execute(select(AuditLog).order_by(AuditLog.id)).scalars().all() + assert [r.after for r in rows] == [ + {"status": "ok", "summary": "x", "result": {"bios": {"enriched": 2}}}, + {"status": "skipped", "summary": "sync paused", "result": {"skipped": "sync paused"}}, + {"status": "error", "summary": "boom"}, + ] From fa55e01ef0be96db8f8960b5f67dde8acd4b035d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 30 Sep 2026 03:26:58 +0200 Subject: [PATCH 18/23] =?UTF-8?q?fix(ui,i18n):=20E30=20review=20round=201?= =?UTF-8?q?=20=E2=80=94=20audit=20rows=20in=20the=20reader's=20language,?= =?UTF-8?q?=20one=20HU=20name=20per=20thing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audit log: a job run named from its result data (nested phases too), a skipped run says so, a secret's reset reads as a reset, choices named as Configuration names them, interval / batch / counts localized; the inbox shares the same jobRunText - HU: remux is "Közvetlen közvetítés" (it shared "Közvetlen lejátszás" with direct play); backfill is behúzás, enrich is dúsítás, collection is kollekció, the allow-list is fehérlista; the last "jobnak" / "transzkódolás"; a test holds the options of one choice to distinct names - contract: every skipped / reason word a job returns is named in both languages - person page: the bio wait is timed from the open and a failing ask stops the polling; ClampedText (ui) shared with the collection page, whose short summary lost its dead More - a playlist names season 0 the Specials; the channel re-slot compares in the reader's alphabet; an invite's date is in the admin's language; the language-ordered lists are asked again once a language switch is saved (saveLanguage) - poster grids' column minimum in rem; lint refuses minmax(Npx,…) --- frontend/eslint.config.js | 8 +++ frontend/src/App.tsx | 7 +- frontend/src/components/AdminUsers.tsx | 7 +- frontend/src/components/Channels.tsx | 5 +- .../src/components/NotificationsPanel.tsx | 14 ++-- frontend/src/components/PlexBrowse.tsx | 4 +- .../PlexCollectionView.dom.test.tsx | 24 +++++++ .../src/components/PlexCollectionView.tsx | 24 +++---- .../components/PlexPersonView.dom.test.tsx | 41 ++++++++++- .../PlexPlaylistView.menu.dom.test.tsx | 11 ++- frontend/src/components/PlexPlaylistView.tsx | 9 ++- .../src/components/adminInvites.dom.test.tsx | 17 ++++- .../src/components/auditColumns.dom.test.tsx | 62 ++++++++++++++++ frontend/src/components/auditColumns.tsx | 66 ++++++++++++----- frontend/src/components/plexViews.tsx | 72 ++++++------------- .../components/ui/ClampedText.dom.test.tsx | 47 ++++++++++++ frontend/src/components/ui/ClampedText.tsx | 55 ++++++++++++++ frontend/src/components/ui/README.md | 2 +- frontend/src/i18n/backendIds.contract.test.ts | 35 +++++++++ frontend/src/i18n/copyRule.test.ts | 29 ++++++++ frontend/src/i18n/locales/en/audit.json | 1 + frontend/src/i18n/locales/en/inbox.json | 1 + frontend/src/i18n/locales/en/plex.json | 1 - frontend/src/i18n/locales/en/scheduler.json | 5 +- frontend/src/i18n/locales/hu/adminLogs.json | 2 +- frontend/src/i18n/locales/hu/audit.json | 1 + .../src/i18n/locales/hu/auditActions.json | 2 +- frontend/src/i18n/locales/hu/channel.json | 10 +-- frontend/src/i18n/locales/hu/channels.json | 24 +++---- frontend/src/i18n/locales/hu/config.json | 12 ++-- frontend/src/i18n/locales/hu/errors.json | 8 +-- frontend/src/i18n/locales/hu/header.json | 2 +- frontend/src/i18n/locales/hu/inbox.json | 1 + frontend/src/i18n/locales/hu/plex.json | 11 ++- frontend/src/i18n/locales/hu/scheduler.json | 11 +-- frontend/src/i18n/locales/hu/settings.json | 4 +- frontend/src/i18n/locales/hu/stats.json | 12 ++-- frontend/src/i18n/locales/hu/users.json | 2 +- frontend/src/lib/channelOrder.test.ts | 41 ++++++----- frontend/src/lib/channelOrder.ts | 18 +++-- frontend/src/lib/jobResult.dom.test.ts | 2 +- frontend/src/lib/jobResult.ts | 31 ++++++++ frontend/src/lib/pxBox.contract.test.ts | 6 ++ frontend/src/lib/queryKeys.ts | 14 ++++ frontend/src/lib/saveLanguage.dom.test.ts | 39 ++++++++++ frontend/src/lib/saveLanguage.ts | 11 +++ 46 files changed, 624 insertions(+), 187 deletions(-) create mode 100644 frontend/src/components/ui/ClampedText.dom.test.tsx create mode 100644 frontend/src/components/ui/ClampedText.tsx create mode 100644 frontend/src/lib/saveLanguage.dom.test.ts create mode 100644 frontend/src/lib/saveLanguage.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 414099d7..4500b12e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -428,6 +428,12 @@ const BOX_PX = "/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/"; const BOX_PX_MESSAGE = "px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx]."; +// …and a grid's auto-fill column MINIMUM in px (`minmax(150px,1fr)`): the cards' text grows with the +// slider while the column floor stays, so the cards get cramped at a large text size (E30 review). A +// fixed track (`grid-cols-[1fr_240px]`) is left alone, as is VirtualGrid's measured `minCol` (a number). +const GRID_MIN_PX = "/minmax\\([\\d.]+px,/"; +const GRID_MIN_PX_MESSAGE = + "px-frozen box size doesn't scale with --font-scale: give a grid column's minimum in rem (minmax(9.375rem,1fr)), not minmax(Npx,…)."; // A stacking order outside the layer scale (DESIGN.md Tokens: `base < menu < chrome < … < tooltip` in // index.css). A numeric `z-10` equals a level today but doesn't follow it when the scale moves; name it. const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/"; @@ -587,6 +593,8 @@ export default tseslint.config( // 150px at the default size) — 32 of these were swept in the 2026-09 debt burn. { selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE }, { selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE }, + { selector: `Literal[value=${GRID_MIN_PX}]`, message: GRID_MIN_PX_MESSAGE }, + { selector: `TemplateElement[value.raw=${GRID_MIN_PX}]`, message: GRID_MIN_PX_MESSAGE }, { selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE }, { selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE }, { selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 71ff6d69..deac0a4c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { qk } from "./lib/queryKeys"; +import { saveLanguage } from "./lib/saveLanguage"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, setUnauthorizedHandler, type Me } from "./lib/api"; @@ -333,9 +334,9 @@ export default function App() { setLanguage(code); // Also from the signed-out login page, where the save 401s — `req` marks that as handled, so // the reporter stays quiet there. - api - .savePrefs({ language: code }) - .catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs"))); + saveLanguage(queryClient, code).catch((e: unknown) => + notifyActionError(e, t("errors.writeFailed.prefs")), + ); } // First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard. diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index dc72735f..13d9e541 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -629,7 +629,8 @@ function AdminInvites() { // is also where the "you already wrote to them" warning belongs, next to the button that would // do it a second time, rather than only in a tooltip the admin may never hover. const onInvite = async (inv: Invite) => { - let lang: LangCode = LANGUAGES.some((l) => l.code === inv.lang) + // The language the INVITE is written in — not the admin's `lang`, which dates the sentence below. + let mailLang: LangCode = LANGUAGES.some((l) => l.code === inv.lang) ? (inv.lang as LangCode) : ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode); const ok = await confirm({ @@ -647,12 +648,12 @@ function AdminInvites() { }, )}

- (lang = v)} /> + (mailLang = v)} /> ), confirmLabel: t("settings.invites.inviteSend"), }); - if (ok) invite.mutate({ inv, lang }); + if (ok) invite.mutate({ inv, lang: mailLang }); }; const add = useMutation({ diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 356af484..f632172e 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -171,7 +171,10 @@ export default function Channels() { const listRef = useRef(null); const pickPriority = (c: ManagedChannel, next: number) => { setPriority(c.id, next); - qc.setQueryData(qk.channels(), (old) => old && placeByPriority(old, c.id)); + qc.setQueryData( + qk.channels(), + (old) => old && placeByPriority(old, c.id, lang), + ); setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 })); }; diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 9249e979..99912cd7 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -18,8 +18,8 @@ import { UserPlus, X, } from "lucide-react"; -import { api, type AppNotification, type JobResult } from "../lib/api"; -import { jobResultText, parseSummary } from "../lib/jobResult"; +import { api, type AppNotification } from "../lib/api"; +import { jobRunText } from "../lib/jobResult"; import { notifyActionError } from "../lib/actionErrors"; import { useLiveQuery } from "../lib/useLiveQuery"; import { focusAccessRequestsTab } from "../lib/adminUsersTab"; @@ -530,13 +530,9 @@ function NotificationRow({ } else if (isScheduler) { // " finished/failed", with the result as the body — named in the reader's language // when it rode along as data (E30), else the English summary of an older notice. - const d = n.data as { job_id: string; status?: string; summary?: string; result?: JobResult }; - const job = t(`scheduler.jobs.${d.job_id}`, d.job_id); - title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { - job, - }); - const result = d.result ?? parseSummary(d.summary); - body = result ? jobResultText(t, result, lang) : d.summary || n.body; + const run = jobRunText(t, n.data as { job_id: string }, lang); + title = run.title; + body = run.body || n.body; } else if (isEmailUndelivered) { // Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name // the message that was lost, since that is what tells the operator what their users didn't get. diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index d71ff5e7..4bd12039 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -699,7 +699,7 @@ export default function PlexBrowse() { {episodes.length > 0 && (

{t("plex.unified.titles")}

)} - + {items.map((c, i) => ( 0 && (

{t("plex.unified.episodes")}

- + {episodes.map((ep, i) => ( { } }); }); + +describe("PlexCollectionView — the summary (E30 review)", () => { + it("offers no More under a summary that fits", async () => { + vi.spyOn(api, "plexCollection").mockResolvedValue({ + ...detail([card("m1", "movie")]), + summary: "One short line.", + } as PlexCollectionDetail); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} + onPlay={vi.fn()} + onOpenCard={() => {}} + onRestartCard={() => {}} + onInfoCard={() => {}} + /> + , + ); + await screen.findByText("One short line."); + expect(screen.queryByText("plex.collection.more")).toBeNull(); // jsdom: nothing is cut + }); +}); diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index f17891e6..b903f883 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -24,6 +24,7 @@ import { toggleWatchState } from "../lib/plexWatch"; import { LoadingState, StateMessage } from "./ui/QueryState"; import NavGrid from "./ui/NavGrid"; import { Button } from "./ui/Button"; +import { ClampedText } from "./ui/ClampedText"; import { IconButton } from "./ui/IconButton"; import { Input } from "./ui/Input"; import { useReturnFocus } from "./ui/useReturnFocus"; @@ -98,7 +99,6 @@ export default function PlexCollectionView({ }); const d = q.data; - const [expanded, setExpanded] = useState(false); const [renaming, setRenaming] = useState(false); const renameFocus = useReturnFocus(renaming); const [name, setName] = useState(""); @@ -333,21 +333,13 @@ export default function PlexCollectionView({ )} {d.summary && ( -
-

- {d.summary} -

- -
+ )}
diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx index 11b5630e..483d589f 100644 --- a/frontend/src/components/PlexPersonView.dom.test.tsx +++ b/frontend/src/components/PlexPersonView.dom.test.tsx @@ -7,7 +7,7 @@ // and the api is mocked so the query resolves synchronously in the test. import { setLanguage } from "../i18n"; import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; beforeAll(() => { @@ -187,6 +187,8 @@ describe("PlexPersonView — the reader's language (E30)", () => { }); }); +const BIO_POLL = 1600; // just past plexViews' BIO_POLL_MS + describe("PlexPersonView — a bio on its way (E30 S6)", () => { const view = ( { expect(screen.queryByRole("status")).toBeNull(); }); + it("stops asking once the wait is over, and takes the placeholder away", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + plexPerson.mockReset(); + plexPerson.mockResolvedValue(person({ biography: null, enriching: true })); // TMDB never answers + wrap(view); + await screen.findByText("Movie One"); + await act(() => vi.advanceTimersByTimeAsync(21_000)); + const asked = plexPerson.mock.calls.length; + expect(asked).toBeGreaterThan(2); + await act(() => vi.advanceTimersByTimeAsync(10_000)); + expect(plexPerson).toHaveBeenCalledTimes(asked); + expect(screen.queryByRole("status")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("stops asking when the page's own request fails", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + plexPerson.mockReset(); + plexPerson + .mockResolvedValueOnce(person({ biography: null, enriching: true })) + .mockRejectedValue(new Error("server down")); + wrap(view); + await screen.findByText("Movie One"); + await act(() => vi.advanceTimersByTimeAsync(BIO_POLL)); + expect(plexPerson).toHaveBeenCalledTimes(2); + await act(() => vi.advanceTimersByTimeAsync(10_000)); + expect(plexPerson).toHaveBeenCalledTimes(2); // not every 1.5 s for as long as it fails + expect(screen.queryByRole("status")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it("offers More only when the bio is cut, and Less once open", async () => { const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400); const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160); diff --git a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx index c6e22b46..5f94722f 100644 --- a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx +++ b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx @@ -206,7 +206,7 @@ describe("PlexPlaylistView · a row's menu", () => { it("removes one season from its label's menu", async () => { mount([ep("e1", 1, 1), ep("e2", 2, 1), ep("e3", 2, 2)]); fireEvent.click(await screen.findByText("The Show")); // open the group - const seasonLabel = await screen.findByText(/plex\.playlist\.season · 2/); + const seasonLabel = await screen.findByText(/plex\.seasonTitle · 2/); const more = seasonLabel.parentElement!.querySelector( 'button[aria-label="plex.playlist.rowMenu"]', )!; @@ -363,3 +363,12 @@ describe("PlexPlaylistView · the reader's language (E30)", () => { } }); }); + +describe("PlexPlaylistView · a season's name (E30 review)", () => { + it("names season 0 the Specials, as the show page does", async () => { + mount([ep("e0", 0, 1), ep("e1", 1, 1)]); + fireEvent.click(await screen.findByText("The Show")); // open the show's seasons + expect(await screen.findByText(/^plex\.specials · 1$/)).toBeTruthy(); + expect(screen.getByText(/^plex\.seasonTitle · 1$/)).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 13ec004e..0285992d 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -54,6 +54,7 @@ import { useReturnFocus } from "./ui/useReturnFocus"; import { SURFACE } from "./ui/surfaces"; import { PageHeading } from "./ui/Headings"; import { MEDIA_BADGE } from "./ui/mediaCardStyles"; +import { seasonTitle } from "../lib/plexDetail"; // A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the // same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat @@ -259,13 +260,11 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) { ); } -/** A season's name, as its label and in the toast that says it went. */ +/** A season's name, as its label and in the toast that says it went — named as the show page names it + * (season 0 is the Specials). */ function useSeasonLabel() { const { t } = useTranslation(); - return (s: Season) => - s.number != null - ? t("plex.playlist.season", { n: s.number }) - : t("plex.playlist.seasonUnknown"); + return (s: Season) => seasonTitle({ season_number: s.number }, t); } function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) { diff --git a/frontend/src/components/adminInvites.dom.test.tsx b/frontend/src/components/adminInvites.dom.test.tsx index 3cf6dc21..536b98cb 100644 --- a/frontend/src/components/adminInvites.dom.test.tsx +++ b/frontend/src/components/adminInvites.dom.test.tsx @@ -10,6 +10,7 @@ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { formatDateTime } from "../lib/format"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -18,7 +19,8 @@ vi.mock("react-i18next", async (importOriginal) => { useTranslation: () => ({ // Echo the interpolation too, so a message that forgets its {{email}} is visible in the test. t: (key: string, vars?: Record) => - vars?.email ? `${key}:${String(vars.email)}` : key, + (vars?.email ? `${key}:${String(vars.email)}` : key) + + (vars?.time ? `@${String(vars.time)}` : ""), i18n: { language: "en" }, }), }; @@ -315,6 +317,19 @@ describe("inviting someone the admin whitelisted", () => { expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain"); }); + it("dates the earlier invite in the admin's language, not the invitee's (E30 review)", async () => { + listMock.mockResolvedValue([ + invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z", lang: "hu" }), + ]); + await open(); + fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com")); + await waitFor(() => expect(confirmMock).toHaveBeenCalled()); + const dialog = render(<>{confirmMock.mock.calls[0]![0].message}); + const at = "2026-09-05T09:00:00Z"; + expect(dialog.container.textContent).toContain(`@${formatDateTime(at, "en")}`); + expect(formatDateTime(at, "en")).not.toBe(formatDateTime(at, "hu")); + }); + it("offers none on a denied row, which would contradict the decision", async () => { listMock.mockResolvedValue([invite({ status: "denied" })]); await open(); diff --git a/frontend/src/components/auditColumns.dom.test.tsx b/frontend/src/components/auditColumns.dom.test.tsx index e9d4bde6..cc299a5c 100644 --- a/frontend/src/components/auditColumns.dom.test.tsx +++ b/frontend/src/components/auditColumns.dom.test.tsx @@ -82,6 +82,68 @@ describe("an audit row's details", () => { expect(details(secret)).toMatch(/ \| titok frissítve/); }); + it("name a nested result and a skipped run from the row's data (E30 review)", () => { + const nested = row({ + target_type: "job", + target_id: "people_enrich", + after: { + status: "ok", + summary: "bios={'enriched': 12}, portraits={'cached': 40}", + result: { bios: { enriched: 12 }, portraits: { cached: 40 } }, + }, + }); + expect(details(nested)).not.toContain("{"); + expect(details(nested)).toMatch(/^Személyadatok és portrék \(Plex\) kész \| /); + const skipped = row({ + target_type: "job", + target_id: "rss_poll", + after: { status: "skipped", summary: "sync paused" }, + }); + expect(details(skipped)).toBe( + "RSS-lekérdezés (új feltöltések) kihagyva | kihagyva: a szinkron szünetel", + ); + }); + + it("tell a secret's reset from its update, and name a choice", () => { + const reset = row({ + action: "config_reset", + target_type: "config", + target_id: "tmdb_api_key", + summary: "tmdb_api_key → default", + }); + expect(details(reset)).toMatch(/ \| titok visszaállítva/); + const choice = row({ + action: "config_set", + target_type: "config", + target_id: "plex_transcode_hw", + before: { value: "auto" }, + after: { value: "on" }, + }); + expect(details(choice)).toMatch(/ \| Automatikus → GPU$/); + }); + + it("name an interval, a batch and a count in the reader's language", () => { + expect( + details( + row({ + action: "scheduler_interval", + target_type: "job", + target_id: "rss_poll", + summary: "rss_poll → 15 min/run", + after: { interval_minutes: 15 }, + }), + ), + ).toBe("RSS-lekérdezés (új feltöltések) | 15 percenként"); + expect( + details(row({ action: "maintenance_tune", after: { revalidate_batch: 50000 } })), + ).toMatch(/^Futásonként újraellenőrzött videók \| 50\s000$/); + expect( + details( + row({ action: "audit_clear", summary: "Cleared 12 audit entries", after: { cleared: 12 } }), + ), + ).not.toContain("Cleared"); + }); + it("add nothing to a pause, whose action already says it", () => { expect(details(row({ action: "sync_pause", summary: "Background sync paused" }))).toBe(""); }); diff --git a/frontend/src/components/auditColumns.tsx b/frontend/src/components/auditColumns.tsx index 8fc643e9..d9181cb6 100644 --- a/frontend/src/components/auditColumns.tsx +++ b/frontend/src/components/auditColumns.tsx @@ -1,7 +1,8 @@ import type { TFunction } from "i18next"; import type { AuditRow } from "../lib/api"; import { formatNumber } from "../lib/format"; -import { jobResultText, parseSummary } from "../lib/jobResult"; +import type { JobResult } from "../lib/api"; +import { jobResultText, jobRunText } from "../lib/jobResult"; import type { Column } from "./ui/DataTable"; import { RelativeTime } from "./ui/RelativeTime"; @@ -14,17 +15,27 @@ function fmt(v: unknown): string { return v == null || v === "" ? "—" : String(v); } -/** A logged config value in the reader's language: a switch reads on/off, a count is grouped. */ -function configValue(t: TFunction, v: unknown, lang: string): string { +/** A logged config value in the reader's language: a switch reads on/off, a count is grouped, a + * choice is named as the Configuration page names it. */ +function configValue(t: TFunction, key: string, v: unknown, lang: string): string { if (typeof v === "boolean") return t(v ? "audit.on" : "audit.off"); if (typeof v === "number") return formatNumber(v, lang); + if (typeof v === "string" && v) return t(`config.choices.${key}.${v}`, { defaultValue: v }); return fmt(v); } +/** A row's `after` as a job-style result (counts), or null when it holds none. */ +function counts(after: Record): JobResult | null { + const out: JobResult = {}; + for (const [k, v] of Object.entries(after)) if (typeof v === "number") out[k] = v; + return Object.keys(out).length ? out : null; +} + /** The details of the rows whose server `summary` is a fixed English line (E30), rebuilt from the row's * data in the reader's language: a job run names the job and its result (as the inbox does), a config - * change names the field and its values, a pause/resume says nothing the action has not. `undefined` = - * not one of these; the generic summary + change rendering applies. */ + * change names the field and its values, an interval or batch change its new value, a count-bearing + * action its counts; a pause/resume says nothing the action has not. `undefined` = not one of these (or + * an older row without the data); the generic summary + change rendering applies. */ function localDetails( t: TFunction, row: AuditRow, @@ -33,23 +44,40 @@ function localDetails( const after = row.after ?? {}; const before = row.before ?? {}; if (row.action === "job_run" && row.target_type === "job" && row.target_id) { - const job = t(`scheduler.jobs.${row.target_id}`, row.target_id); - const failed = after.status === "error"; - const summary = typeof after.summary === "string" ? after.summary : null; - // A nested phase (a Python dict repr) or an error text does not parse, and shows as it was. - const result = failed ? null : parseSummary(summary); - return { - head: t(failed ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { job }), - sub: result ? jobResultText(t, result, lang) : summary, - }; + const run = jobRunText(t, { job_id: row.target_id, ...after }, lang); + return { head: run.title, sub: run.body }; } if ((row.action === "config_set" || row.action === "config_reset") && row.target_id) { - const head = t(`config.fields.${row.target_id}.label`, row.target_id); + const key = row.target_id; + const head = t(`config.fields.${key}.label`, key); + const reset = row.action === "config_reset"; // A secret's value is never logged: there is no before/after to show. - if (!row.before && !row.after) return { head, sub: t("audit.secretUpdated") }; - const to = - row.action === "config_reset" ? t("audit.default") : configValue(t, after.value, lang); - return { head, sub: `${configValue(t, before.value, lang)} → ${to}` }; + if (!row.before && !row.after) + return { head, sub: t(reset ? "audit.secretReset" : "audit.secretUpdated") }; + const to = reset ? t("audit.default") : configValue(t, key, after.value, lang); + return { head, sub: `${configValue(t, key, before.value, lang)} → ${to}` }; + } + if ( + row.action === "scheduler_interval" && + row.target_id && + typeof after.interval_minutes === "number" + ) + return { + head: t(`scheduler.jobs.${row.target_id}`, row.target_id), + sub: t("scheduler.everyMin", { count: after.interval_minutes }), + }; + if (row.action === "maintenance_tune" && typeof after.revalidate_batch === "number") + return { + head: t("scheduler.maintenance.batchLabel"), + sub: formatNumber(after.revalidate_batch, lang), + }; + if ( + row.action === "audit_clear" || + row.action === "demo_reset" || + row.action === "discovery_purge" + ) { + const result = counts(after); + return result ? { head: null, sub: jobResultText(t, result, lang) } : undefined; } if (row.action === "sync_pause" || row.action === "sync_resume") return { head: null, sub: null }; return undefined; diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 47eb2755..e31979fd 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -48,6 +48,7 @@ import { useCopyItems } from "./copyItems"; import { ContextMenu } from "./ui/ContextMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { Button } from "./ui/Button"; +import { ClampedText } from "./ui/ClampedText"; import { Carousel } from "./ui/Carousel"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexCollectionEditor from "./PlexCollectionEditor"; @@ -128,45 +129,6 @@ const BIO_POLL_MS = 1500; const KNOWN_FOR_HALF = 4; const BIO_WAIT_MS = 20_000; -/** A person's biography, clamped to a few lines with a More / Less toggle — offered only when the - * text is actually cut (measured, so a short bio carries no dead button). */ -function PersonBio({ text }: { text: string }) { - const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); - const [clipped, setClipped] = useState(false); - const ref = useRef(null); - useLayoutEffect(() => { - const el = ref.current; - if (!el || expanded) return; - // Re-measured on a resize: the text-size slider and the window both change where the cut falls. - const measure = () => setClipped(el.scrollHeight > el.clientHeight + 1); - measure(); - const ro = new ResizeObserver(measure); - ro.observe(el); - return () => ro.disconnect(); - }, [text, expanded]); - return ( -
-

- {text} -

- {(clipped || expanded) && ( - - )} -
- ); -} - // --- Actor detail: a cast/crew member's filmography (movies + shows in one place) --------------- // The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it // shows the person's headshot + name, a "filter the library by them" action, and their titles as two @@ -196,16 +158,23 @@ export function PlexPersonView({ const personKey = qk.plexPerson(name, lang); // A person the people_enrich job hasn't reached yet gets their bio fetched when the page opens (E30 // S6); the answer says so (`enriching`) and the page asks again until it lands — for a while, not - // forever: past BIO_WAIT_MS a TMDB that is down stops being waited on (the next open tries again). - const [openedAt] = useState(() => Date.now()); + // forever: BIO_WAIT_MS after the page opened (by the clock, not by the last answer — a failing ask + // leaves that where it was) a TMDB that is down stops being waited on, and a failing ask ends it at + // once (the next open tries again). The page is keyed on the person, so the wait starts per person. + const [waitOver, setWaitOver] = useState(false); + useEffect(() => { + const timer = setTimeout(() => setWaitOver(true), BIO_WAIT_MS); + return () => clearTimeout(timer); + }, []); const q = useQuery({ queryKey: personKey, queryFn: () => api.plexPerson(name, lang), refetchInterval: (query) => - query.state.data?.enriching && query.state.dataUpdatedAt - openedAt < BIO_WAIT_MS + query.state.data?.enriching && !waitOver && query.state.status !== "error" ? BIO_POLL_MS : false, }); + const bioOnItsWay = !!q.data?.enriching && !waitOver && !q.isError; // E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls // out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control. const { artBg, crossfade } = useDetailPrefs(); @@ -256,7 +225,7 @@ export function PlexPersonView({ cards.length > 0 ? (

{t(titleKey, { count: cards.length })}

- + {cards.map((c, i) => (
{d.biography ? ( - + ) : ( - d.enriching && - q.dataUpdatedAt - openedAt < BIO_WAIT_MS && ( + bioOnItsWay && (

{t("plex.series.seasons")}

- + {d.seasons.map((se, i) => ( - + {se.episodes.map((ep, i) => ( { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); +afterEach(cleanup); + +const view = (text: string) => ( + +); + +describe("ClampedText", () => { + it("offers no toggle for a text that fits", () => { + render(view("Short.")); + expect(screen.queryByText("More")).toBeNull(); // jsdom: nothing is cut + }); + + it("offers More for a cut text, and Less once open", () => { + const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400); + const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60); + try { + render(view("A long text.\n\nOn and on.")); + const p = screen.getByText(/A long text/); + expect(p.className).toContain("line-clamp-3"); + fireEvent.click(screen.getByText("More")); + expect(screen.getByText("Less").getAttribute("aria-expanded")).toBe("true"); + expect(p.className).not.toContain("line-clamp-3"); + } finally { + tall.mockRestore(); + box.mockRestore(); + } + }); +}); diff --git a/frontend/src/components/ui/ClampedText.tsx b/frontend/src/components/ui/ClampedText.tsx new file mode 100644 index 00000000..0160295f --- /dev/null +++ b/frontend/src/components/ui/ClampedText.tsx @@ -0,0 +1,55 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { Button } from "./Button"; + +// Static class names, so Tailwind sees them. +const CLAMP = { 3: "line-clamp-3", 8: "line-clamp-8" } as const; + +/** A long text (a biography, a summary) clamped to a few lines, with a More / Less toggle offered only + * when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a + * resize: the window and the text-size slider both move where the cut falls. */ +export function ClampedText({ + text, + lines, + moreLabel, + lessLabel, + className, +}: { + text: string; + lines: keyof typeof CLAMP; + moreLabel: string; + lessLabel: string; + className?: string; +}) { + const [expanded, setExpanded] = useState(false); + const [clipped, setClipped] = useState(false); + const ref = useRef(null); + useLayoutEffect(() => { + const el = ref.current; + if (!el || expanded) return; + const measure = () => setClipped(el.scrollHeight > el.clientHeight + 1); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [text, expanded]); + return ( +
+

+ {text} +

+ {(clipped || expanded) && ( + + )} +
+ ); +} diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index c6cb00a7..7c85e7d9 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -40,7 +40,7 @@ domain-free. | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` | | actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `
@@ -614,9 +617,13 @@ export function PlexShowView({
)} {show.summary && ( -

- {show.summary} -

+ )}
{show.resume && ( diff --git a/frontend/src/components/ui/ClampedText.dom.test.tsx b/frontend/src/components/ui/ClampedText.dom.test.tsx index 16a21b11..711fc1c2 100644 --- a/frontend/src/components/ui/ClampedText.dom.test.tsx +++ b/frontend/src/components/ui/ClampedText.dom.test.tsx @@ -3,7 +3,7 @@ // A clamped text offers More only when it is actually cut (E30 S6 on the person page, shared with the // collection page in the E30 review, where a one-line summary carried a More that did nothing). import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { ClampedText } from "./ClampedText"; beforeAll(() => { @@ -44,4 +44,33 @@ describe("ClampedText", () => { box.mockRestore(); } }); + + it("measures again when its box changes: the window or the text size moved the cut", () => { + const seen: ResizeObserverCallback[] = []; // the callbacks of observers watching something + const real = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(private cb: ResizeObserverCallback) {} + observe() { + seen.push(this.cb); + } + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + let height = 60; + const tall = vi + .spyOn(HTMLElement.prototype, "scrollHeight", "get") + .mockImplementation(() => height); + const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60); + try { + render(view("A text that fits, until the reader raises the text size.")); + expect(screen.queryByText("More")).toBeNull(); + height = 400; // the text size went up: the same text now runs past three lines + act(() => seen[seen.length - 1]!([], {} as ResizeObserver)); + expect(screen.getByText("More")).toBeTruthy(); + } finally { + tall.mockRestore(); + box.mockRestore(); + globalThis.ResizeObserver = real; + } + }); }); diff --git a/frontend/src/components/ui/ClampedText.tsx b/frontend/src/components/ui/ClampedText.tsx index 0160295f..9422a0e8 100644 --- a/frontend/src/components/ui/ClampedText.tsx +++ b/frontend/src/components/ui/ClampedText.tsx @@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState } from "react"; import { Button } from "./Button"; // Static class names, so Tailwind sees them. -const CLAMP = { 3: "line-clamp-3", 8: "line-clamp-8" } as const; +const CLAMP = { 3: "line-clamp-3", 5: "line-clamp-5", 8: "line-clamp-8" } as const; /** A long text (a biography, a summary) clamped to a few lines, with a More / Less toggle offered only * when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 594c0578..7b0781a8 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -1,4 +1,6 @@ { + "textMore": "More", + "textLess": "Less", "navLabel": "Plex", "backToLibrary": "Back to library", "collection": { @@ -13,8 +15,6 @@ "nameMaybeTaken": "A collection with this name may already exist in another library — you can still create it here.", "readOnly": "Read-only", "empty": "This collection has no items.", - "more": "More", - "less": "Less", "addSearch": "Search titles to add…", "addHint": "Type to search for a title to add.", "addNoResults": "No matches.", @@ -353,8 +353,7 @@ "aka": "Also known as", "knownFor": "Known for", "bioLoading": "Loading the biography", - "more": "More", - "less": "Less" + "bioFailed": "Couldn't load the biography right now" }, "view": { "label": "View", diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index ad682198..0478b75b 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -4,10 +4,10 @@ "empty": "Még nincs naplóbejegyzés.", "system": "Rendszer", "clear": "Napló ürítése", - "clearTitle": "Üríted a naplót?", + "clearTitle": "Üríted az eseménynaplót?", "clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)", "cleared": "{{count}} naplóbejegyzés törölve", - "clearFailed": "Nem sikerült üríteni a naplót", + "clearFailed": "Nem sikerült üríteni az eseménynaplót", "truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.", "on": "Be", "off": "Ki", diff --git a/frontend/src/i18n/locales/hu/auditActions.json b/frontend/src/i18n/locales/hu/auditActions.json index 735272aa..3f27b63d 100644 --- a/frontend/src/i18n/locales/hu/auditActions.json +++ b/frontend/src/i18n/locales/hu/auditActions.json @@ -20,7 +20,7 @@ "sync_pause": "Szinkron szüneteltetve", "sync_resume": "Szinkron folytatva", "job_run": "Ütemezett feladat lefutott", - "audit_clear": "Napló ürítve", + "audit_clear": "Eseménynapló ürítve", "download_credential_set": "Letöltési cookie-k beállítva", "download_credential_delete": "Letöltési cookie-k törölve", "invite_delete": "Fehérlista-bejegyzés törölve", diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index f76ee0b0..75cff01b 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -45,7 +45,7 @@ "hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy." }, "backfill_quota_reserve": { - "label": "Letöltési kvótatartalék", + "label": "Behúzási kvótatartalék", "hint": "Tartalékban tartott egységek, hogy az ütemezett behúzás ne éheztesse ki az interaktív szinkront / dúsítást." }, "search_daily_limit_per_user": { @@ -194,11 +194,11 @@ }, "plex_max_transcodes": { "label": "Max. párhuzamos átkódolás", - "hint": "A nem-H.264 fájlok egyidejű átkódolási munkameneteinek felső korlátja. A remux (stream-copy) munkamenetek csak az összesített munkamenet-korlátba számítanak bele; a direktben játszható fájlokra nincs korlát." + "hint": "A nem-H.264 fájlok egyidejű átkódolási munkameneteinek felső korlátja. A közvetlen közvetítés munkamenetei csak az összesített munkamenet-korlátba számítanak bele; a közvetlenül játszható fájlokra nincs korlát." }, "plex_max_sessions": { "label": "Max párhuzamos lejátszási munkamenet", - "hint": "Az összes egyidejű lejátszási munkamenet felső korlátja, a remuxot (stream-copy) is beleértve — mindegyik a film teljes, szegmentált másolatát írja a munkakötetre. Egy fiók legfeljebb kettőt tarthat. A direktben játszható fájlok nem munkamenetek, rájuk nincs korlát." + "hint": "Az összes egyidejű lejátszási munkamenet felső korlátja, a közvetlen közvetítést is beleértve — mindegyik a film teljes, szegmentált másolatát írja a munkakötetre. Egy fiók legfeljebb kettőt tarthat. A közvetlenül játszható fájlok nem munkamenetek, rájuk nincs korlát." }, "plex_transcode_hw": { "label": "Hardveres átkódolás", @@ -218,7 +218,7 @@ }, "plex_audio_loudnorm": { "label": "Hangerő normalizálása", - "hint": "A sztereó lekeverés EBU R128 szerinti hangerő-normalizálása — azokhoz a filmekhez, ahol a párbeszéd suttogás, az akció meg fülsiketítő. Csak remuxos vagy átkódolt lejátszásnál él (a direktben játszott fájl érintetlen marad). Egy 5.1-es filmen mérve: −27,6 → −16,6 LUFS, szűkebb halk/hangos különbséggel — minden kb. 11 dB-lel hangosabb lesz, szóval bekapcsolás után vedd lejjebb a hangerőt. A következő lejátszástól érvényes." + "hint": "A sztereó lekeverés EBU R128 szerinti hangerő-normalizálása — azokhoz a filmekhez, ahol a párbeszéd suttogás, az akció meg fülsiketítő. Csak közvetlen közvetítésnél vagy átkódolásnál él (a közvetlenül játszott fájl érintetlen marad). Egy 5.1-es filmen mérve: −27,6 → −16,6 LUFS, szűkebb halk/hangos különbséggel — minden kb. 11 dB-lel hangosabb lesz, szóval bekapcsolás után vedd lejjebb a hangerőt. A következő lejátszástól érvényes." }, "tmdb_api_key": { "label": "TMDB API-kulcs", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 625f6cef..844c82d3 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -1,4 +1,6 @@ { + "textMore": "Több", + "textLess": "Kevesebb", "navLabel": "Plex", "backToLibrary": "Vissza a könyvtárhoz", "collection": { @@ -13,8 +15,6 @@ "nameMaybeTaken": "Lehet, hogy már létezik ilyen nevű kollekció egy másik könyvtárban — itt akkor is létrehozhatod.", "readOnly": "Csak olvasható", "empty": "Ennek a kollekciónak nincsenek elemei.", - "more": "Több", - "less": "Kevesebb", "addSearch": "Keress címet a hozzáadáshoz…", "addHint": "Gépelj a hozzáadni kívánt cím kereséséhez.", "addNoResults": "Nincs találat.", @@ -353,8 +353,7 @@ "aka": "Más néven", "knownFor": "Ismert szerepei", "bioLoading": "Az életrajz betöltése", - "more": "Több", - "less": "Kevesebb" + "bioFailed": "Az életrajzot most nem sikerült betölteni" }, "view": { "label": "Nézet", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index ae127832..af07a43c 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -123,7 +123,7 @@ "channels_processed": "feldolgozott csatorna", "videos_added": "hozzáadott videó", "reconciled_full": "teljesen egyeztetve", - "reason": "ok", + "reason": "indok", "channels_tagged": "címkézett csatorna", "orphan_tags_removed": "törölt árva címke", "probed": "ellenőrizve", diff --git a/frontend/src/lib/jobResult.dom.test.ts b/frontend/src/lib/jobResult.dom.test.ts index e3badee7..4a328d4c 100644 --- a/frontend/src/lib/jobResult.dom.test.ts +++ b/frontend/src/lib/jobResult.dom.test.ts @@ -23,7 +23,7 @@ describe("a job's result", () => { "hu", ), ).toBe( - "friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · ok: nincs szolgáltatásfiók) · " + + "friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · indok: nincs szolgáltatásfiók) · " + "mély (feldolgozott csatorna: 2 · hozzáadott videó: 5 · teljesen egyeztetve: 1)", ); }); diff --git a/frontend/src/lib/jobResult.ts b/frontend/src/lib/jobResult.ts index bf215157..53365236 100644 --- a/frontend/src/lib/jobResult.ts +++ b/frontend/src/lib/jobResult.ts @@ -40,7 +40,7 @@ export function jobResultText(t: TFunction, result: JobResult, lang: string): st /** What a job run said, as the inbox notice and the audit row both show it (E30): a title naming the job * and how the run ended, and its result in the reader's language — from the data when it rode along, - * else read back from the English one-liner. A run skipped for a pause names why; a failed run keeps the + * else read back from the English one-liner. A skipped run names only why; a failed run keeps the * upstream's own error text (it is diagnostic). */ export function jobRunText( t: TFunction, @@ -63,8 +63,21 @@ export function jobRunText( : (parseSummary(summary) ?? (run.status === "skipped" && summary ? { skipped: summary } : null)); } + // A skipped run's title already says "skipped": its body is only the reason, not "skipped: ". + const reason = run.status === "skipped" ? onlySkipReason(result) : null; return { title: t(titleKey, { job }), - body: result ? jobResultText(t, result, lang) : summary, + body: + reason !== null + ? t(`scheduler.skip.${vocabKey(reason)}`, { defaultValue: reason }) + : result + ? jobResultText(t, result, lang) + : summary, }; } + +function onlySkipReason(result: JobResult | null): string | null { + const keys = result ? Object.keys(result) : []; + const word = result?.skipped; + return keys.length === 1 && typeof word === "string" ? word : null; +} diff --git a/frontend/src/lib/saveLanguage.dom.test.ts b/frontend/src/lib/saveLanguage.dom.test.ts index c4790cda..3acbd8ab 100644 --- a/frontend/src/lib/saveLanguage.dom.test.ts +++ b/frontend/src/lib/saveLanguage.dom.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { QueryClient } from "@tanstack/react-query"; import { api } from "./api"; import { LANGUAGE_ORDERED, qk } from "./queryKeys"; -import { saveLanguage } from "./saveLanguage"; +import { saveLanguage, serverOrderLang } from "./saveLanguage"; afterEach(() => vi.restoreAllMocks()); @@ -37,3 +37,12 @@ describe("saveLanguage", () => { expect(asked).not.toHaveBeenCalled(); }); }); + +describe("serverOrderLang", () => { + it("is the stored language, else English — never the UI's guess", () => { + expect(serverOrderLang({ language: "hu" })).toBe("hu"); + expect(serverOrderLang({})).toBe("en"); // nothing stored: the server sorts in English + expect(serverOrderLang({ language: "de" })).toBe("en"); + expect(serverOrderLang(undefined)).toBe("en"); + }); +}); diff --git a/frontend/src/lib/saveLanguage.ts b/frontend/src/lib/saveLanguage.ts index f96d9ef1..bd414407 100644 --- a/frontend/src/lib/saveLanguage.ts +++ b/frontend/src/lib/saveLanguage.ts @@ -1,4 +1,5 @@ import type { QueryClient } from "@tanstack/react-query"; +import { isSupported } from "../i18n"; import { api } from "./api"; import { LANGUAGE_ORDERED } from "./queryKeys"; @@ -9,3 +10,11 @@ export async function saveLanguage(qc: QueryClient, code: string): Promise await api.savePrefs({ language: code }); for (const queryKey of LANGUAGE_ORDERED) void qc.invalidateQueries({ queryKey }); } + +/** The language the server orders this account's lists in (backend `mailtext.lang_for_user`): the + * stored preference, else English. Not always the UI's — a browser-detected language, or one picked + * while signed out, is never stored — so a list re-slotted here to match the server's order uses it. */ +export function serverOrderLang(preferences: Record | null | undefined): string { + const lang = preferences?.language; + return typeof lang === "string" && isSupported(lang) ? lang : "en"; +} From 08e62bbb168181ff9b67cb7337e3c3ded891b72b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 1 Oct 2026 10:43:59 +0200 Subject: [PATCH 21/23] =?UTF-8?q?fix(plex):=20E30=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20a=20dead=20headshot=20breaks=20the=20Plex-failing?= =?UTF-8?q?=20streak,=20410=20held=20by=20a=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/plex/people.py | 5 ++-- backend/tests/test_plex_people_enrich.py | 24 ++++++++++++++----- ...Order.test.ts => channelOrder.dom.test.ts} | 0 3 files changed, 21 insertions(+), 8 deletions(-) rename frontend/src/lib/{channelOrder.test.ts => channelOrder.dom.test.ts} (100%) diff --git a/backend/app/plex/people.py b/backend/app/plex/people.py index afac6525..84c65eb2 100644 --- a/backend/app/plex/people.py +++ b/backend/app/plex/people.py @@ -244,7 +244,8 @@ def warm_portraits(db: Session, limit: int) -> dict: ).tuples() if thumb } - done = dead = plex_failed = down = 0 # `down` counts in a row, `plex_failed` in all + # `down` counts Plex failing in a row (any answer from Plex breaks it), `plex_failed` in all + done = dead = plex_failed = down = 0 with plex: for pid in people_by_credits(db, "portrait", None): if done + dead >= limit or down >= _PLEX_DOWN_AFTER: @@ -264,7 +265,7 @@ def warm_portraits(db: Session, limit: int) -> dict: # Plex failing, which must neither mark the file nor keep the run going. cause = exc.__cause__ if isinstance(cause, httpx.HTTPStatusError) and cause.response.status_code in _DEAD: - dead += 1 + dead, down = dead + 1, 0 # Plex answered: it is up, the streak is broken imgcache.mark_miss(key) else: plex_failed, down = plex_failed + 1, down + 1 diff --git a/backend/tests/test_plex_people_enrich.py b/backend/tests/test_plex_people_enrich.py index 4666d6b8..33ff2fb7 100644 --- a/backend/tests/test_plex_people_enrich.py +++ b/backend/tests/test_plex_people_enrich.py @@ -117,13 +117,15 @@ def test_the_batch_stores_the_hungarian_biography(db): class _Plex: """A PlexClient stand-in. `down`: urls Plex does not answer for (True: none); `dead`: urls Plex - answers "not found" for (a dead CDN file); `status`: an error status Plex answers every url with.""" + answers `gone` for (a dead CDN file: "not found"); `status`: an error status Plex answers every + other url with.""" - def __init__(self, down=False, dead=(), status=None): + def __init__(self, down=False, dead=(), status=None, gone=404): self.asked: list[tuple[str, int | None]] = [] self.down = down self.dead = set(dead) self.status = status + self.gone = gone def __enter__(self): return self @@ -135,7 +137,7 @@ class _Plex: self.asked.append((url, width)) if self.down is True or (self.down and url in self.down): raise plex_people.PlexError("down") from httpx.ConnectError("refused") - code = 404 if url in self.dead else self.status + code = self.gone if url in self.dead else self.status if code: req = httpx.Request("GET", url) status = httpx.HTTPStatusError( @@ -236,13 +238,23 @@ def test_plex_failing_between_successes_does_not_end_the_run(db, cache_dir, plex assert plex_people.warm_portraits(db, 100) == {"cached": 4, "failed": 4} -def test_dead_headshots_are_left_alone_and_the_rest_still_land(db, cache_dir, plex_on): +def test_plex_failing_between_dead_files_does_not_end_the_run(db, cache_dir, plex_on): + _faces(db, 8) + plex_on["plex"] = _Plex( # down, dead, down, dead, …: Plex answers in between + down={CDN + f"{i}.jpg" for i in (0, 2, 4, 6)}, dead={CDN + f"{i}.jpg" for i in (1, 3, 5, 7)} + ) + assert plex_people.warm_portraits(db, 100) == {"cached": 0, "failed": 8} + assert len(plex_on["plex"].asked) == 8 + + +@pytest.mark.parametrize("gone", [404, 410]) +def test_dead_headshots_are_left_alone_and_the_rest_still_land(db, cache_dir, plex_on, gone): _faces(db, 8) dead = {CDN + f"{i}.jpg" for i in range(6)} # more dead files than any failure limit - plex_on["plex"] = _Plex(dead=dead) + plex_on["plex"] = _Plex(dead=dead, gone=gone) assert plex_people.warm_portraits(db, 100) == {"cached": 2, "failed": 6} - plex_on["plex"] = again = _Plex(dead=dead) + plex_on["plex"] = again = _Plex(dead=dead, gone=gone) assert plex_people.warm_portraits(db, 100) == {"cached": 0} assert again.asked == [] # dead ones marked, live ones cached: nothing to ask for diff --git a/frontend/src/lib/channelOrder.test.ts b/frontend/src/lib/channelOrder.dom.test.ts similarity index 100% rename from frontend/src/lib/channelOrder.test.ts rename to frontend/src/lib/channelOrder.dom.test.ts From 3563858fd19f038d5ccacef415284158f05cc1d2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 1 Oct 2026 10:43:59 +0200 Subject: [PATCH 22/23] =?UTF-8?q?fix(ui,i18n):=20E30=20review=20round=203?= =?UTF-8?q?=20=E2=80=94=20the=20stored=20language=20follows=20the=20screen?= =?UTF-8?q?,=20a=20fresh=20text=20starts=20collapsed,=20Retry=20waits=20ag?= =?UTF-8?q?ain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/BootGate.dom.test.tsx | 24 +++++++++++- frontend/src/components/BootGate.tsx | 26 ++++++++++--- frontend/src/components/Channels.tsx | 3 +- frontend/src/components/PlexBrowse.tsx | 2 + .../components/PlexPersonView.dom.test.tsx | 23 +++++++++++ frontend/src/components/plexViews.tsx | 16 ++++++-- .../components/ui/ClampedText.dom.test.tsx | 15 ++++++++ frontend/src/components/ui/ClampedText.tsx | 9 ++++- frontend/src/i18n/locales/en/audit.json | 2 +- frontend/src/i18n/locales/hu/audit.json | 2 +- frontend/src/i18n/locales/hu/scheduler.json | 2 +- frontend/src/lib/channelOrder.dom.test.ts | 38 +++++++++++++------ frontend/src/lib/channelOrder.ts | 7 +++- frontend/src/lib/saveLanguage.ts | 10 +++-- 14 files changed, 147 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/BootGate.dom.test.tsx b/frontend/src/components/BootGate.dom.test.tsx index 55c18d51..a81f4c74 100644 --- a/frontend/src/components/BootGate.dom.test.tsx +++ b/frontend/src/components/BootGate.dom.test.tsx @@ -8,7 +8,7 @@ // // Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe // records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted. -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest"; import { render, screen, cleanup, act, fireEvent, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect } from "react"; @@ -47,6 +47,7 @@ function mount() { return qc; } const account = (over: Partial = {}) => ({ id: 5, preferences: {}, ...over }) as Me; +let savePrefs: MockInstance; const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") }); beforeEach(async () => { @@ -57,6 +58,7 @@ beforeEach(async () => { mounts = 0; await i18n.changeLanguage("en"); vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true }); + savePrefs = vi.spyOn(api, "savePrefs").mockResolvedValue({ ok: true } as never); }); afterEach(() => { cleanup(); @@ -85,6 +87,26 @@ describe("BootGate", () => { expect(renders.map((r) => r.lang)).toEqual(renders.map(() => "hu")); }); + it("an account with no language stored stores the one it is shown in, once", async () => { + await i18n.changeLanguage("hu"); // the browser's language, never saved + vi.spyOn(api, "me").mockResolvedValue(account()); + const qc = mount(); + + await screen.findByTestId("app"); + await waitFor(() => expect(savePrefs).toHaveBeenCalledWith({ language: "hu" })); + expect(savePrefs).toHaveBeenCalledTimes(1); + // The server now sorts in Hungarian, and so does what reads the stored language. + await waitFor(() => expect(qc.getQueryData(qk.me())?.preferences.language).toBe("hu")); + }); + + it("an account with its language stored stores nothing", async () => { + vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } })); + mount(); + + await screen.findByTestId("app"); + expect(savePrefs).not.toHaveBeenCalled(); + }); + it("mounts nothing of the app while the identity is still unknown", async () => { vi.spyOn(api, "me").mockReturnValue(new Promise(() => {})); mount(); diff --git a/frontend/src/components/BootGate.tsx b/frontend/src/components/BootGate.tsx index 9e7bc8f0..5091975e 100644 --- a/frontend/src/components/BootGate.tsx +++ b/frontend/src/components/BootGate.tsx @@ -1,7 +1,9 @@ import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { api, getActiveAccount, setActiveAccount } from "../lib/api"; +import { log } from "../lib/log"; +import { saveLanguage } from "../lib/saveLanguage"; import { qk } from "../lib/queryKeys"; import i18n, { isSupported, setLanguage } from "../i18n"; import { LoadingState, StateMessage } from "./ui/QueryState"; @@ -38,6 +40,7 @@ export function BootScreen() { export default function BootGate({ children }: { children: ReactNode }) { const { t } = useTranslation(); + const qc = useQueryClient(); // First-run install gate: a fresh instance reports configured=false and runs in setup mode, where // every other route is locked — so `me` is held back until the instance is known to be set up // (otherwise it would 503 against the setup-mode lock). @@ -81,7 +84,10 @@ export default function BootGate({ children }: { children: ReactNode }) { // not is corrected here — otherwise the tab would run as the default account on the server while // reading and writing the gone account's local state. // • Adopt the account's language, which follows the account across devices — the local copy only - // knows what this browser last showed. + // knows what this browser last showed. An account with none stored yet (the setup wizard's admin, + // an account older than the stamp sign-up gives) stores the language it is shown in: the server + // orders its lists and writes its mail in the stored one, else English, and a Hungarian screen + // over English-ordered lists breaks "A→Z in the UI language's alphabet". const [readyFor, setReadyFor] = useState(undefined); // `me` is read for the identity it produced, so it rides a ref: a background refetch of the same // account must not re-run the adoption over a language the user has just changed. That rule used @@ -104,11 +110,21 @@ export default function BootGate({ children }: { children: ReactNode }) { const account = meRef.current; if (account && getActiveAccount() !== account.id) setActiveAccount(account.id); const lang = account?.preferences?.language; - // eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale. - if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang); + if (typeof lang === "string" && isSupported(lang)) { + // eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale. + if (lang !== i18n.language) setLanguage(lang); + } else if (account) { + // eslint-disable-next-line local/locale-format -- a one-shot read of the language the app is about to be shown in, stored once; nothing renders it. + const shown = i18n.language; + saveLanguage(qc, shown).catch((e: unknown) => + log.warn("boot.language_store_failed", "the shown language could not be stored", { + error: String(e), + }), + ); + } // eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them. setReadyFor(identity); - }, [identity, readyFor]); + }, [identity, readyFor, qc]); // No answer at all (a network or 5xx failure on the very first setup or account probe — past the // one retry `req` already makes): say so, with a retry. An unanswered setup probe is NOT "signed diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index a6dc845a..58cd49e9 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -27,7 +27,6 @@ import { useChannelTagActions } from "../lib/useChannelTagActions"; import { useTagSuggestions } from "./useTagSuggestions"; import { useChannelPriority } from "../lib/useChannelPriority"; import { placeByPriority } from "../lib/channelOrder"; -import { serverOrderLang } from "../lib/saveLanguage"; import { focusNavItem } from "../lib/listNav"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ColumnSortControl from "./ui/ColumnSortControl"; @@ -174,7 +173,7 @@ export default function Channels() { setPriority(c.id, next); qc.setQueryData( qk.channels(), - (old) => old && placeByPriority(old, c.id, serverOrderLang(me.preferences)), + (old) => old && placeByPriority(old, c.id, me.preferences), ); setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 })); }; diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 4bd12039..b147c691 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -483,7 +483,9 @@ export default function PlexBrowse() { if (sub.view.kind === "show") { const showId = sub.view.id; return ( + // Keyed by the show: another show is a fresh page (its own summary collapsed, no open dialog). diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx index 66be5c9c..4adbaef2 100644 --- a/frontend/src/components/PlexPersonView.dom.test.tsx +++ b/frontend/src/components/PlexPersonView.dom.test.tsx @@ -276,6 +276,29 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => { } }); + it("a retry after the wait is over waits again: a bio still on its way still lands", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + plexPerson.mockReset(); + plexPerson + .mockResolvedValueOnce(person({ biography: null, enriching: true })) + .mockRejectedValueOnce(new Error("server restarting")) + .mockResolvedValueOnce(person({ biography: null, enriching: true })) // the retry + .mockResolvedValue(person({ biography: "Late bio." })); + wrap(view); + await screen.findByText("Movie One"); + await act(() => vi.advanceTimersByTimeAsync(BIO_POLL)); + await act(() => vi.advanceTimersByTimeAsync(25_000)); // the first wait runs out + fireEvent.click(within(screen.getByRole("alert")).getByRole("button")); + await act(() => vi.advanceTimersByTimeAsync(10)); + expect(screen.getByRole("status")).toBeTruthy(); // on its way again, not a blank + await act(() => vi.advanceTimersByTimeAsync(BIO_POLL)); + await screen.findByText("Late bio."); + } finally { + vi.useRealTimers(); + } + }); + it("offers More only when the bio is cut, and Less once open", async () => { const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400); const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index aff680e3..0481054f 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -160,12 +160,14 @@ export function PlexPersonView({ // S6); the answer says so (`enriching`) and the page asks again until it lands — for a while, not // forever: BIO_WAIT_MS after the page opened (by the clock, not by the last answer — a failing ask // leaves that where it was) a TMDB that is down stops being waited on, and a failing ask ends it at - // once (the next open tries again). The page is keyed on the person, so the wait starts per person. + // once (the next open tries again). The page is keyed on the person, so the wait starts per person; + // a Retry starts it over (`waitRound`), or a retry answered "still on its way" would show nothing. const [waitOver, setWaitOver] = useState(false); + const [waitRound, setWaitRound] = useState(0); useEffect(() => { const timer = setTimeout(() => setWaitOver(true), BIO_WAIT_MS); return () => clearTimeout(timer); - }, []); + }, [waitRound]); const q = useQuery({ queryKey: personKey, queryFn: () => api.plexPerson(name, lang), @@ -336,7 +338,15 @@ export function PlexPersonView({ // person with no bio; the retry asks (and waits) again. d.enriching && q.isError && ( - q.refetch()}> + { + setWaitOver(false); + setWaitRound((n) => n + 1); + void q.refetch(); + }} + > {t("plex.person.bioFailed")} ) diff --git a/frontend/src/components/ui/ClampedText.dom.test.tsx b/frontend/src/components/ui/ClampedText.dom.test.tsx index 711fc1c2..6dbcad68 100644 --- a/frontend/src/components/ui/ClampedText.dom.test.tsx +++ b/frontend/src/components/ui/ClampedText.dom.test.tsx @@ -45,6 +45,21 @@ describe("ClampedText", () => { } }); + it("starts another text collapsed: a reused view opening the next item", () => { + const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400); + const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60); + try { + const { rerender } = render(view("The first show's long summary.")); + fireEvent.click(screen.getByText("More")); + rerender(view("The next show's long summary.")); + expect(screen.getByText(/next show/).className).toContain("line-clamp-3"); + expect(screen.getByText("More").getAttribute("aria-expanded")).toBe("false"); + } finally { + tall.mockRestore(); + box.mockRestore(); + } + }); + it("measures again when its box changes: the window or the text size moved the cut", () => { const seen: ResizeObserverCallback[] = []; // the callbacks of observers watching something const real = globalThis.ResizeObserver; diff --git a/frontend/src/components/ui/ClampedText.tsx b/frontend/src/components/ui/ClampedText.tsx index 9422a0e8..41ddf634 100644 --- a/frontend/src/components/ui/ClampedText.tsx +++ b/frontend/src/components/ui/ClampedText.tsx @@ -6,7 +6,9 @@ const CLAMP = { 3: "line-clamp-3", 5: "line-clamp-5", 8: "line-clamp-8" } as con /** A long text (a biography, a summary) clamped to a few lines, with a More / Less toggle offered only * when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a - * resize: the window and the text-size slider both move where the cut falls. */ + * resize: the window and the text-size slider both move where the cut falls. Another text starts + * collapsed: a view reused for the next item (a show page opening another show) must not open its + * text already expanded. */ export function ClampedText({ text, lines, @@ -22,6 +24,11 @@ export function ClampedText({ }) { const [expanded, setExpanded] = useState(false); const [clipped, setClipped] = useState(false); + const [shown, setShown] = useState(text); + if (shown !== text) { + setShown(text); + setExpanded(false); + } const ref = useRef(null); useLayoutEffect(() => { const el = ref.current; diff --git a/frontend/src/i18n/locales/en/audit.json b/frontend/src/i18n/locales/en/audit.json index de5f2d0d..82df2382 100644 --- a/frontend/src/i18n/locales/en/audit.json +++ b/frontend/src/i18n/locales/en/audit.json @@ -3,7 +3,7 @@ "intro": "An append-only record of admin actions and scheduler run summaries — who did what, and when. Logged at action granularity, never per item.", "empty": "No audit entries yet.", "system": "System", - "clear": "Clear log", + "clear": "Clear audit log", "clearTitle": "Clear the audit log?", "clearConfirm": "Permanently delete all audit entries? This can't be undone. (One entry recording that you cleared it is kept.)", "cleared": "Cleared {{count}} audit entries", diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index 0478b75b..907042c2 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -3,7 +3,7 @@ "intro": "Az admin-műveletek és az ütemező futás-összegzéseinek csak-hozzáfűző naplója — ki, mit, mikor. Művelet-szinten naplózva, soha nem elemenként.", "empty": "Még nincs naplóbejegyzés.", "system": "Rendszer", - "clear": "Napló ürítése", + "clear": "Eseménynapló ürítése", "clearTitle": "Üríted az eseménynaplót?", "clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)", "cleared": "{{count}} naplóbejegyzés törölve", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index af07a43c..06755b52 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -75,7 +75,7 @@ "plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infóoldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő szinkron után jelennek meg.", "plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes egyeztetések között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.", "plex_watch_reconcile": "A megtekintési állapot teljes egyeztetése, amely minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben levett megtekintve jel nem tükröződik vissza.", - "audit_gc": "Törli a beállított megőrzési időnél (Konfiguráció → Eseménynapló) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul.", + "audit_gc": "Törli a beállított megőrzési időnél (Konfiguráció → Eseménynapló) régebbi admin-naplóbejegyzéseket. Ha leáll, az eseménynapló csak nő — ártalmatlan, de korlátlanul.", "log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.", "tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.", "people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később." diff --git a/frontend/src/lib/channelOrder.dom.test.ts b/frontend/src/lib/channelOrder.dom.test.ts index 583b22aa..479b8751 100644 --- a/frontend/src/lib/channelOrder.dom.test.ts +++ b/frontend/src/lib/channelOrder.dom.test.ts @@ -1,28 +1,32 @@ +// @vitest-environment jsdom +// jsdom: the order language comes from the account's stored one, checked against i18n's languages. import { describe, expect, it } from "vitest"; import { placeByPriority, priorityChoices } from "./channelOrder"; const row = (id: string, priority: number, title: string | null = id) => ({ id, priority, title }); const ids = (rows: { id: string }[]) => rows.map((r) => r.id); +const EN = { language: "en" }; +const HU = { language: "hu" }; describe("placeByPriority", () => { const list = [row("a", 10), row("b", 5), row("c", 5), row("d", 0), row("e", 0)]; it("bubbles a raised channel up to the first row it now outranks", () => { expect( - ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e", "en")), + ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e", EN)), ).toEqual(["a", "b", "c", "e", "d"]); }); it("sinks a lowered channel below every row it no longer outranks", () => { - expect( - ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a", "en")), - ).toEqual(["b", "c", "a", "d", "e"]); + expect(ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a", EN))).toEqual( + ["b", "c", "a", "d", "e"], + ); }); it("orders equal priorities by title, case-folded, a missing title last", () => { const rows = [row("x", 3, "beta"), row("y", 3, null), row("z", 3, "Alpha")]; - expect(ids(placeByPriority(rows, "z", "en"))).toEqual(["z", "x", "y"]); - expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y", "en"))).toEqual([ + expect(ids(placeByPriority(rows, "z", EN))).toEqual(["z", "x", "y"]); + expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y", EN))).toEqual([ "x", "y", ]); @@ -31,21 +35,31 @@ describe("placeByPriority", () => { it("leaves every other row where it was, even ones out of the server's order", () => { const rows = [row("p", 0), row("q", 9), row("r", 2)]; // r lands before the first row it outranks; p and q keep their (unsorted) order. - expect(ids(placeByPriority(rows, "r", "en"))).toEqual(["r", "p", "q"]); + expect(ids(placeByPriority(rows, "r", EN))).toEqual(["r", "p", "q"]); }); it("returns an unknown id's list unchanged", () => { - expect(ids(placeByPriority(list, "nope", "en"))).toEqual(ids(list)); + expect(ids(placeByPriority(list, "nope", EN))).toEqual(ids(list)); }); it("places a title in the reader's alphabet, as the server's collation does (E30 review)", () => { const rows = [row("e", 0, "Ecset"), row("f", 0, "Fa"), row("m", 5, "Életrajz")]; const moved = [...rows.slice(0, 2), { ...rows[2]!, priority: 0 }]; - expect(ids(placeByPriority(moved, "m", "hu"))).toEqual(["e", "m", "f"]); + expect(ids(placeByPriority(moved, "m", HU))).toEqual(["e", "m", "f"]); const cs = [row("u", 0, "Cukor"), row("x", 0, "Dob"), row("c", 5, "Csiga")]; - expect( - ids(placeByPriority([...cs.slice(0, 2), { ...cs[2]!, priority: 0 }], "c", "hu")), - ).toEqual(["u", "c", "x"]); + expect(ids(placeByPriority([...cs.slice(0, 2), { ...cs[2]!, priority: 0 }], "c", HU))).toEqual([ + "u", + "c", + "x", + ]); + }); + + it("places by the STORED language, whatever the screen shows: the server sorts by that", () => { + const cs = [row("u", 0, "Cukor"), row("x", 0, "Dob"), row("c", 5, "Csiga")]; + const moved = [...cs.slice(0, 2), { ...cs[2]!, priority: 0 }]; + // None stored (a Hungarian screen picked while signed out): the server's English order. + expect(ids(placeByPriority(moved, "c", {}))).toEqual(["c", "u", "x"]); + expect(ids(placeByPriority(moved, "c", { language: "de" }))).toEqual(["c", "u", "x"]); }); }); diff --git a/frontend/src/lib/channelOrder.ts b/frontend/src/lib/channelOrder.ts index 75fcd41b..5eb877f0 100644 --- a/frontend/src/lib/channelOrder.ts +++ b/frontend/src/lib/channelOrder.ts @@ -1,4 +1,5 @@ import { compareText } from "./collate"; +import { serverOrderLang } from "./saveLanguage"; // The channel manager's list order, and the priority steps its menu offers (E28 S3e3b). @@ -32,14 +33,16 @@ function before(a: Ordered, b: Ordered, lang: string): boolean { /** `rows` with the channel `id` taken out and put back where its priority now places it, every * other row left exactly where it was — the server's order, without a refetch that would reorder - * anything the reader did not touch. */ + * anything the reader did not touch. It takes the account's `preferences`, not a language: the + * server sorts in the STORED language (`serverOrderLang`), which a UI language is not always. */ export function placeByPriority( rows: readonly T[], id: string, - lang: string, + preferences: Record | null | undefined, ): T[] { const moved = rows.find((r) => r.id === id); if (!moved) return [...rows]; + const lang = serverOrderLang(preferences); const rest = rows.filter((r) => r !== moved); const at = rest.findIndex((r) => before(moved, r, lang)); rest.splice(at < 0 ? rest.length : at, 0, moved); diff --git a/frontend/src/lib/saveLanguage.ts b/frontend/src/lib/saveLanguage.ts index bd414407..a06c9660 100644 --- a/frontend/src/lib/saveLanguage.ts +++ b/frontend/src/lib/saveLanguage.ts @@ -2,18 +2,22 @@ import type { QueryClient } from "@tanstack/react-query"; import { isSupported } from "../i18n"; import { api } from "./api"; import { LANGUAGE_ORDERED } from "./queryKeys"; +import { updateMeCache } from "./useMe"; /** Store the reader's language choice on the server, then ask again for the lists the server orders * by it (`LANGUAGE_ORDERED`): only once the save has landed does the server sort in the new alphabet. - * Rejects with the save's error; the lists are left alone then. */ + * The cached `me` takes it too, so what reads the stored language (`serverOrderLang`) agrees with the + * server at once, not at the next `me` poll. Rejects with the save's error; nothing is touched then. */ export async function saveLanguage(qc: QueryClient, code: string): Promise { await api.savePrefs({ language: code }); + updateMeCache(qc, (me) => ({ ...me, preferences: { ...me.preferences, language: code } })); for (const queryKey of LANGUAGE_ORDERED) void qc.invalidateQueries({ queryKey }); } /** The language the server orders this account's lists in (backend `mailtext.lang_for_user`): the - * stored preference, else English. Not always the UI's — a browser-detected language, or one picked - * while signed out, is never stored — so a list re-slotted here to match the server's order uses it. */ + * stored preference, else English. Not always the UI's: an account with none stored stores the one + * it is shown in at boot (BootGate), but until that save lands, or if it fails, the two differ — so a + * list re-slotted here to match the server's order uses this, not the UI's. */ export function serverOrderLang(preferences: Record | null | undefined): string { const lang = preferences?.language; return typeof lang === "string" && isSupported(lang) ? lang : "en"; From 6b86f99249318e82d4a3f925b976796256e18989 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 1 Oct 2026 10:44:39 +0200 Subject: [PATCH 23/23] =?UTF-8?q?release:=200.97.0=20notes=20=E2=80=94=20t?= =?UTF-8?q?he=20app=20in=20Hungarian?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ VERSION | 2 +- frontend/src/content/release-notes/en.json | 9 +++++++++ frontend/src/content/release-notes/hu.json | 9 +++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 105daf8e..e11557f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.97.0 — 2026-10-01 + +### New + +- The app speaks Hungarian throughout: Plex genres and statuses, numbers, units and A→Z order follow your language. +- Person pages show a Hungarian biography where TMDB has one, and open faster, with headshots kept on the server. + +### Fixed + +- Smaller fixes to wording and the Plex pages. + ## 0.96.0 — 2026-09-27 ### New diff --git a/VERSION b/VERSION index 13bb6285..05e39cbb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.96.0 +0.97.0 diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index 84981b73..213ac478 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,13 @@ [ + { + "version": "0.97.0", + "date": "2026-10-01", + "new": [ + "The app speaks Hungarian throughout: Plex genres and statuses, numbers, units and A→Z order follow your language.", + "Person pages show a Hungarian biography where TMDB has one, and open faster, with headshots kept on the server." + ], + "fixed": ["Smaller fixes to wording and the Plex pages."] + }, { "version": "0.96.0", "date": "2026-09-27", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index bbac1cb6..db97e619 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,13 @@ [ + { + "version": "0.97.0", + "date": "2026-10-01", + "new": [ + "Az alkalmazás végig magyarul szól: a Plex műfajai és állapotai, a számok, a mértékegységek és az ábécérend is a választott nyelvet követi.", + "A személyoldalak magyar életrajzot mutatnak, ahol a TMDB-ben van, és gyorsabban nyílnak, a fejképeket a szerver tárolja." + ], + "fixed": ["Kisebb javítások a szövegekben és a Plex-oldalakon."] + }, { "version": "0.96.0", "date": "2026-09-27",