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/CLAUDE.md b/CLAUDE.md index 9f1f4240..5a0f01ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ how it gets built. Concretely: `routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`, `downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`, -`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `bodylimit`, `progress`, +`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `bodylimit`, `progress`, `collation`, `security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.** Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and 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/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/alembic/versions/0092_plex_person_biography_hu.py b/backend/alembic/versions/0092_plex_person_biography_hu.py new file mode 100644 index 00000000..8f710bb2 --- /dev/null +++ b/backend/alembic/versions/0092_plex_person_biography_hu.py @@ -0,0 +1,31 @@ +"""add biography_hu to plex_people — TMDB's Hungarian biography (E30 S4) + +Revision ID: 0092_plex_person_biography_hu +Revises: 0091_plex_tagline_hu +Create Date: 2026-09-29 + +The person page showed a Hungarian reader the English biography although TMDB has a Hungarian one for +most people. The person fetch now appends `translations` and keeps the Hungarian biography beside the +English one. Already-enriched people are un-stamped, so their next page open refetches them once (the +same lazy path as a first open) and fills it; until then the English biography stands in. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0092_plex_person_biography_hu" +down_revision: str | None = "0091_plex_tagline_hu" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.add_column("plex_people", sa.Column("biography_hu", sa.Text(), nullable=True)) + 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: + op.drop_column("plex_people", "biography_hu") 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/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/README.md b/backend/app/README.md index 9fb2cac1..ec7ba9ef 100644 --- a/backend/app/README.md +++ b/backend/app/README.md @@ -23,7 +23,8 @@ Three layers, by *who* runs the code — not by feature: 3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log - level) and `debugsession` (a recording that tags its user's lines, E16), … are single-file primitives many layers import. They depend on nothing above them (a core + level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the + reader's alphabet, E30), … are single-file primitives many layers import. They depend on nothing above them (a core module must never import a `routes/…` module). **Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a diff --git a/backend/app/collation.py b/backend/app/collation.py new file mode 100644 index 00000000..a69c6730 --- /dev/null +++ b/backend/app/collation.py @@ -0,0 +1,39 @@ +"""Alphabetical order in the reader's language (E30 S3). + +Why not the database default: the Postgres image is Alpine, and musl's `en_US.utf8` collation is BYTE +order — every accented initial sorted after "Z" ("Zorró < Életrajzi < Ötödik"), in every A→Z list the +server orders. Postgres ships the ICU collations on the same image (dev, test, prod alike), so a text +sort names one: Hungarian puts "Ö" after "O" and "Cs" after "Cu", English folds the accents in. + +`collated` wraps an ORDER BY expression — and because a keyset cursor compares with the SAME expression +(`feedquery.sort_keys`), the cursor pages in the same order it sorted in. `sorted_words` orders a Python +list the same way through the database, so a list built in Python matches one built in SQL exactly.""" + +from typing import Any + +from sqlalchemy import SQLColumnExpression, column, func, select, values +from sqlalchemy.orm import Session +from sqlalchemy.sql.elements import ColumnElement +from sqlalchemy.types import String + +from app.mailtext import normalize + +ICU: dict[str, str] = {"en": "en-x-icu", "hu": "hu-x-icu"} + + +def collation(lang: str | None) -> str: + """The ICU collation for a UI language; anything unknown sorts as English.""" + return ICU[normalize(lang)] + + +def collated(expr: SQLColumnExpression[Any], lang: str | None) -> ColumnElement[Any]: + """`expr` compared in the reader's alphabet. Case-folded, so "apple" and "Apple" sit together.""" + return func.lower(expr).collate(collation(lang)) + + +def sorted_words(db: Session, words: list[str], lang: str | None) -> list[str]: + """`words` in the reader's alphabetical order, by the same ICU collation as `collated`.""" + if not words: + return [] + w = values(column("w", String), name="words").data([(x,) for x in words]) + return list(db.scalars(select(w.c.w).order_by(collated(w.c.w, lang), w.c.w))) 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/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/feedquery.py b/backend/app/feedquery.py index b4e17c9d..1023ac2d 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -15,6 +15,7 @@ from datetime import date, datetime, timedelta, timezone from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select from sqlalchemy.orm import Session, aliased +from app.collation import collated from app.models import ( LIVE_OR_UPCOMING, BlockedChannel, @@ -478,8 +479,10 @@ def feed_years(db: Session, user: User, **params) -> list[dict]: # replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows, # and the page boundary is stable even as the scheduler ingests new videos mid-scroll # (OFFSET would shift and duplicate/skip rows). -def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]: +def sort_keys(sort: str, seed: int, rank_expr=None, lang: str | None = None) -> list[dict]: + """`lang` orders the title sorts in the reader's alphabet (`collation`); the other keys ignore it.""" pub = {"expr": Video.published_at, "asc": False, "nullable": False, "kind": "dt"} + title = collated(Video.title, lang) table: dict[str, list[dict]] = { "newest": [pub], "oldest": [{**pub, "asc": True}], @@ -491,10 +494,8 @@ def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]: "duration_asc": [ {"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"} ], - "title": [{"expr": func.lower(Video.title), "asc": True, "nullable": True, "kind": "str"}], - "title_desc": [ - {"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"} - ], + "title": [{"expr": title, "asc": True, "nullable": True, "kind": "str"}], + "title_desc": [{"expr": title, "asc": False, "nullable": True, "kind": "str"}], "subscribers": [ {"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"} ], diff --git a/backend/app/models.py b/backend/app/models.py index 5677c594..91b268c0 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( @@ -1415,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" @@ -1425,11 +1428,15 @@ 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) + # TMDB's Hungarian biography (E30 S4), from the same request's `translations`; a Hungarian reader is + # served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and + # keywords have no Hungarian on TMDB, so they stay in the instance language. + biography_hu: Mapped[str | None] = mapped_column(Text) birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is deathday: Mapped[str | None] = mapped_column(String(10)) place_of_birth: Mapped[str | None] = mapped_column(Text) 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/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/imgcache.py b/backend/app/plex/imgcache.py index 76f20b56..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 @@ -56,6 +59,49 @@ 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 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 9db11f90..84c65eb2 100644 --- a/backend/app/plex/people.py +++ b/backend/app/plex/people.py @@ -3,13 +3,23 @@ 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 +import re +from datetime import timedelta +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, sysconfig 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). @@ -19,9 +29,7 @@ PERSON_IMG_HOST = "https://metadata-static.plex.tv/" def person_thumb_url(thumb: str | None) -> str | None: """Proxied /person-image URL for a Plex CDN headshot, or None when it's missing / off-CDN.""" t = str(thumb or "") - return ( - f"/api/plex/person-image?u={quote(t, safe='')}" if t.startswith(PERSON_IMG_HOST) else None - ) + return f"/api/plex/person-image?u={quote(t, safe='')}" if is_portrait_url(t) else None def cast_from_meta(meta: dict, limit: int = 24) -> list[dict]: @@ -89,14 +97,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: @@ -113,3 +130,152 @@ 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 = ( + 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", + # 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. 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 "not found" 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): plain path segments, no query, no +# fragment, no dot segment or empty segment (an HTTP client folds `a/../b` and the CDN `a//b` onto one +# file). The person-image proxy takes nothing else, so it cannot be made to cache endless spellings of +# one file (the image cache has no eviction). Used where a stored headshot is made, served and warmed. +_SEGMENT = r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*" # a name, dots only between its parts +_PORTRAIT_URL = re.compile(re.escape(PERSON_IMG_HOST) + _SEGMENT + rf"(?:/{_SEGMENT})*") + + +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: + """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 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(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 + + +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 is_portrait_url(url) else None + + +# Plex failing this many headshots in a row ends a run: it is Plex that is failing, not the files. +_PLEX_DOWN_AFTER = 3 +# The statuses that mean the one file is gone. +_DEAD = frozenset({404, 410}) + + +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) and the Plex module on; without them + nothing is prefetched — full-size originals for every person would cost gigabytes. A headshot Plex + answers "not found" for is marked and left for PORTRAIT_RETRY; Plex failing in a row ends the run.""" + if not sysconfig.get_bool(db, "plex_enabled"): + return {"cached": 0, "skipped": "disabled"} + try: + plex = PlexClient(db) + # 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] = { + pid: thumb + for pid, thumb in db.execute( + select(PlexPerson.id, PlexPerson.photo_thumb).where(PlexPerson.photo_thumb.is_not(None)) + ).tuples() + if thumb + } + # `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: + break + url = cdn_url(photos.get(pid) or "") + 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, down = done + 1, 0 + except PlexError as exc: + # "Not found" is Plex's answer about this one file (a dead CDN headshot: measured 404, + # the CDN itself says 403). Anything else — no answer, a refused token, a 5xx — is + # 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, 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 + log.warning( + "a portrait could not be cached", + extra=logs.event( + "plex.people.portrait_failed", + person_id=pid, + reason=type(exc.__cause__).__name__, + ), + ) + failed = dead + plex_failed + return {"cached": done, **({"failed": failed} if failed else {})} diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 406ffe8c..636d34f1 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" @@ -183,7 +184,13 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list: gsel = split_csv(p.get("genres")) 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 271a2d9f..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 @@ -24,6 +25,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__) @@ -448,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 @@ -659,35 +665,72 @@ 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: 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 + # 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 +742,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/plex/tmdb.py b/backend/app/plex/tmdb.py index f373d072..0f0896c9 100644 --- a/backend/app/plex/tmdb.py +++ b/backend/app/plex/tmdb.py @@ -10,8 +10,10 @@ 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 import httpx from sqlalchemy import func, select @@ -21,6 +23,8 @@ 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 +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. @@ -124,9 +128,17 @@ 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"}) + """One person's bio + combined credits (E5 S6) + the bio's translations (E30 S4), in a single + append_to_response request.""" + return self._get( + f"/person/{person_id}", {"append_to_response": "combined_credits,translations"} + ) def guid_to_tmdb_id(guid: str | None, media_type: str, tmdb: TmdbClient) -> int | None: @@ -254,8 +266,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, @@ -301,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 = ( @@ -356,13 +372,62 @@ 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 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( @@ -406,10 +471,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 +483,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: @@ -437,30 +509,58 @@ def serve_block(db: Session, row) -> dict | None: "keywords": row.keywords or [], "crew": row.crew or [], "studios": row.studios or [], - "production_countries": row.production_countries or [], - "recommendations": resolve_recs(db, row.recommendations), + "production_countries": [_country(c) for c in row.production_countries or []], + "recommendations": resolve_recs(db, row.recommendations, lang=lang), "backdrops": row.backdrops or [], } # --- 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 @@ -470,6 +570,7 @@ def parse_person(payload: dict) -> dict: break return { "biography": (payload.get("biography") or "").strip() or None, + "biography_hu": _translated_bio(payload, "hu"), "birthday": payload.get("birthday") or None, "deathday": payload.get("deathday") or None, "place_of_birth": payload.get("place_of_birth") or None, @@ -479,8 +580,17 @@ def parse_person(payload: dict) -> dict: } +def _translated_bio(payload: dict, lang: str) -> str | None: + """The `lang` biography from the appended `translations` (TMDB leaves an untranslated one blank).""" + for tr in (payload.get("translations") or {}).get("translations") or []: + if tr.get("iso_639_1") == lang: + return ((tr.get("data") or {}).get("biography") or "").strip() or None + return None + + def apply_person(row: PlexPerson, parsed: dict) -> None: row.biography = parsed["biography"] + row.biography_hu = parsed["biography_hu"] row.birthday = parsed["birthday"] row.deathday = parsed["deathday"] row.place_of_birth = parsed["place_of_birth"] @@ -490,11 +600,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: @@ -502,7 +623,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. @@ -515,9 +636,41 @@ 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 + + +# 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 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 = 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: + continue + try: + _fetch_person_into(tmdb, person) + except TmdbError as exc: + db.rollback() + failed, in_a_row = failed + 1, in_a_row + 1 + log.warning( + "a TMDB person lookup in the batch failed", + extra=logs.event( + "plex.tmdb.people_batch_failed", person_id=pid, reason=type(exc).__name__ + ), + ) + continue + db.commit() + 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/channels.py b/backend/app/routes/channels.py index f28a9d20..9c9c3a52 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -9,6 +9,8 @@ from pydantic import BaseModel from sqlalchemy import func, select, update from sqlalchemy.orm import Session +from app.collation import collated, sorted_words +from app.mailtext import lang_for_user from app import logs, quota from app.auth import admin_user, current_user, require_human, require_write_scope from app.db import get_db @@ -44,7 +46,7 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_ select(Subscription, Channel) .join(Channel, Channel.id == Subscription.channel_id) .where(Subscription.user_id == user.id) - .order_by(Subscription.priority.desc(), func.lower(Channel.title)) + .order_by(Subscription.priority.desc(), collated(Channel.title, lang_for_user(user))) ).all() # Per-channel aggregates over the shared catalog in ONE grouped pass: stored count, last @@ -139,7 +141,7 @@ def _discovery_rows(db: Session, user: User) -> list: return db.execute( q.group_by(Channel.id).order_by( func.count(func.distinct(PlaylistItem.video_id)).desc(), - func.lower(Channel.title), + collated(Channel.title, lang_for_user(user)), ) ).all() @@ -192,10 +194,14 @@ def discover_channels( ): apply_channel_details(db, yt.get_channels(need), need) db.commit() - # Match _discovery_rows' ORDER BY exactly: video-count DESC, then title (NULLs LAST, - # as Postgres orders them — coercing NULL→"" would wrongly float untitled channels up). + # Match _discovery_rows' ORDER BY exactly: video-count DESC, then title in the reader's + # alphabet (NULLs LAST, as Postgres orders them — coercing NULL→"" would wrongly float + # untitled channels up). + titles = [r[0].title for r in rows if r[0].title is not None] + alpha = {w: i for i, w in enumerate(sorted_words(db, titles, lang_for_user(user)))} rows = sorted( - rows, key=lambda r: (-int(r[1]), r[0].title is None, (r[0].title or "").lower()) + rows, + key=lambda r: (-int(r[1]), r[0].title is None, alpha.get(r[0].title or "", 0)), ) except YouTubeError as exc: log.warning( @@ -659,7 +665,7 @@ def list_blocked(user: User = Depends(current_user), db: Session = Depends(get_d select(Channel) .join(BlockedChannel, BlockedChannel.channel_id == Channel.id) .where(BlockedChannel.user_id == user.id) - .order_by(func.lower(Channel.title)) + .order_by(collated(Channel.title, lang_for_user(user))) ) .scalars() .all() 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/downloads.py b/backend/app/routes/downloads.py index d96a95fd..af75086e 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -21,6 +21,8 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session +from app.mailtext import lang_for_user +from app.collation import collated from app import audit, sysconfig from app.auth import admin_user, require_human from app.config import settings @@ -917,7 +919,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() @@ -981,7 +990,7 @@ def share_recipients( db.execute( select(User) .where(User.id != user.id, *messageable_clauses()) - .order_by(func.lower(User.email)) + .order_by(collated(User.email, lang_for_user(user))) ) .scalars() .all() diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index fb5edcb3..4ae00d95 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -10,6 +10,7 @@ from sqlalchemy.orm.exc import StaleDataError from app import quota from app.auth import current_user from app.db import get_db +from app.mailtext import lang_for_user from app.feedquery import ( decode_cursor, encode_cursor, @@ -108,7 +109,7 @@ def get_feed( db: Session = Depends(get_db), ) -> dict: query, rank_expr = filtered_query(db, user, **params) - keys = sort_keys(sort, seed, rank_expr) + keys = sort_keys(sort, seed, rank_expr, lang_for_user(user)) # Expose each key column on the row so we can build the next cursor from the last item. query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)]) diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 1cbe96fd..342bda78 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -21,6 +21,8 @@ from pydantic import BaseModel from sqlalchemy import and_, case, delete, func, or_, select from sqlalchemy.orm import Session +from app.mailtext import lang_for_user +from app.collation import collated from app.auth import require_human, resolved_user_id from app.db import SessionLocal, get_db from app.models import Message, MessageKey, User @@ -246,7 +248,7 @@ def list_recipients( select(User) .join(MessageKey, MessageKey.user_id == User.id) .where(User.id != user.id, *messageable_clauses()) - .order_by(func.lower(func.coalesce(User.display_name, User.email))) + .order_by(collated(func.coalesce(User.display_name, User.email), lang_for_user(user))) ) .scalars() .all() 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..ab56d020 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -10,10 +10,13 @@ from sqlalchemy import ( func, literal, null, + or_, select, ) from sqlalchemy.orm import Session, aliased +from app.mailtext import lang_for_user +from app.collation import collated, sorted_words from app import sysconfig from app.auth import current_user from app.db import get_db @@ -25,7 +28,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, @@ -54,14 +57,15 @@ from app.utils import to_tsquery_str router = APIRouter(prefix="/api/plex", tags=["plex"]) -def sort_column(u, sort: str): +def sort_column(u, sort: str, lang: str = "en"): """The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control offers but this cannot order would quietly fall back to `added_at` and look like a broken control. - An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback.""" + An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback. The title sorts + in the reader's alphabet (`collation`).""" return { "added": u.c.added_at, - "title": func.lower(u.c.title), + "title": collated(u.c.title, lang), "year": u.c.year, "rating": u.c.rating, "duration": u.c.duration_s, @@ -78,7 +82,7 @@ def list_libraries(user: User = Depends(current_user), db: Session = Depends(get db.execute( select(PlexLibrary) .where(PlexLibrary.overlay_of.is_(None)) - .order_by(PlexLibrary.kind.desc(), PlexLibrary.title) + .order_by(PlexLibrary.kind.desc(), collated(PlexLibrary.title, lang_for_user(user))) ) .scalars() .all() @@ -237,7 +241,7 @@ def unified_library( if tsq is not None: order = [u.c.rank.desc()] else: - col = sort_column(u, sort) + col = sort_column(u, sort, lang) order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] order.append(u.c.rk.desc()) rows = db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all() @@ -281,26 +285,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 { @@ -523,16 +533,19 @@ def facets( for c in split_csv(f.playable): if c in PLAYBACK_CLASSES: playable_counts.setdefault(c, 0) + # Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader (E30 S1). + alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))} return { "genres": [ - {"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0]) + {"value": g, "count": c} + for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[kv[0]]) ], "content_ratings": [ {"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1]) ], "studios": [ {"value": s, "count": c} - for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0])) + for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]])) ], # Fixed good→heavy order (not by count), so the class list reads as a severity scale. "playable": [ diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index f22c9288..239c2ed3 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -9,6 +9,8 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session +from app.mailtext import lang_for_user +from app.collation import collated, sorted_words from app import logs from app.auth import admin_user, current_user from app.db import get_db @@ -22,10 +24,10 @@ 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 +from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys from app.utils import like_escape from app.refusal import refused from app.routes._common import not_found @@ -142,7 +144,7 @@ def collections( db.execute( query.order_by( PlexCollection.child_count.desc().nullslast(), - func.lower(PlexCollection.title), + collated(PlexCollection.title, lang_for_user(user)), ) ) .scalars() @@ -208,16 +210,24 @@ MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = { DEFAULT_MEMBER_SORT = "year" -def sort_members(members: list[Member], sort: str, sort_dir: str) -> list[Member]: +def sort_members( + members: list[Member], sort: str, sort_dir: str, alpha: dict[str, int] | None = None +) -> list[Member]: """Order a collection's members by one of `PLEX_SORT_KEYS`. Two properties worth stating because they are easy to get wrong with a plain `reverse=True`: members with no value for the key sink to the END in BOTH directions (matching the browse grid's `nullslast()` — a movie with no rating is not "the worst rated"), and ties always resolve title-ascending, so flipping the direction reverses the ranking rather than the alphabet inside - it. An unknown key falls back to the page's default instead of returning an arbitrary order.""" + it. An unknown key falls back to the page's default instead of returning an arbitrary order. + + `alpha` ranks each title in the reader's alphabet (`collation.sorted_words`); without it, titles + compare as Python strings, which puts every accented initial after "z".""" + by_title = (lambda m: alpha[m.title]) if alpha is not None else (lambda m: m.title) key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT]) - ordered = sorted(members, key=lambda m: m.title) # stable secondary, applied first + if key is MEMBER_SORT_KEYS["title"]: + key = by_title + ordered = sorted(members, key=by_title) # stable secondary, applied first have = [m for m in ordered if key(m) is not None] missing = [m for m in ordered if key(m) is None] have.sort(key=key, reverse=sort_dir != "asc") @@ -229,6 +239,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 +248,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") @@ -244,7 +256,7 @@ def collection_detail( db.execute( select(PlexItem) .where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key])) - .order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title)) + .order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang)) ) .scalars() .all() @@ -253,7 +265,7 @@ def collection_detail( db.execute( select(PlexShow) .where(PlexShow.collection_keys.contains([rating_key])) - .order_by(PlexShow.year.asc().nullslast(), func.lower(PlexShow.title)) + .order_by(PlexShow.year.asc().nullslast(), collated(title_col(PlexShow, lang), lang)) ) .scalars() .all() @@ -285,7 +297,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 +310,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, @@ -308,11 +320,12 @@ def collection_detail( ) for sh in shows ] - members = sort_members(members, sort, sort_dir) + alpha = {w: i for i, w in enumerate(sorted_words(db, [m.title for m in members], lang))} + members = sort_members(members, sort, sort_dir, alpha) 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 f621ba6b..fe2a9aee 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -1,14 +1,16 @@ """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 from datetime import datetime, timezone from fastapi import APIRouter, Depends -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.orm import Session +from app.collation import collated, sorted_words from app import logs from app.auth import current_user from app.db import get_db @@ -24,6 +26,7 @@ from app.models import ( from app.plex import detail as plex_detail from app.plex import people as plex_people from app.plex import rollup, subtitles +from app.plex.query import title_col from app.plex import tmdb as plex_tmdb from app.plex.cards import episode_card, movie_card, show_card from app.plex.client import PlexClient, PlexError, PlexNotConfigured @@ -107,7 +110,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 +148,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 +160,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 @@ -210,7 +214,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, @@ -234,7 +238,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 +260,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 [] @@ -273,7 +279,8 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: ).scalars() } # Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last. - ordered = sorted((c for c in cols.values()), key=lambda c: (c.child_count or 0, c.title)) + alpha = {w: i for i, w in enumerate(sorted_words(db, [c.title for c in cols.values()], lang))} + ordered = sorted(cols.values(), key=lambda c: (c.child_count or 0, alpha[c.title])) out = [] for col in ordered: rk = col.rating_key @@ -285,7 +292,9 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: PlexItem.kind == "movie", PlexItem.collection_keys.contains([rk]), ) - .order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title)) + .order_by( + PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang) + ) .limit(60) ) .scalars() @@ -305,7 +314,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 @@ -339,6 +348,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, @@ -384,9 +413,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")), } @@ -396,9 +423,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) @@ -468,7 +493,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) @@ -512,7 +537,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, + "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 6a6283a6..05d6936b 100644 --- a/backend/app/routes/plex/discover.py +++ b/backend/app/routes/plex/discover.py @@ -18,11 +18,12 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import and_, func, or_, select from sqlalchemy.orm import Session +from app.collation import sorted_words 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 +62,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 +124,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 +157,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 +171,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 +218,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 +238,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]] @@ -261,7 +265,8 @@ def _genre_tiles( expanded = base.subquery() for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all(): counts[g] = counts.get(g, 0) + c - top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:GENRE_TILES] + alpha = {w: i for i, w in enumerate(sorted_words(db, list(counts), lang))} + top = sorted(counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))[:GENRE_TILES] # A 2×2 poster collage per tile: the top movies in that genre (color hash is the client fallback). art = top_art_keys( db, @@ -310,7 +315,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"] @@ -346,9 +351,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: @@ -357,7 +364,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") @@ -456,8 +466,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..2b4440b2 100644 --- a/backend/app/routes/plex/people.py +++ b/backend/app/routes/plex/people.py @@ -7,7 +7,8 @@ from fastapi import APIRouter, BackgroundTasks, Depends from sqlalchemy import func, or_, select from sqlalchemy.orm import Session -from app import logs +from app.collation import collated +from app import logs, sysconfig from app.auth import current_user from app.db import SessionLocal, get_db from app.models import ( @@ -18,22 +19,28 @@ 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.query import content_libs, split_csv +from app.plex.cards import movie_card, norm_lang, show_card +from app.plex.query import content_libs, split_csv, title_col 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, 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.""" + """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 @@ -50,10 +57,10 @@ 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))) - return out + out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang)) + return out[:_KNOWN_FOR_SHOWN] @router.get("/people/cards") @@ -159,6 +166,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: @@ -171,18 +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) @@ -199,14 +213,16 @@ def person_detail( PlexItem.directors.contains([name]), ), ) - .order_by(PlexItem.year.desc().nullslast(), func.lower(PlexItem.title)) + .order_by( + PlexItem.year.desc().nullslast(), collated(title_col(PlexItem, lang), lang) + ) .limit(_PERSON_SECTION_MAX) ) .scalars() .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: @@ -220,28 +236,34 @@ def person_detail( PlexShow.directors.contains([name]), ), ) - .order_by(PlexShow.year.desc().nullslast(), func.lower(PlexShow.title)) + .order_by( + PlexShow.year.desc().nullslast(), collated(title_col(PlexShow, lang), lang) + ) .limit(_PERSON_SECTION_MAX) ) .scalars() .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, "photo": person.photo_thumb if person else None, - "biography": person.biography if person else None, + # The Hungarian biography for a Hungarian reader, the instance-language one when TMDB has none. + "biography": ((person.biography_hu if lang == "hu" else None) or person.biography) + if person + else None, "birthday": person.birthday if person else None, "deathday": person.deathday if person else None, "place_of_birth": person.place_of_birth if person else None, "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..562973a4 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,31 @@ 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 (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") - 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/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/app/routes/sync.py b/backend/app/routes/sync.py index 5617f0e1..998d658b 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -71,7 +71,7 @@ def sync_status(user: User = Depends(current_user), db: Session = Depends(get_db def my_status(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: """Per-user sync progress: how far the channels *this* user subscribes to have synced.""" sub_join = and_(Subscription.channel_id == Channel.id, Subscription.user_id == user.id) - total, details_synced, recent_synced, deep_done = db.execute( + total, details_synced, recent_synced, all_deep_done = db.execute( select( func.count(Channel.id), func.count(Channel.details_synced_at), @@ -82,7 +82,7 @@ def my_status(user: User = Depends(current_user), db: Session = Depends(get_db)) .join(Subscription, sub_join) ).one() total = total or 0 - deep_done = int(deep_done or 0) + all_deep_done = int(all_deep_done or 0) # Per-user full-history (deep) backfill: channels this user has opted in, and an ETA # for the still-pending ones (quota-bound estimate). @@ -97,6 +97,10 @@ def my_status(user: User = Depends(current_user), db: Session = Depends(get_db)) .all() ) deep_eta = estimate_deep_backfill(db, deep_requested_channels) + # "done / requested" is shown as one ratio, so both halves count the SAME channels: the ones this + # user asked full history for. Counting every subscribed channel whose back-catalog happens to be + # complete (another user asked, or it was short) gave "342/329". + deep_done = sum(1 for c in deep_requested_channels if c.backfill_done) my_videos = db.scalar( select(func.count(Video.id)).join( @@ -117,7 +121,7 @@ def my_status(user: User = Depends(current_user), db: Session = Depends(get_db)) "channels_recent_synced": recent_synced, "channels_deep_done": deep_done, "channels_recent_pending": total - recent_synced, - "channels_deep_pending": total - deep_done, + "channels_deep_pending": total - all_deep_done, "channels_deep_requested": len(deep_requested_channels), "deep_pending_count": deep_eta["channels_pending"], "deep_eta_seconds": deep_eta["eta_seconds"], diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 156fbe21..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 @@ -69,6 +70,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, } @@ -115,7 +117,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 +129,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() @@ -140,7 +144,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) @@ -152,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, @@ -160,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: @@ -192,25 +204,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") - _audit_run(db, actor_id, name, "skipped", "sync paused", None) + _notify_done(db, actor_id, name, "skipped", "sync paused", _PAUSED) + _audit_run(db, actor_id, name, "skipped", "sync paused", _PAUSED) 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 +249,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.""" + 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 +302,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"), } @@ -369,6 +413,12 @@ 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 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: # Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep # forever). Pure DB, no quota. @@ -415,6 +465,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/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_audit_count_rows.py b/backend/tests/test_audit_count_rows.py new file mode 100644 index 00000000..1cf7f433 --- /dev/null +++ b/backend/tests/test_audit_count_rows.py @@ -0,0 +1,36 @@ +"""E30: an admin action that counts something keeps the count as data in its audit row's `after`, under +the key the audit log names in the reader's language (`scheduler.result.`, frontend +auditColumns.tsx) — not only in the English `summary`. A renamed key would fall back to the raw word. +DB lane (Postgres). +""" + +from sqlalchemy import select + +from app.models import AuditLog + + +def _after(db, action: str) -> dict: + db.expire_all() + return db.execute(select(AuditLog.after).where(AuditLog.action == action)).scalar_one() + + +def test_clearing_the_audit_log_keeps_the_count_as_data(as_user, db, make_user): + admin = make_user(email="boss@example.com", role="admin") + db.add_all([AuditLog(action="sync_pause"), AuditLog(action="sync_resume")]) + db.commit() + assert as_user(admin).delete("/api/admin/audit").json()["cleared"] == 2 + assert _after(db, "audit_clear") == {"cleared": 2} + + +def test_a_demo_reset_keeps_the_count_as_data(as_user, db, make_user): + admin = make_user(email="boss@example.com", role="admin") + seeded = as_user(admin).post("/api/admin/demo/reset").json()["playlists_seeded"] + assert _after(db, "demo_reset") == {"playlists_seeded": seeded} + + +def test_a_discovery_purge_keeps_its_counts_as_data(as_user, db, make_user): + admin = make_user(email="boss@example.com", role="admin") + body = as_user(admin).post("/api/admin/purge-discovery").json() + body.pop("ok") + assert _after(db, "discovery_purge") == body + assert {"videos_deleted", "channels_deleted", "search_videos_deleted"} <= set(body) 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_collation.py b/backend/tests/test_collation.py new file mode 100644 index 00000000..2558be23 --- /dev/null +++ b/backend/tests/test_collation.py @@ -0,0 +1,302 @@ +"""E30 S3: every A→Z list the server orders is in the reader's alphabet. The database image is Alpine, +whose `en_US.utf8` is BYTE order: "Életrajzi" and "Ötödik" sorted after "Zorró" in the feed, the channel +list, the Plex grid, the genre list. Hungarian puts "Ö" after "O" and "Cs" after "Cu". Needs the DB.""" + +from fastapi.testclient import TestClient + +import app.state as state +from app.auth import current_user +from app.collation import sorted_words +from app.db import get_db +from app.main import app +from app.models import ( + Channel, + Playlist, + PlaylistItem, + PlexCollection, + PlexItem, + PlexLibrary, + Subscription, + User, + Video, +) +from datetime import datetime, timezone + +WORDS = ["Zorró", "Életrajzi", "Ötödik", "oroszlán", "Csillag", "Cukor"] +HU = ["Cukor", "Csillag", "Életrajzi", "oroszlán", "Ötödik", "Zorró"] +EN = ["Csillag", "Cukor", "Életrajzi", "oroszlán", "Ötödik", "Zorró"] + + +def _user(db, lang: str) -> User: + user = User(email=f"collate-{lang}@example.com", preferences={"language": lang}) + db.add(user) + db.flush() + return user + + +def _client(db, user, monkeypatch, override_dep) -> TestClient: + monkeypatch.setattr(state, "_configured_cache", True) + override_dep(get_db, db.info["request"]) + override_dep(current_user, lambda: user) + return TestClient(app) + + +def test_words_sort_in_the_readers_alphabet(db): + assert sorted_words(db, WORDS, "hu") == HU + assert sorted_words(db, WORDS, "en") == EN + assert sorted_words(db, WORDS, "xx") == EN # an unknown language sorts as English + assert sorted_words(db, [], "hu") == [] + + +def _seed_feed(db, user): + db.add(Channel(id="UCc", title="Channel")) + db.flush() + db.add(Subscription(user_id=user.id, channel_id="UCc", priority=0, hidden=False)) + db.add_all( + Video( + id=f"v{i}", + channel_id="UCc", + published_at=datetime(2024, 1, i + 1, tzinfo=timezone.utc), + title=w, + is_short=False, + live_status="none", + via_search=False, + via_explore=False, + ) + for i, w in enumerate(WORDS) + ) + db.commit() + + +def test_the_feed_title_sort_pages_in_the_readers_alphabet(db, monkeypatch, override_dep): + user = _user(db, "hu") + _seed_feed(db, user) + client = _client(db, user, monkeypatch, override_dep) + + full = [it["title"] for it in client.get("/api/feed?sort=title&limit=50").json()["items"]] + assert full == HU + # The cursor compares with the same collated expression, so paging walks the same order. + walked: list[str] = [] + cursor = None + for _ in range(len(WORDS)): + url = "/api/feed?sort=title&limit=2" + (f"&cursor={cursor}" if cursor else "") + body = client.get(url).json() + walked += [it["title"] for it in body["items"]] + cursor = body["next_cursor"] + if not body["has_more"]: + break + assert walked == HU + desc = client.get("/api/feed?sort=title_desc&limit=50").json()["items"] + assert [it["title"] for it in desc] == HU[::-1] + + +def test_the_channel_list_is_in_the_readers_alphabet(db, monkeypatch, override_dep): + 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( + Subscription(user_id=user.id, channel_id=f"UC{i}", priority=0, hidden=False) + for i in range(len(WORDS)) + ) + db.commit() + r = _client(db, user, monkeypatch, override_dep).get("/api/channels") + assert [c["title"] for c in r.json()] == HU + + +def _seed_plex(db): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + col = PlexCollection(rating_key="col1", library_id=lib.id, title="Set", child_count=len(WORDS)) + db.add(col) + db.add_all( + PlexItem( + rating_key=f"m{i}", + library_id=lib.id, + kind="movie", + title=f"EN {i}", + title_hu=w, + year=2000, + genres_hu=[w], + collection_keys=["col1"], + ) + for i, w in enumerate(WORDS) + ) + db.commit() + + +def _plex(db, monkeypatch, override_dep) -> TestClient: + user = _user(db, "hu") + _seed_plex(db) + return _client(db, user, monkeypatch, override_dep) + + +def test_the_plex_grid_sorts_by_the_hungarian_title(db, monkeypatch, override_dep): + client = _plex(db, monkeypatch, override_dep) + grid = client.get("/api/plex/library?scope=movie&sort=title&sort_dir=asc&lang=hu").json() + assert [it["title"] for it in grid["items"]] == HU + + +def test_the_plex_genre_list_is_in_the_readers_alphabet(db, monkeypatch, override_dep): + client = _plex(db, monkeypatch, override_dep) + facets = client.get("/api/plex/facets?scope=movie&lang=hu").json() + assert [g["value"] for g in facets["genres"]] == HU + + +def test_a_collection_sorts_by_the_hungarian_title(db, monkeypatch, override_dep): + client = _plex(db, monkeypatch, override_dep) + members = client.get("/api/plex/collections/col1?sort=title&sort_dir=asc&lang=hu").json() + assert [it["title"] for it in members["items"]] == HU + # year ties everywhere, so the default (year) order falls through to the title tiebreak + default = client.get("/api/plex/collections/col1?lang=hu").json() + assert [it["title"] for it in default["items"]] == HU + + +def _seed_discovery(db, user, synced: bool): + """Channels the user's playlist holds one video from each, not subscribed: discovery lists them, + count-tied, so the title decides. `synced=False` leaves them stubs, which sends the endpoint down + its enrich path — the one that re-sorts in Python and has to agree with the ORDER BY.""" + pl = Playlist(user_id=user.id, name="Mix") + db.add(pl) + db.flush() + stamp = datetime(2024, 1, 1, tzinfo=timezone.utc) if synced else None + for i, w in enumerate(WORDS): + db.add(Channel(id=f"UCd{i}", title=w, details_synced_at=stamp)) + db.flush() + db.add( + Video( + id=f"d{i}", + channel_id=f"UCd{i}", + published_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + title="x", + is_short=False, + live_status="none", + via_search=False, + via_explore=False, + ) + ) + db.flush() + db.add(PlaylistItem(playlist_id=pl.id, video_id=f"d{i}", position=i)) + db.commit() + + +def test_channel_discovery_is_in_the_readers_alphabet(db, monkeypatch, override_dep): + user = _user(db, "hu") + _seed_discovery(db, user, synced=True) + r = _client(db, user, monkeypatch, override_dep).get("/api/channels/discovery") + assert [c["title"] for c in r.json()] == HU + + +def test_channel_discovery_keeps_that_order_after_enriching(db, monkeypatch, override_dep): + import app.routes.channels as channels + + class FakeYouTube: + def __init__(self, *a, **k): ... + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get_channels(self, ids): + return [] + + monkeypatch.setattr(channels, "YouTubeClient", FakeYouTube) + monkeypatch.setattr(channels, "apply_channel_details", lambda *a, **k: None) + user = _user(db, "hu") + _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_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_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_my_status_deep.py b/backend/tests/test_my_status_deep.py new file mode 100644 index 00000000..891fa74d --- /dev/null +++ b/backend/tests/test_my_status_deep.py @@ -0,0 +1,33 @@ +"""The Stats page's "Full history: done / requested" ratio counts the SAME channels on both sides — the +ones this user asked full history for. The numerator used to count every subscribed channel whose +back-catalog was complete (another user had asked, or the channel was short), so the page read +"342/329". "Channels without full history yet" still counts every subscribed channel. Needs the DB.""" + +from app.models import Channel, Subscription + + +def test_full_history_ratio_counts_only_the_requested_channels(db, make_user, as_user): + user = make_user("deep@example.com") + db.add_all( + [ + Channel(id="UCreqdone", title="Requested, done", backfill_done=True), + Channel(id="UCreqopen", title="Requested, pending", backfill_done=False), + Channel(id="UCdone", title="Not requested, done anyway", backfill_done=True), + Channel(id="UCopen", title="Not requested, pending", backfill_done=False), + ] + ) + db.flush() + db.add_all( + [ + Subscription(user_id=user.id, channel_id="UCreqdone", deep_requested=True), + Subscription(user_id=user.id, channel_id="UCreqopen", deep_requested=True), + Subscription(user_id=user.id, channel_id="UCdone"), + Subscription(user_id=user.id, channel_id="UCopen"), + ] + ) + db.commit() + + body = as_user(user).get("/api/sync/my-status").json() + assert body["channels_deep_requested"] == 2 + assert body["channels_deep_done"] == 1 # never more than it is out of + assert body["channels_deep_pending"] == 2 # subscribed channels without full history yet 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_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_lang.py b/backend/tests/test_plex_lang.py index 1113320a..00da79c9 100644 --- a/backend/tests/test_plex_lang.py +++ b/backend/tests/test_plex_lang.py @@ -12,7 +12,19 @@ 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.plex.cards import display_summary, display_title +from app.models import ( + PlexCollection, + PlexItem, + PlexLibrary, + PlexPlaylist, + PlexPlaylistItem, + PlexSeason, + PlexShow, + PlexState, + User, +) def _seed(db): @@ -30,7 +42,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, @@ -44,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), @@ -102,12 +118,52 @@ 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_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") 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 @@ -147,3 +203,126 @@ 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), + ] + ) + # 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 + + +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"), + ] + # 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"] + 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/backend/tests/test_plex_overlay.py b/backend/tests/test_plex_overlay.py index 5b30d00b..bf70ca34 100644 --- a/backend/tests/test_plex_overlay.py +++ b/backend/tests/test_plex_overlay.py @@ -6,11 +6,16 @@ Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. Runs (migrations 0063/0064) so the JSONB + STORED generated columns are exercised. """ +from alembic import command +from alembic.config import Config from sqlalchemy import func, select +from sqlalchemy.orm import Session import app.plex.sync as syncmod from app import sysconfig -from app.models import PlexItem, PlexLibrary +from app.db import engine +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 +249,171 @@ 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_a_twin_without_genres_keeps_the_rows_own_hungarian_list(db): + en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie") + hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu") + db.add_all([en_lib, hu_lib]) + db.flush() + row = PlexItem( + rating_key="m1", + library_id=en_lib.id, + kind="movie", + title="Heat", + guid="tmdb:1", + genres=["Crime"], + ) + db.add(row) + db.commit() + _sync_overlay( + db, + _StubPlex([_hu_movie("h1", "Szemtől szemben", "HU.", [], "tmdb://1")]), + hu_lib, + en_lib.id, + {}, + ) + db.commit() + db.refresh(row) + assert (row.title_hu, row.genres_hu) == ("Szemtől szemben", ["Bűnügyi"]) + + +def test_every_synced_row_gets_hungarian_genres(): + """E30 review: not only an overlaid row — the HU facets and filter read genres_hu for all.""" + from app.plex.sync import _apply_facet_fields + + row = PlexItem(title="Solo") + _apply_facet_fields(row, {"Genre": [{"tag": "Drama"}, {"tag": "Action"}]}) + assert (row.genres, row.genres_hu) == (["Drama", "Action"], ["Dráma", "Akció"]) + + +def test_the_genre_map_is_idempotent_and_keeps_an_unknown_word(): + assert hu_genre("science fiction") == "Sci-Fi" # case-insensitive + assert hu_genre("Dráma") == "Dráma" + assert hu_genre("Space Opera") == "Space Opera" # unknown: shown as Plex gave it, never dropped + once = hu_genres(["Drama", "Action", "Dráma"]) + assert once == ["Dráma", "Akció"] + assert hu_genres(once) == once + assert hu_genres(None) is None and hu_genres([]) is None + + +def test_0094_gives_every_row_without_a_hungarian_list_one(db): + cfg = Config("alembic.ini") + command.downgrade(cfg, "0093_plex_person_known_for_votes") + upgraded = False + try: + with Session(engine) as s: + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + s.add(lib) + s.flush() + rows = { + "bare": PlexItem(genres=["Action", "Biography"]), + "overlaid": PlexItem( + genres=["Action"], genres_hu=["Kaland"] + ), # its twin's own list + "genreless": PlexItem(genres=[]), + } + for key, row in rows.items(): + row.rating_key, row.library_id, row.kind, row.title = key, lib.id, "movie", key + show = PlexShow(rating_key="s", library_id=lib.id, title="s", genres=["Comedy"]) + s.add_all([*rows.values(), show]) + s.commit() + ids = {k: r.id for k, r in rows.items()} + show_id = show.id + command.upgrade(cfg, "head") + upgraded = True + with Session(engine) as s: + got = {k: s.get(PlexItem, i).genres_hu for k, i in ids.items()} # type: ignore[union-attr] + show_hu = s.get(PlexShow, show_id).genres_hu # type: ignore[union-attr] + assert got == {"bare": ["Akció", "Életrajzi"], "overlaid": ["Kaland"], "genreless": None} + assert show_hu == ["Vígjáték"] + finally: + if not upgraded: + command.upgrade(cfg, "head") diff --git a/backend/tests/test_plex_people_enrich.py b/backend/tests/test_plex_people_enrich.py new file mode 100644 index 00000000..33ff2fb7 --- /dev/null +++ b/backend/tests/test_plex_people_enrich.py @@ -0,0 +1,463 @@ +"""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 httpx +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=()): + self.asked: list[int] = [] + self.fail_on = set(fail_on) + + def fetch_person(self, pid): + self.asked.append(pid) + if pid in 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_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": 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": 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_failures_between_successes_do_not_end_the_batch(db): + db.add_all([PlexPerson(name=f"P{i}", tmdb_person_id=i) for i in range(1, 9)]) + db.commit() + tmdb = _Tmdb(fail_on={1, 3, 5, 7}) # fail, ok, fail, ok, …: never three in a row + assert plex_tmdb.enrich_people_batch(db, tmdb, 10) == {"enriched": 4, "failed": 4} # type: ignore[arg-type] + assert tmdb.asked == list(range(1, 9)) + + +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: + """A PlexClient stand-in. `down`: urls Plex does not answer for (True: none); `dead`: urls Plex + 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, 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 + + def __exit__(self, *a): + return False + + def image_bytes(self, url, width=None, height=None): + 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 = self.gone if url in self.dead else self.status + if code: + req = httpx.Request("GET", url) + status = httpx.HTTPStatusError( + str(code), request=req, response=httpx.Response(code, request=req) + ) + raise plex_people.PlexError(str(code)) from status + return b"small", "image/jpeg" + + +@pytest.fixture +def cache_dir(tmp_path, monkeypatch): + monkeypatch.setattr(imgcache, "CACHE_DIR", tmp_path) + return tmp_path + + +@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"]) + 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, 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_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(original) + + +def test_portraits_wait_for_plex(db, cache_dir, plex_on, monkeypatch): + _faces(db, 1) + + 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_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 + + +@pytest.mark.parametrize("code", [401, 403, 429, 500, 503]) +def test_plex_refusing_is_plex_failing_not_a_dead_file(db, cache_dir, plex_on, code): + _faces(db, 8) + plex_on["plex"] = _Plex(status=code) # a rotated token, a busy or broken transcoder + 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")) # the files are not to blame: asked again next run + + +def test_plex_failing_between_successes_does_not_end_the_run(db, cache_dir, plex_on): + _faces(db, 8) + plex_on["plex"] = _Plex(down={CDN + f"{i}.jpg" for i in (0, 2, 4, 6)}) # down, ok, down, ok, … + assert plex_people.warm_portraits(db, 100) == {"cached": 4, "failed": 4} + + +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, gone=gone) + assert plex_people.warm_portraits(db, 100) == {"cached": 2, "failed": 6} + + 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 + + +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 spellings an HTTP + # client folds onto it, no other host. + for bad in ( + url + "?n=1", + url + "#x", + CDN + "x/../face.jpg", + CDN + "./face.jpg", + plex_people.PERSON_IMG_HOST + "people//face.jpg", + CDN + ".face.jpg", + "https://example.com/people/a.jpg", + ): + assert get(bad).status_code == 400, bad + assert plex_people.person_thumb_url(bad) is None, bad # never stored either + + # 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) + monkeypatch.setattr(plex_people.httpx, "Client", None) # the CDN is not asked again + assert get(url).content == b"original" # the kept original answers + + # 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(): + 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 ec18c777..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 @@ -332,3 +333,74 @@ def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch, overri assert j["also_known_as"] == ["Alt Name"] # Only the in-library known_for credit resolves to a card; the out-of-library one is dropped. assert [c["title"] for c in j["known_for"]] == ["Known Movie"] + + +# --- E30 S4: the biography in the reader's language -------------------------------------------------- +# The same /person request appends `translations`; the Hungarian one is kept beside the instance-language +# biography, and a Hungarian reader is served it, falling back to the English one when TMDB has none. + + +def _translation(lang: str, bio: str) -> dict: + return {"iso_639_1": lang, "iso_3166_1": lang.upper(), "data": {"biography": bio}} + + +def test_parse_person_keeps_the_hungarian_biography(): + payload = { + **PAYLOAD, + "translations": { + "translations": [ + _translation("de", "Ein Schauspieler."), + _translation("hu", " Ünnepelt színész. "), + ] + }, + } + assert plex_tmdb.parse_person(payload)["biography_hu"] == "Ünnepelt színész." + + +def test_parse_person_without_a_hungarian_biography(): + assert plex_tmdb.parse_person(PAYLOAD)["biography_hu"] is None + blank = {**PAYLOAD, "translations": {"translations": [_translation("hu", " ")]}} + assert plex_tmdb.parse_person(blank)["biography_hu"] is None + + +def test_fetch_person_asks_for_the_translations(): + client = plex_tmdb.TmdbClient.__new__(plex_tmdb.TmdbClient) + seen: list[dict] = [] + client._get = lambda path, params=None: seen.append(params or {}) or {} # type: ignore[method-assign] + client.fetch_person(500) + assert "translations" in seen[0]["append_to_response"].split(",") + + +def test_person_detail_serves_the_biography_in_the_readers_language(db, monkeypatch, override_dep): + from datetime import datetime, timezone + + done = datetime(2024, 1, 1, tzinfo=timezone.utc) + db.add(PlexLibrary(plex_key="7", title="Movies", kind="movie", enabled=True)) + db.add_all( + [ + PlexPerson( + name="Both P", + tmdb_person_id=1, + tmdb_enriched_at=done, + biography="English bio.", + biography_hu="Magyar életrajz.", + ), + PlexPerson( + name="Only EN P", tmdb_person_id=2, tmdb_enriched_at=done, biography="Only English." + ), + ] + ) + user = User(email="biolang@example.com") + db.add(user) + db.commit() + + def bio(name: str, lang: str) -> str | None: + resp = _get(db, user, monkeypatch, override_dep, f"/api/plex/person/{name}?lang={lang}") + assert resp.status_code == 200, resp.text + return resp.json()["biography"] + + assert bio("Both P", "hu") == "Magyar életrajz." + assert bio("Both P", "en") == "English bio." + assert ( + bio("Only EN P", "hu") == "Only English." + ) # TMDB has no Hungarian one: the English stands in diff --git a/backend/tests/test_plex_tmdb.py b/backend/tests/test_plex_tmdb.py index 640fd686..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 @@ -255,13 +256,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 +410,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,7 +425,74 @@ 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" 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..f77cd6c8 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,63 @@ 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 + + +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"}, + ] diff --git a/docs/DESIGN.md b/docs/DESIGN.md index cc84ee8b..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 @@ -531,7 +566,7 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen # - **Messages.** A toast or error of one sentence has no full stop and no "✓": "Couldn't sync subscriptions" / "Nem sikerült szinkronizálni". Two sentences keep their stops. Help text is prose. - **Quotes** around a name are curly: “abc” in English, „abc” in Hungarian. -- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/date-format`): recency in a list +- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/locale-format`): recency in a list is relative ("3d ago" / "3 napja") through `ui/RelativeTime`, which carries the exact minute on hover; a date is "Sep 27, 2026" / "2026. szept. 27."; a moment "Sep 27, 2026, 08:30 AM" / "2026. szept. 27. 08:30"; seconds only in the server log, the audit and the recorder. @@ -540,6 +575,17 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen # label holding one is written in that same form ("Under 90 min"). Never a bare letter ("90m", "24h", "7d"; test-held). In a sentence, and in a list of time spans named in words ("24 hours", "1 week"), the unit is a word. A clock-style length on a picture stays "2:34:11". +- **Numbers** in the UI language, from `lib/format.ts` (same lint): the decimal mark and grouping are the + language's ("8.3" / "8,3", "1,234" / "1 234"); a big count is compact ("1.2K" / "1,2 E"); a size keeps + its symbol ("5.0 GB" / "5,0 GB"), as do a stream's (Mbps) and "%" / "px". Never `toFixed` on screen. +- **Order.** An A→Z list is in the UI language's alphabet ("Ö" after "O", "Cs" after "Cu" in Hungarian), + by the name the reader SEES: `compareText` (`lib/collate.ts`) on the client, the ICU collation of + `app/collation.py` on the server, so both sort alike. +- **Hungarian words, not English ones** (E30 D3). Kept as Hungarian users say them: Feed, Shorts, Admin, + Token, Port, and technical tokens (log levels, ffmpeg presets, remux). Everything else is Hungarian: + közvetítés (stream), Prioritás, Feladat (job), Eseménynapló (audit), Demó, Automatikus, Infó, + Felület / Szerver (frontend / backend), videók behúzása (backfill), átkódolás (transcode), e-mail. + A longer Hungarian word gets its room — a column is sized to it, a pill truncates — never clipped. - A label says what happens ("Delete playlist"), not "OK"; a destructive confirm names the object. ## Anti-patterns @@ -550,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 @@ -624,6 +673,9 @@ 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 | | 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint | | 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed | diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 17759e77..4500b12e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -82,33 +82,66 @@ const focusSafeDisabled = { }; }, }; -// E28 S3g (docs/DESIGN.md "Copy"): the app shows a date in three forms plus the relative one, all -// built in lib/format.ts in the UI language. A bare `toLocaleString()` formats in the BROWSER's locale -// and as numbers with seconds; seven call sites had drifted into five shapes. Outside lib/format.ts -// a `toLocale*String` call or an `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` is refused (a -// number goes through formatNumber, which the same rule covers). -const dateFormat = { +// E28 S3g + E30 S3 (docs/DESIGN.md "Copy"): a date, a number and an alphabetical order are all in the +// UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare +// `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a +// bare `toFixed` writes the English decimal point, a bare `localeCompare` sorts in the browser's +// alphabet. So outside those files a `toLocale*String` call or an Intl date/number formatter is refused, +// an `Intl.Collator` outside collate.ts, and a `localeCompare` anywhere. +// +// And in a component, the language itself must come off `useTranslation()`, never the i18n singleton +// (`import i18n from "../i18n"`): the React Compiler memoizes on what it can see, and a singleton read +// is invisible to it — a card kept "2 hr 4 min" and a Plex query its English after a switch to +// Hungarian. The singleton is resolved by SCOPE, so `const { i18n } = useTranslation()` passes. +const localeFormat = { meta: { type: "problem", messages: { - date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatDateTimeSec, relativeTime, formatNumber), in the UI language.", + date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatNumber, formatDecimal, …), in the UI language.", + order: + "Compare text with compareText (lib/collate.ts), in the UI language — a bare localeCompare sorts in the browser's.", + singleton: + "Take the language from useTranslation() (`const { i18n } = useTranslation()`), not the i18n singleton — the React Compiler cannot see a singleton read, so the value stays in the old language after a switch.", }, }, create(context) { const path = context.filename.split("\\").join("/"); - // lib/format.ts is where the forms live; a test computes its expectations; the Plex player's - // on-screen clock (lib/plexPlayerPrefs.ts) is a clock face with its own 12/24h switch, not a date. - if (/\/src\/lib\/(format|plexPlayerPrefs)\.ts$|\.test\.tsx?$/.test(path)) return {}; + if (/\.test\.tsx?$/.test(path)) return {}; + // lib/format.ts is where the forms live; the Plex player's on-screen clock (lib/plexPlayerPrefs.ts) + // is a clock face with its own 12/24h switch, not a date. + const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.ts$/.test(path); + const collates = /\/src\/lib\/collate\.ts$/.test(path); + const component = /\/src\/components\//.test(path); + const importedFromI18n = (node) => { + for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) { + const v = scope.set.get(node.name); + if (v) + return v.defs.some( + (d) => d.type === "ImportBinding" && /\/i18n$|^i18next$/.test(d.parent.source.value), + ); + } + return false; + }; return { "CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"( node, ) { - context.report({ node, messageId: "date" }); + if (!formats) context.report({ node, messageId: "date" }); }, - "MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat)$/]"( + "MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat|NumberFormat)$/]"( node, ) { - context.report({ node, messageId: "date" }); + if (!formats) context.report({ node, messageId: "date" }); + }, + "MemberExpression[object.name='Intl'][property.name='Collator']"(node) { + if (!collates) context.report({ node, messageId: "order" }); + }, + "CallExpression > MemberExpression.callee[property.name='localeCompare']"(node) { + context.report({ node, messageId: "order" }); + }, + "MemberExpression[property.name='language'][object.type='Identifier']"(node) { + if (component && importedFromI18n(node.object)) + context.report({ node, messageId: "singleton" }); }, }; }, @@ -382,7 +415,7 @@ const localRules = { "jsx-comment-text": jsxCommentText, "focus-safe-disabled": focusSafeDisabled, "button-primitive": buttonPrimitive, - "date-format": dateFormat, + "locale-format": localeFormat, "input-primitive": inputPrimitive, "surface-role": surfaceRole, "literal-a11y-text": literalA11yText, @@ -395,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-])/"; @@ -477,7 +516,7 @@ export default tseslint.config( "local/jsx-comment-text": "error", "local/focus-safe-disabled": "error", "local/button-primitive": "error", - "local/date-format": "error", + "local/locale-format": "error", "local/input-primitive": "error", "local/surface-role": "error", "local/literal-a11y-text": "error", @@ -554,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/About.tsx b/frontend/src/components/About.tsx index 853ebfad..e925985e 100644 --- a/frontend/src/components/About.tsx +++ b/frontend/src/components/About.tsx @@ -8,10 +8,10 @@ import { formatDateTime } from "../lib/format"; import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; -function fmtDate(d?: string | null): string { +function fmtDate(d: string | null | undefined, lang: string): string { if (!d) return "—"; const t = new Date(d); - return isNaN(t.getTime()) ? d : formatDateTime(d); + return isNaN(t.getTime()) ? d : formatDateTime(d, lang); } // About dialog: app + build + schema versions (frontend/backend/database), plus a @@ -23,7 +23,8 @@ export default function About({ onClose: () => void; onOpenReleaseNotes: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const { data } = useQuery({ queryKey: qk.version(), queryFn: api.version, @@ -40,7 +41,7 @@ export default function About({ data ? data.app_version + (data.git_sha !== "unknown" ? ` · ${data.git_sha}` : "") : "…", ], [t("about.database"), data?.db_revision ?? "…"], - [t("about.buildDate"), fmtDate(data?.build_date ?? FRONTEND_BUILD_DATE)], + [t("about.buildDate"), fmtDate(data?.build_date ?? FRONTEND_BUILD_DATE, lang)], ]; return ( diff --git a/frontend/src/components/AccountPanel.tsx b/frontend/src/components/AccountPanel.tsx index 5442f1ed..f6b61efe 100644 --- a/frontend/src/components/AccountPanel.tsx +++ b/frontend/src/components/AccountPanel.tsx @@ -55,7 +55,8 @@ export default function AccountPanel({ user: AdminUserRow; onClose: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null); const [copied, setCopied] = useState(false); @@ -182,7 +183,7 @@ export default function AccountPanel({ label={t("users.account.lastLogin")} value={ a.last_login_at - ? formatDateTime(a.last_login_at) + ? formatDateTime(a.last_login_at, lang) : a.is_demo ? t("users.account.lastLoginDemo") : t("users.account.lastLoginNone") diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx index 4839de51..bfc0a8e0 100644 --- a/frontend/src/components/AdminLogs.tsx +++ b/frontend/src/components/AdminLogs.tsx @@ -74,7 +74,8 @@ function toQuery(state: LogsUrlState, q: string): LogQuery { } export default function AdminLogs() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [state, setState] = useState(() => logsParamsToState(new URLSearchParams(window.location.search)), @@ -406,7 +407,7 @@ export default function AdminLogs() { level={row.level} event={row.event ?? "—"} msg={row.msg} - title={formatDateTimeSec(row.ts)} + title={formatDateTimeSec(row.ts, lang)} open={open === row.id} onToggle={() => setOpen(open === row.id ? null : row.id)} > @@ -468,14 +469,15 @@ function RowDetail({ onRequest: (id: string) => void; onJob: (id: number) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const pivotCls = "font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid"; return (
{t("adminLogs.fields.when")}
-
{formatDateTimeSec(row.ts)}
+
{formatDateTimeSec(row.ts, lang)}
{t("adminLogs.fields.source")}
{row.source} · {row.logger} diff --git a/frontend/src/components/AdminUsers.dom.test.tsx b/frontend/src/components/AdminUsers.dom.test.tsx index 2a6fcd94..81c87530 100644 --- a/frontend/src/components/AdminUsers.dom.test.tsx +++ b/frontend/src/components/AdminUsers.dom.test.tsx @@ -93,7 +93,7 @@ describe("the last sign-in column (E22 S1)", () => { , ); - expect(await screen.findByText(relativeTime(at))).toBeTruthy(); + expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy(); expect(screen.getAllByText("—")).toHaveLength(2); }); }); diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 20157888..13d9e541 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -103,7 +103,8 @@ export default function AdminUsers() { } function UsersRoles({ me }: { me: Me }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); const q = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers }); @@ -336,14 +337,14 @@ function UsersRoles({ me }: { me: Me }) { - {u.last_login_at ? relativeTime(u.last_login_at) : "—"} + {u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
@@ -440,7 +441,8 @@ function PendingAccessRowView({ onActivate: () => void; onResend: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; return (
@@ -449,7 +451,7 @@ function PendingAccessRowView({ {row.created_at && ( {t("users.pendingAccess.signedUp", { - time: formatDateTime(row.created_at), + time: formatDateTime(row.created_at, lang), })} )} @@ -536,6 +538,7 @@ function InviteLangPicker({ function AdminInvites() { const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); const me = useMe(); @@ -626,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({ @@ -640,16 +644,16 @@ function AdminInvites() { : "settings.invites.inviteConfirm", { email: inv.email, - time: formatDateTime(inv.invited_at ?? new Date(0).toISOString()), + time: formatDateTime(inv.invited_at ?? new Date(0).toISOString(), lang), }, )}

- (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({ @@ -728,7 +732,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 @@ -740,7 +748,7 @@ function AdminInvites() { hint={ i.invited_at ? t("settings.invites.invitedAt", { - time: formatDateTime(i.invited_at), + time: formatDateTime(i.invited_at, lang), }) : t("settings.invites.inviteHint") } @@ -927,7 +935,8 @@ function InviteRow({ onDelete: () => void; busy: boolean; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; return (
@@ -935,7 +944,7 @@ function InviteRow({ {inv.requested_at && (
{t("settings.invites.requested", { - time: formatDateTime(inv.requested_at), + time: formatDateTime(inv.requested_at, lang), })}
)} diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index 855c1087..b034905b 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -13,12 +13,14 @@ import DataTable from "./ui/DataTable"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { auditColumns } from "./auditColumns"; import { notifyActionError } from "../lib/actionErrors"; +import { compareText } from "../lib/collate"; // Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run // summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via // DataTable; the API returns the most recent window (capped) — the log stays small by design. export default function AuditLog() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() }); @@ -26,10 +28,12 @@ export default function AuditLog() { const rows = useMemo(() => data?.items ?? [], [data]); const actionOptions = useMemo(() => { - const present = Array.from(new Set(rows.map((r) => r.action))).sort(); - return present.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) })); - }, [rows, t]); - const columns = useMemo(() => auditColumns(t, actionOptions), [t, actionOptions]); + // By what the reader sees — the named action, in their alphabet — not by the code. + return Array.from(new Set(rows.map((r) => r.action))) + .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, lang, actionOptions), [t, lang, actionOptions]); const clear = useMutation({ mutationFn: () => api.clearAudit(), 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 7b524cf9..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,10 +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; - 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/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/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index ef651069..3e0896f8 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -41,7 +41,8 @@ export default function ChannelDiscovery({ onOpenWizard: () => void; onViewChannel: (id: string, name: string) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); // The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is @@ -148,7 +149,7 @@ export default function ChannelDiscovery({ hideInCard: true, render: (c) => , }, - subsColumn(t), + subsColumn(t, lang), { key: "videos", header: t("channels.discovery.cols.totalVideos"), @@ -157,7 +158,7 @@ export default function ChannelDiscovery({ sortable: true, sortValue: (c) => c.video_count ?? -1, render: (c) => ( - {formatCountOrDash(c.video_count)} + {formatCountOrDash(c.video_count, lang)} ), }, { @@ -186,7 +187,7 @@ export default function ChannelDiscovery({ // computed for cards — DataTable sorts and slices internally, so doing it in table mode too would // copy+sort the whole list every render for a result nothing reads. const isCards = layout === "cards"; - const sortedRows = isCards ? sortRows(rows, columns, sort) : rows; + const sortedRows = isCards ? sortRows(rows, columns, sort, lang) : rows; const { paged: pagedRows, pagerProps, diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 4e436f9f..a15c2ca3 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"; @@ -35,13 +35,14 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyActionError } from "../lib/actionErrors"; import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api"; import type { FeedView } from "../lib/feedView"; -import { channelYouTubeUrl, formatDate, formatViews } from "../lib/format"; +import { channelYouTubeUrl, formatDate, formatCompact } from "../lib/format"; import { ChannelTagsControl } from "./ChannelTags"; import { useChannelTagActions } from "../lib/useChannelTagActions"; 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 @@ -69,6 +70,7 @@ export default function ChannelPage({ onShowInFeed: () => void; }) { const { t, i18n } = useTranslation(); + const lang = i18n.language; const { closeChannel } = useNavigationActions(); const qc = useQueryClient(); const confirm = useConfirm(); @@ -230,11 +232,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) => @@ -284,7 +282,7 @@ export default function ChannelPage({ const metaParts = [ ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null, ch?.subscriber_count != null - ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) + ? t("channel.subscribers", { formatted: formatCompact(ch.subscriber_count, lang) }) : null, // Show "stored / total" when we only hold a subset of a subscribed channel's uploads, so the // gap between the browsable videos and YouTube's raw count is honest rather than looking like @@ -295,7 +293,7 @@ export default function ChannelPage({ : t("channel.videoCount", { count: ch.video_count }) : null, ch?.total_view_count != null - ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) + ? t("channel.totalViews", { formatted: formatCompact(ch.total_view_count, lang) }) : null, // Per-type breakdown (R14 moved this off the manager table's N/S/L column). Only meaningful // once we actually hold some of the channel's videos. diff --git a/frontend/src/components/ChannelStatsPopover.dom.test.tsx b/frontend/src/components/ChannelStatsPopover.dom.test.tsx index b477c91f..570f8743 100644 --- a/frontend/src/components/ChannelStatsPopover.dom.test.tsx +++ b/frontend/src/components/ChannelStatsPopover.dom.test.tsx @@ -14,12 +14,16 @@ import { FloatingTree } from "@floating-ui/react"; // (i18n/index.ts calls i18n.use(initReactI18next) at import time). vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); import ChannelStatsPopover from "./ChannelStatsPopover"; import { type MyStatus } from "../lib/api"; import { formatEta, formatNumber } from "../lib/format"; +import type { TFunction } from "i18next"; beforeAll(() => { class Stub { @@ -74,7 +78,7 @@ describe("ChannelStatsPopover", () => { expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy(); expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio - expect(screen.getByText(formatNumber(1234))).toBeTruthy(); // my_videos, locale-agnostic + expect(screen.getByText(formatNumber(1234, "en"))).toBeTruthy(); // my_videos, locale-agnostic }); it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => { @@ -86,7 +90,9 @@ describe("ChannelStatsPopover", () => { // The value is the FORMATTED ETA (via formatEta), not raw seconds — asserting it guards the // formatEta wiring, which is untested anywhere else. Using the same formatEta keeps this // locale/impl-agnostic (expected === what the component renders). - expect(screen.getByText(formatEta(120))).toBeTruthy(); + expect( + screen.getByText(formatEta(((k: string) => k) as unknown as TFunction, 120)), + ).toBeTruthy(); expect(screen.queryByText("120")).toBeNull(); // never the raw seconds }); diff --git a/frontend/src/components/ChannelStatsPopover.tsx b/frontend/src/components/ChannelStatsPopover.tsx index 85fda529..0a280ab2 100644 --- a/frontend/src/components/ChannelStatsPopover.tsx +++ b/frontend/src/components/ChannelStatsPopover.tsx @@ -22,7 +22,8 @@ import { SURFACE } from "./ui/surfaces"; // with a leading icon. Kept in the manager's own toolbar (the header's SyncStatus is global; these // are channel-catalog figures). export default function ChannelStatsPopover({ status }: { status: MyStatus }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const [open, setOpen] = useState(false); const pop = useAnchoredPopover({ open, @@ -58,7 +59,7 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) { { icon: Clock, label: t("channels.stats.left"), - value: formatEta(status.deep_eta_seconds), + value: formatEta(t, status.deep_eta_seconds), hint: t("channels.stats.leftHint", { count: status.deep_pending_count }), }, ] @@ -66,13 +67,13 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) { { icon: Film, label: t("channels.stats.myVideos"), - value: formatNumber(status.my_videos), + value: formatNumber(status.my_videos, lang), hint: t("channels.stats.myVideosHint"), }, { icon: Zap, label: t("channels.stats.quotaLeft"), - value: formatNumber(status.quota_remaining_today), + value: formatNumber(status.quota_remaining_today, lang), hint: t("channels.stats.quotaLeftHint"), }, ]; diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 14c2e9c5..58cd49e9 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"; @@ -106,7 +107,8 @@ export default function Channels() { const { openChannel: onViewChannel, setPage } = useNavigationActions(); const feedFilters = useFeedFilters(); const { setFilters: setFeedFilters } = useFeedFiltersActions(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); // Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App @@ -118,7 +120,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); @@ -169,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, me.preferences), + ); setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 })); }; @@ -363,6 +368,7 @@ export default function Channels() { const columns = buildChannelColumns({ t, + lang: i18n.language, prioOptions, userTags, bumpPriority, @@ -470,7 +476,7 @@ export default function Channels() { // Cards have no column headers, so apply the shared column filters (sync + any funnel set in table // mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort. const sortedChannels = isCards - ? sortRows(filterRows(syncedChannels, columns, filters), columns, sort) + ? sortRows(filterRows(syncedChannels, columns, filters), columns, sort, lang) : syncedChannels; const { paged: pagedCards, diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index e9b6e28a..ddbdf971 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -143,7 +143,7 @@ export default function ConfigPanel() { notifyActionError(e, t("config.plexTestFailed")); }, }); - // Bug reports' "Check the connection" (E16): the repository answers, takes issues, is private. + // Bug reports' "Test connection" (E16): the repository answers, takes issues, is private. const testIssues = useMutation({ mutationFn: () => api.testIssues(), onSuccess: (r) => diff --git a/frontend/src/components/DebugRecorder.tsx b/frontend/src/components/DebugRecorder.tsx index 195601d0..b74d9e24 100644 --- a/frontend/src/components/DebugRecorder.tsx +++ b/frontend/src/components/DebugRecorder.tsx @@ -372,7 +372,8 @@ const chipTone = (s: DebugSession): string => { /** The last sessions, newest first — reopening one opens its composer, with what was written. */ function SessionHistory() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions }); const items = q.data?.items.filter((s) => s.ended_at) ?? []; if (q.isError && !q.data) @@ -394,7 +395,7 @@ function SessionHistory() { key={s.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm" > - {formatDateTimeSec(s.started_at)} + {formatDateTimeSec(s.started_at, lang)} {s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)} diff --git a/frontend/src/components/DiagnosticsSettings.tsx b/frontend/src/components/DiagnosticsSettings.tsx index 87bce22e..c6f57f69 100644 --- a/frontend/src/components/DiagnosticsSettings.tsx +++ b/frontend/src/components/DiagnosticsSettings.tsx @@ -40,7 +40,8 @@ function useRemaining(until: string | null): { h: number; m: number } | null { // the server keeps it and for how long, and that it lapses; only its button raises the level. // Returning to Alap needs no consent and happens at once. export default function DiagnosticsSettings() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const me = useMe(); const qc = useQueryClient(); const state = me.diagnostics; @@ -173,8 +174,8 @@ export default function DiagnosticsSettings() { level: levelName(current), time: remaining.h > 0 - ? `${formatUnit(remaining.h, "hour")} ${formatUnit(remaining.m, "minute")}` - : formatUnit(remaining.m, "minute"), + ? `${formatUnit(remaining.h, "hour", lang)} ${formatUnit(remaining.m, "minute", lang)}` + : formatUnit(remaining.m, "minute", lang), })} diff --git a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx index bdfe7757..06ba4905 100644 --- a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx @@ -15,7 +15,10 @@ const mocks = vi.hoisted(() => ({ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); vi.mock("../lib/useLiveQuery", () => ({ diff --git a/frontend/src/components/DownloadCenter.menu.dom.test.tsx b/frontend/src/components/DownloadCenter.menu.dom.test.tsx index 7a6695ac..ae34950e 100644 --- a/frontend/src/components/DownloadCenter.menu.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.menu.dom.test.tsx @@ -18,7 +18,10 @@ const mocks = vi.hoisted(() => ({ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); vi.mock("../lib/useLiveQuery", () => ({ 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/Messages.keyboard.dom.test.tsx b/frontend/src/components/Messages.keyboard.dom.test.tsx index 19dc6792..05644f1a 100644 --- a/frontend/src/components/Messages.keyboard.dom.test.tsx +++ b/frontend/src/components/Messages.keyboard.dom.test.tsx @@ -8,7 +8,10 @@ import { act, cleanup, render, screen } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); const conversation = (id: number, name: string) => ({ diff --git a/frontend/src/components/NotificationsPanel.menu.dom.test.tsx b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx index 5d1c7fc7..131e4925 100644 --- a/frontend/src/components/NotificationsPanel.menu.dom.test.tsx +++ b/frontend/src/components/NotificationsPanel.menu.dom.test.tsx @@ -23,7 +23,7 @@ const h = vi.hoisted(() => ({ vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), })); vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm })); vi.mock("../lib/useLiveQuery", () => ({ @@ -330,3 +330,37 @@ describe("NotificationsPanel · the page's background menu", () => { expect(menuRows()).toEqual(["inbox.markAllRead"]); }); }); + +describe("a download-lifecycle notice (E30)", () => { + it("is named from its data in the reader's language, never the server's English title", () => { + h.server = [ + server(1, { + type: "download_shared", + title: "Cats was shared with you", + body: null, + data: { kind: "download_shared", from: "a@b.c", title: "Cats" }, + } as never), + server(2, { + type: "download_expiring", + title: "Dogs", + data: { kind: "download_expiring", title: "Dogs" }, + } as never), + server(3, { + type: "download_expired", + title: "download", // an untitled video: the server's English fallback word + data: { kind: "download_expired", title: null }, + } as never), + server(4, { + type: "download_evicted", + title: "Birds", + data: { kind: "download_evicted", title: "Birds" }, + } as never), + ]; + mount(); + for (const key of ["downloadShared", "downloadExpiring", "downloadExpired", "downloadEvicted"]) + expect(screen.getByText(`inbox.${key}.title`)).toBeTruthy(); + for (const title of ["Cats", "Dogs", "Birds"]) expect(screen.getByText(title)).toBeTruthy(); + expect(screen.queryByText("Cats was shared with you")).toBeNull(); + expect(screen.queryByText("download")).toBeNull(); + }); +}); diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index eca8753b..99912cd7 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -19,6 +19,7 @@ import { X, } from "lucide-react"; 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"; @@ -438,6 +439,14 @@ export default function NotificationsPanel() { } /** A server notification's link, from its type and typed payload (the row's button and its menu). */ +// The download-lifecycle notification types → their `inbox.` copy. +const DOWNLOAD_LIFECYCLE: Record = { + download_shared: "downloadShared", + download_expiring: "downloadExpiring", + download_expired: "downloadExpired", + download_evicted: "downloadEvicted", +}; + function serverKind(n: AppNotification): "scheduler" | "download" | null { if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler"; if (n.type === "download_done") return "download"; @@ -498,6 +507,7 @@ function NotificationRow({ onOpenScheduler: () => void; onOpenDownloads: () => void; }) { + const lang = useTranslation().i18n.language; // The maintenance producer ships the affected videos in `data.videos`; list a few titles. const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos) ? n.data!.videos @@ -508,6 +518,8 @@ function NotificationRow({ const isScheduler = serverKind(n) === "scheduler"; const isDownloadDone = serverKind(n) === "download"; const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string"; + // A download's life after it is done (E30): shared with you, about to expire, expired, evicted. + const lifecycle = DOWNLOAD_LIFECYCLE[n.type]; let title = n.title; let body = n.body; if (isMaintenance) { @@ -516,13 +528,11 @@ function NotificationRow({ title = t("inbox.maintenance.title"); body = t("inbox.maintenance.body", { count: d.count }); } else if (isScheduler) { - // " finished/failed", with the raw result summary as the (technical) body. - const d = n.data as { job_id: string; status?: string; summary?: string }; - const job = t(`scheduler.jobs.${d.job_id}`, d.job_id); - title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { - job, - }); - body = d.summary || n.body; + // " 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 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. @@ -534,6 +544,13 @@ function NotificationRow({ : "inbox.emailUndelivered.sendFailed", { subject: d.subject || n.body || "" }, ); + } else if (lifecycle) { + // The server stores an English title ("… was shared with you", or the bare word "download" for an + // untitled video); the notice is named from `data`, the video's title as the body — none when it + // has no title (or the share notice predates the title riding along). + const d = n.data as { title?: string | null; from?: string }; + title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" }); + body = d.title || null; } else if (isDownloadDone) { // "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body // (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled. diff --git a/frontend/src/components/PersonalViewsModal.dom.test.tsx b/frontend/src/components/PersonalViewsModal.dom.test.tsx index 486f2bf6..9d08d7a9 100644 --- a/frontend/src/components/PersonalViewsModal.dom.test.tsx +++ b/frontend/src/components/PersonalViewsModal.dom.test.tsx @@ -22,6 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => { return { ...actual, useTranslation: () => ({ + i18n: { language: "en" }, t: (key: string, o?: { name?: string }) => key === "common.order.dragNamed" ? `${key}:${o?.name}` : key, }), diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 2422348c..b492edb2 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -44,7 +44,7 @@ import { channelYouTubeUrl, formatDate, formatDuration, - formatViews, + formatCompact, relativeTime, } from "../lib/format"; import { renderDescription } from "./descriptionLinks"; @@ -119,6 +119,7 @@ export default function PlayerModal({ // icon next to the name keeps the open-on-YouTube behaviour. }) { const { t, i18n } = useTranslation(); + const lang = i18n.language; const { openChannel } = useNavigationActions(); const qc = useQueryClient(); const descFade = useScrollFade(); @@ -899,12 +900,12 @@ export default function PlayerModal({ ·{" "} {t("player.views", { count: active.view_count, - formattedCount: formatViews(active.view_count), + formattedCount: formatCompact(active.view_count, lang), })} )} - · {relativeTime(active.published_at)} + · {relativeTime(active.published_at, lang)} {active.published_at && ` · ${fullDate(active.published_at)}`} {active.duration_seconds != null && ( @@ -924,13 +925,13 @@ export default function PlayerModal({ ·{" "} {t("player.views", { count: detail.data.view_count, - formattedCount: formatViews(detail.data.view_count), + formattedCount: formatCompact(detail.data.view_count, lang), })} )} {detail.data?.published_at && ( - · {relativeTime(detail.data.published_at)} ·{" "} + · {relativeTime(detail.data.published_at, lang)} ·{" "} {fullDate(detail.data.published_at)} )} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index d9f14932..e92c73f5 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -160,7 +160,8 @@ export default function Playlists() { // The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the // list + its rail controls). This detail pane reacts to the selection. const { selectedId } = usePlaylists(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const plActions = usePlaylistActions(); const [renaming, setRenaming] = useState(false); @@ -391,8 +392,8 @@ export default function Playlists() { const filterActive = Object.values(filters).some(isActive); const shown = useMemo( - () => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy), - [items, columns, filters, viewSort, groupBy], + () => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy, lang), + [items, columns, filters, viewSort, groupBy, lang], ); // Read by the drag announcements, which must report the CURRENT positions without making the // announcement closures a render dependency of DndContext. @@ -909,7 +910,7 @@ export default function Playlists() {
{t("playlists.itemCount", { count: items.length })} - {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`} + {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`} {filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`} {(items.length > 1 || viewActive) && ( diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 5810eab2..ccc49e7f 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; +import { compareText } from "../lib/collate"; import { formatRuntime } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -59,7 +60,8 @@ export default function PlaylistsRail({ // box and the Playlists detail pane); this rail owns the list query + its own controls. const { search, selectedId } = usePlaylists(); const { setSelectedId } = usePlaylistsActions(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); const plSortOptions: SortOption[] = [ @@ -123,7 +125,7 @@ export default function PlaylistsRail({ if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; switch (plSort.key) { case "name": - return sign * plName(a).localeCompare(plName(b)); + return sign * compareText(plName(a), plName(b), lang); case "count": return sign * (a.item_count - b.item_count); case "duration": @@ -134,7 +136,7 @@ export default function PlaylistsRail({ return 0; } }); - }, [playlists, plSort, plName]); + }, [playlists, plSort, plName, lang]); const q = search.trim().toLowerCase(); const visiblePlaylists = useMemo( @@ -366,7 +368,7 @@ export default function PlaylistsRail({
{t("playlists.itemCount", { count: pl.item_count })} {pl.total_duration_seconds > 0 && - ` · ${formatRuntime(pl.total_duration_seconds)}`} + ` · ${formatRuntime(pl.total_duration_seconds, lang)}`}
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index c2c2cc53..b147c691 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -93,7 +93,7 @@ const BACKABLE_VIEWS: readonly string[] = [ ]; export default function PlexBrowse() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); // Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the // search term (q) comes from the shared header box; the rail hands off a playlist to open via the @@ -103,7 +103,7 @@ export default function PlexBrowse() { const dq = useDebounced(q.trim(), 350); // Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so // a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch. - const lang = plexLang(); + const lang = plexLang(i18n.language); // The language the grid query is allowed to run at — lags `lang` by one render on a switch so the // genre-clear effect (below) can settle first (see the effect for why). const [committedLang, setCommittedLang] = useState(lang); @@ -116,7 +116,7 @@ export default function PlexBrowse() { // The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only // key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort. const sortState = healPlexSort(sort, scope); - const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); + const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang); // Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field. const fieldState = healPlexField(field, scope); // Discover is the landing (E5 S4); the flat grid is the "Browse all" secondary view. A header search @@ -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). @@ -697,7 +701,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) => ( { vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + // the real instance: these tests switch the language with setLanguage + const real = (await import("i18next")).default; + return { + ...actual, + useTranslation: () => ({ i18n: real, t: (key: string) => key }), + }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); @@ -119,7 +125,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 +211,43 @@ 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"); + } + }); +}); + +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.textMore")).toBeNull(); // jsdom: nothing is cut + }); +}); diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 14e82b62..a3147178 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"; @@ -23,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"; @@ -66,7 +68,7 @@ export default function PlexCollectionView({ onRestartCard: (card: PlexCard) => void; onInfoCard: (card: PlexCard) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); // One menu for the member grid (see ui/ContextMenu). @@ -83,10 +85,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(i18n.language); + 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 @@ -96,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(""); @@ -116,7 +118,7 @@ export default function PlexCollectionView({ // what the add-panel searches and which sort keys are on offer (only a movie has a runtime). const scope = items.some((c) => c.type === "show") ? "show" : "movie"; const sortState = healPlexSort(sortPref, scope); - const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); + const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang); // Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so // Play-all expands it into its episodes (buildQueue). Either kind means there's something to play — // this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's @@ -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); }), @@ -262,7 +264,7 @@ export default function PlexCollectionView({ (d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`), d?.content_rating, d && t("plex.browse.items", { count: d.child_count ?? items.length }), - formatRuntime(d?.total_runtime_s), + formatRuntime(d?.total_runtime_s, lang), ].filter(Boolean) as string[]; return ( @@ -331,21 +333,13 @@ export default function PlexCollectionView({ )} {d.summary && ( -
-

- {d.summary} -

- -
+ )}
diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx index 70f7e906..c874d782 100644 --- a/frontend/src/components/PlexDiscover.dom.test.tsx +++ b/frontend/src/components/PlexDiscover.dom.test.tsx @@ -27,6 +27,7 @@ vi.mock("react-i18next", async (importOriginal) => { return { ...actual, useTranslation: () => ({ + i18n: { language: "en" }, t: (key: string, opts?: Record) => opts?.title ? `${key}:${opts.title}` : key, }), diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index c59800c6..55a79075 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -108,9 +108,9 @@ export default function PlexDiscover({ onGenre: (genre: string) => void; onSeeAllRecent: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); - const lang = plexLang(); + const lang = plexLang(i18n.language); // The active sidebar filters narrow every rail, so Discover matches the Browse grid. They're in the // query keys so a filter change refetches (and the watched-toggle setQueryData below keys the same). const { filters } = usePlex(); diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index 2ae35f1c..8554e15e 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -14,6 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => { return { ...actual, useTranslation: () => ({ + i18n: { language: "en" }, t: (key: string, opts?: Record) => opts?.name ? `${key}:${opts.name}` : key, }), @@ -64,7 +65,11 @@ const detail: PlexItemDetail = { { name: "Sam Writer", job: "Writer" }, ], studios: ["Studio A", "Studio B"], - production_countries: ["United States"], + // One coded (named in the reader's language), one from a row enriched before codes were stored. + production_countries: [ + { code: "HU", name: "Hungary" }, + { code: null, name: "United States" }, + ], recommendations: [], backdrops: [], }, @@ -93,7 +98,8 @@ describe("PlexInfo enriched detail (page)", () => { expect(screen.getByText("$160M")).toBeTruthy(); expect(screen.getByText("$830M")).toBeTruthy(); expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback - expect(screen.getByText("United States")).toBeTruthy(); + // E30: a coded country is named through the vocab (key-echo i18n here), an uncoded one as stored. + expect(screen.getByText("vocab.region.HU, United States")).toBeTruthy(); }); it("keyword chips filter the library on click", () => { @@ -127,6 +133,8 @@ describe("PlexInfo enriched detail (page)", () => { const onOpenPerson = vi.fn(); wrap(); expect(screen.getByText("plex.info.crew")).toBeTruthy(); + // E30: the job is TMDB's English word, named through the vocab (key-echo i18n here). + expect(screen.getByText("vocab.crewJob.Director")).toBeTruthy(); fireEvent.click(screen.getByText("Jane Director")); expect(onOpenPerson).toHaveBeenCalledWith("Jane Director"); }); diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index e4468862..de959932 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -4,12 +4,11 @@ import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api"; -import { formatRuntime } from "../lib/format"; +import { formatCompact, formatDecimal, formatMoney, formatRuntime } from "../lib/format"; import { notifyActionError } from "../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail"; import { usePref } from "../lib/usePref"; -import i18n from "../i18n"; import { Carousel } from "./ui/Carousel"; import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexPlaylistAdd from "./PlexPlaylistAdd"; @@ -28,6 +27,7 @@ import { PlexMenu } from "./PlexMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { usePlexCardActions } from "./usePlexCardActions"; import { toggleWatchState } from "../lib/plexWatch"; +import { regionName, vocab } from "../lib/vocab"; // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from @@ -37,24 +37,6 @@ import { toggleWatchState } from "../lib/plexWatch"; // The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew — E5 S5) render // on the page variant only; the overlay stays lean. -// Localize the grouping/compact notation to the active language (the app convention, lib/api/core.ts); -// `i18n.language` is read at call time, and the component re-renders on a language switch. Currency stays -// USD — TMDB budget/revenue figures are USD regardless of the viewer's language. -const compact = (n: number) => - new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n); -const money = (n: number) => - new Intl.NumberFormat(i18n.language, { - notation: "compact", - style: "currency", - currency: "USD", - // The minimum is load-bearing, not a default written out: given only a maximum, a compact - // format is free to pad to it. The very same options render "$160.0M" on Node 22 and "$160M" - // on Node 24 (measured 2026-09-20 — the server gate was red on exactly this while the local one - // was green), and browsers split along the same line. Pin it rather than inherit the engine's. - minimumFractionDigits: 0, - maximumFractionDigits: 1, - }).format(n); - // One rating chip's number: a filter button when this is the score the backend's ratingMin column // tracks, plain text otherwise. It lives at MODULE level, not inside RatingsRow, because a component // declared during render is a NEW component type on every render — React unmounts and remounts the @@ -69,7 +51,8 @@ function Score({ filterable: boolean; onFilterRating?: (score: number) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const shown = formatDecimal(score, i18n.language); return filterable && onFilterRating ? ( onFilterRating(Math.floor(score))} @@ -77,12 +60,12 @@ function Score({ title={t("plex.info.filterRating", { n: Math.floor(score) })} > - {score.toFixed(1)} + {shown} ) : ( - {score.toFixed(1)} + {shown} ); } @@ -109,7 +92,7 @@ function RatingsRow({ * theme's dark gold would sit on the player's black (2.6:1, measured). */ onDark?: boolean; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const primary = imdbRating ?? audience ?? null; // the filterable score // The source label is the chip's own colour at full strength: dimmed to 70% it fell under 4.5:1. const chipCls = `inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-semibold ${ @@ -146,7 +129,7 @@ function RatingsRow({ - TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""} + TMDb{has(tmdbVotes) ? ` · ${formatCompact(tmdbVotes, i18n.language)}` : ""} )} @@ -170,7 +153,8 @@ function FactsPanel({ detail: PlexItemDetail; onFilter?: (patch: Partial) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const tmdb = detail.tmdb; const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : []; const rows: { key: string; label: string; node: ReactNode }[] = []; @@ -194,7 +178,7 @@ function FactsPanel({ rows.push({ key: "runtime", label: t("plex.info.facts.runtime"), - node: formatRuntime(detail.duration_seconds), + node: formatRuntime(detail.duration_seconds, lang), }); const cert = detail.content_rating; if (cert) @@ -208,9 +192,17 @@ function FactsPanel({ ), }); if (tmdb?.budget) - rows.push({ key: "budget", label: t("plex.info.facts.budget"), node: money(tmdb.budget) }); + rows.push({ + key: "budget", + label: t("plex.info.facts.budget"), + node: formatMoney(tmdb.budget, lang), + }); if (tmdb?.revenue) - rows.push({ key: "revenue", label: t("plex.info.facts.revenue"), node: money(tmdb.revenue) }); + rows.push({ + key: "revenue", + label: t("plex.info.facts.revenue"), + node: formatMoney(tmdb.revenue, lang), + }); if (studios.length) rows.push({ key: "studios", @@ -233,7 +225,9 @@ function FactsPanel({ rows.push({ key: "countries", label: t("plex.info.facts.countries"), - node: tmdb.production_countries.join(", "), + node: tmdb.production_countries + .map((c) => regionName(t, c.code, i18n.language, c.name)) + .join(", "), }); if (rows.length === 0) return null; return ( @@ -309,7 +303,7 @@ function CrewPanel({ ) : (
{c.name}
)} -
{c.job}
+
{vocab(t, "crewJob", c.job)}
))}
@@ -369,7 +363,8 @@ export default function PlexInfo({ onPlayItem, library, }: Props) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); // Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs: @@ -598,7 +593,7 @@ export default function PlexInfo({ )} {detail.duration_seconds ? ( - {formatRuntime(detail.duration_seconds)} + {formatRuntime(detail.duration_seconds, lang)} ) : null} {detail.studio && ( { @@ -24,7 +25,12 @@ beforeAll(() => { vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + // the real instance: these tests switch the language with setLanguage + const real = (await import("i18next")).default; + return { + ...actual, + useTranslation: () => ({ i18n: real, t: (key: string) => key }), + }; }); const plexPerson = vi.fn(); @@ -112,9 +118,11 @@ describe("PlexPersonView", () => { expect(screen.getByText("plex.person.shows")).toBeTruthy(); // E5 S6 bio hero: department, biography, birth facts, aka. - expect(screen.getByText("Acting")).toBeTruthy(); + // E30: TMDB's English department, named through the vocab (key-echo i18n here). + expect(screen.getByText("vocab.department.Acting")).toBeTruthy(); expect(screen.getByText("A celebrated actor.")).toBeTruthy(); - expect(screen.getByText(/1970-01-01/)).toBeTruthy(); + // E30 S3: the birth date as a date in the UI language, not the raw ISO string + expect(screen.getByText("Jan 1, 1970 · Springfield")).toBeTruthy(); expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy(); expect(screen.getByText(/Janie D/)).toBeTruthy(); // "Known for" rail renders its card. @@ -123,7 +131,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 +156,203 @@ 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"); + } + }); +}); + +const BIO_POLL = 1600; // just past plexViews' BIO_POLL_MS + +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("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, and says so with a retry", 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(); + // Not a blank that reads as "no biography": the failure is said, and a retry asks again. + const alert = screen.getByRole("alert"); + expect(alert.textContent).toContain("plex.person.bioFailed"); + plexPerson.mockResolvedValue(person({ biography: "Landed bio." })); + fireEvent.click(within(alert).getByRole("button")); + await screen.findByText("Landed bio."); + } finally { + vi.useRealTimers(); + } + }); + + 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); + try { + plexPerson.mockReset(); + plexPerson.mockResolvedValue(person({ biography: "A long life.\n\nMany films." })); + wrap(view); + fireEvent.click(await screen.findByText("plex.textMore")); + expect(screen.getByText("plex.textLess").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.textMore")).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/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 46d691a0..837d2563 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -27,7 +27,7 @@ import { Volume2, VolumeX, } from "lucide-react"; -import { api, HttpError } from "../lib/api"; +import { api, HttpError, type PlexTrackNaming } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { audioOrdForLang, @@ -88,6 +88,7 @@ import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { Slider } from "./ui/NativeControls"; import { formatUnit } from "../lib/format"; +import { trackLabel } from "../lib/vocab"; // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. const PlexInfo = lazy(() => import("./PlexInfo")); @@ -131,17 +132,20 @@ const DOUBLE_TAP_MS = 300; type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart?: boolean }; export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); // `t` changes identity on a language switch. loadSession only uses it for error strings, so read it // through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change // rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume // position (losing live progress). See the detail effect's deps. const tRef = useRef(t); tRef.current = t; + // E30: a track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)"). + const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language); + const subtitleName = (x: PlexTrackNaming) => trackLabel(t, x, "subtitle", i18n.language); const qc = useQueryClient(); const [id, setId] = useState(itemId); // Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid. - const lang = plexLang(); + const lang = plexLang(i18n.language); const detailQ = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang), @@ -982,7 +986,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } seekTo(playhead(absRef, loadStartRef) + zone * TAP_SKIP_S); flashOsd( t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", { - d: formatUnit(skips * TAP_SKIP_S, "second"), + d: formatUnit(skips * TAP_SKIP_S, "second", lang), }), ); return; @@ -995,7 +999,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } } toggleControls(); }, - [togglePlay, showControls, toggleControls, seekTo, flashOsd, t, uiVisible], + [togglePlay, showControls, toggleControls, seekTo, flashOsd, t, lang, uiVisible], ); // Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the @@ -1238,16 +1242,21 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } audio: () => { const ord = cycleAudio(); if (ord === undefined) return; // one track (or none): nothing to say - const label = detail?.audio_streams.find((a) => a.ord === (ord ?? 0))?.label; - flashOsd(`${t("plex.player.audio")} · ${label ?? "—"}`); + const track = detail?.audio_streams.find((a) => a.ord === (ord ?? 0)); + flashOsd( + `${t("plex.player.audio")} · ${track ? trackLabel(t, track, "audio", i18n.language) : "—"}`, + ); }, subtitle: () => { const ord = cycleSubtitle(); if (ord === undefined) return; + const track = ord == null ? null : detail?.subtitle_streams.find((x) => x.ord === ord); const label = ord == null ? t("plex.player.subOff") - : (detail?.subtitle_streams.find((x) => x.ord === ord)?.label ?? "—"); + : track + ? trackLabel(t, track, "subtitle", i18n.language) + : "—"; flashOsd(`${t("plex.player.subtitles")} · ${label}`); }, info: () => setInfoOpen((v) => !v), @@ -1309,6 +1318,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } cycleSubtitle, flashOsd, t, + i18n.language, detail, prevId, nextId, @@ -1336,6 +1346,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0; // While scrubbing the head follows the finger (preview); otherwise it tracks playback. const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct; + // The tracks playing now, for the stats overlay. + const currentAudio = detail?.audio_streams.find((x) => x.ord === (audioOrd ?? 0)); + const currentSub = detail?.subtitle_streams.find((x) => x.ord === subOrd); // Portaled to , like every other full-screen overlay here (Modal, PlayerModal, ChatDock, // BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the @@ -1412,7 +1425,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } kind="subtitles" src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)} srcLang={selectedSub.language ?? undefined} - label={selectedSub.label} + label={subtitleName(selectedSub)} {...subTrackListeners} default /> @@ -1466,7 +1479,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } ) : ( <> - {t("plex.player.subPreparing", { label: selectedSub.label })} + {t("plex.player.subPreparing", { label: subtitleName(selectedSub) })} )} @@ -1783,14 +1796,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } tone="media" role="menuitemradio" checked={(audioOrd ?? 0) === a.ord} - label={a.label} + label={audioName(a)} onSelect={() => changeAudio(a.ord)} className={`${TRACK_ROW} ${ (audioOrd ?? 0) === a.ord ? "text-accent" : "text-white" }`} > - {a.label} + {audioName(a)} ))} @@ -1815,14 +1828,14 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } tone="media" role="menuitemradio" checked={subOrd === sub.ord} - label={sub.label} + label={subtitleName(sub)} onSelect={() => changeSubtitle(sub.ord)} className={`${TRACK_ROW} ${ subOrd === sub.ord ? "text-accent" : "text-white" }`} > - {sub.label} + {subtitleName(sub)} ))} @@ -1896,13 +1909,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } videoRef={videoRef} hlsRef={hlsRef} fragMeterRef={fragMeterRef} - audioLabel={ - detail?.audio_streams.find((a) => a.ord === (audioOrd ?? 0))?.label ?? undefined - } + audioLabel={currentAudio ? audioName(currentAudio) : undefined} subLabel={ subOrd == null ? t("plex.player.subOff") - : (detail?.subtitle_streams.find((x) => x.ord === subOrd)?.label ?? undefined) + : currentSub + ? subtitleName(currentSub) + : undefined } playable={detail?.playable ?? ""} hwRef={hwRef} diff --git a/frontend/src/components/PlexPlayerSettings.dom.test.tsx b/frontend/src/components/PlexPlayerSettings.dom.test.tsx index a1403f57..37289ca8 100644 --- a/frontend/src/components/PlexPlayerSettings.dom.test.tsx +++ b/frontend/src/components/PlexPlayerSettings.dom.test.tsx @@ -10,7 +10,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), - useTranslation: () => ({ t: (k: string) => k }), + useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }), })); import { PlexPlayerSettings } from "./PlexPlayerSettings"; diff --git a/frontend/src/components/PlexPlayerSettings.tsx b/frontend/src/components/PlexPlayerSettings.tsx index 82fc8a43..81047446 100644 --- a/frontend/src/components/PlexPlayerSettings.tsx +++ b/frontend/src/components/PlexPlayerSettings.tsx @@ -84,7 +84,7 @@ export function PlexPlayerSettings(props: { min={-10} max={10} step={0.1} - suffix="s" + seconds onChange={(v) => patchPrefs({ subOffset: v })} onReset={() => patchPrefs({ subOffset: 0 })} /> @@ -94,7 +94,7 @@ export function PlexPlayerSettings(props: { min={-5} max={5} step={0.1} - suffix="s" + seconds onChange={changeAudioOffset} onReset={() => changeAudioOffset(0)} /> @@ -109,7 +109,7 @@ export function PlexPlayerSettings(props: { min={5} max={30} step={1} - suffix="s" + seconds onChange={(v) => patchPrefs({ seekStep: v })} /> patchPrefs({ fineSeekStep: v })} /> patchPrefs({ autoSkipDelay: v })} /> )} 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..5f94722f 100644 --- a/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx +++ b/frontend/src/components/PlexPlaylistView.menu.dom.test.tsx @@ -4,17 +4,21 @@ // 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"; -vi.mock("react-i18next", async (importOriginal) => ({ - ...(await importOriginal()), - useTranslation: () => ({ - t: (key: string, o?: { title?: string }) => (o?.title ? `${key}:${o.title}` : key), - i18n: { language: "en" }, - }), -})); +vi.mock("react-i18next", async (importOriginal) => { + const real = (await import("i18next")).default; + return { + ...(await importOriginal()), + useTranslation: () => ({ + t: (key: string, o?: { title?: string }) => (o?.title ? `${key}:${o.title}` : key), + i18n: real, + }), + }; +}); const h = vi.hoisted(() => ({ notify: vi.fn(), confirm: vi.fn(async () => true) })); vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), @@ -202,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"]', )!; @@ -346,3 +350,25 @@ 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"); + } + }); +}); + +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 d4771e42..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 }) { @@ -406,7 +405,8 @@ export default function PlexPlaylistView({ /** A title's media-info page, from its menu's "Details". */ onInfoCard?: (card: PlexCard) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); const [renaming, setRenaming] = useState(false); @@ -755,7 +755,7 @@ export default function PlexPlaylistView({ {items.length > 0 && (
{t("plex.browse.items", { count: items.length })} - {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`} + {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
)} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 0a1630a2..fb3e83ef 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -44,7 +44,7 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[ ]; export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); // Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and // the header search box); this rail is the primary editor of them. Opening a playlist is a // hand-off to PlexBrowse via the shared `playlistOpen` slot. @@ -60,7 +60,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl // Titles are display-only (chip labels) — keep them OUT of the facet query key so renaming/relabelling // never refetches. collectionMode stays IN the key: any/all changes the result set. const { collectionTitles: _cts, ...facetKey } = filters; - const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch + const lang = plexLang(i18n.language); // HU/EN genre vocabulary — refetch facets on a language switch const facetsQ = useQuery({ queryKey: qk.plexFacets(scope, show, facetKey, lang), queryFn: () => api.plexFacets(scope, filters, show, lang), diff --git a/frontend/src/components/PlexStatsOverlay.tsx b/frontend/src/components/PlexStatsOverlay.tsx index 24e33974..369889a8 100644 --- a/frontend/src/components/PlexStatsOverlay.tsx +++ b/frontend/src/components/PlexStatsOverlay.tsx @@ -38,7 +38,8 @@ export function PlexStatsOverlay({ durationRef: RefObject; onClose: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const [rows, setRows] = useState([]); useEffect(() => { @@ -87,6 +88,7 @@ export function PlexStatsOverlay({ setRows( buildStatRows({ + lang, playable, hwEncoder: hwRef.current, tonemap: tonemapRef.current, @@ -110,6 +112,7 @@ export function PlexStatsOverlay({ const iv = setInterval(sample, 1000); return () => clearInterval(iv); }, [ + lang, videoRef, hlsRef, fragMeterRef, diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index 4c116d36..af963438 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -6,6 +6,7 @@ import { Lock, Pencil, Plus, Trash2 } from "lucide-react"; import Modal from "./ui/Modal"; import { useConfirm } from "./confirmContext"; import { api, type DownloadProfile, type DownloadSpec } from "../lib/api"; +import { profileName } from "../lib/vocab"; import { Button } from "./ui/Button"; import SegmentedToggle from "./ui/SegmentedToggle"; import FilterSelect from "./ui/FilterSelect"; @@ -118,7 +119,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) { {builtins.map((p) => (
- {p.name} + {profileName(t, p)}
))} {mine.length > 0 && ( diff --git a/frontend/src/components/Scheduler.dom.test.tsx b/frontend/src/components/Scheduler.dom.test.tsx index 92e85b7c..4ec5142c 100644 --- a/frontend/src/components/Scheduler.dom.test.tsx +++ b/frontend/src/components/Scheduler.dom.test.tsx @@ -11,7 +11,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; + return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) }; }); import Scheduler from "./Scheduler"; @@ -32,6 +32,7 @@ const status = (over: Partial = {}): SchedulerStatus => ({ last_started: null, last_finished: null, last_result: null, + result: null, last_error: null, progress: null, }, @@ -258,3 +259,25 @@ describe("Scheduler while paused (E28 audit F13)", () => { expect(runOne).not.toHaveBeenCalled(); }); }); + +describe("a job's last result (E30)", () => { + it("is named from its data, not the server's English one-liner", async () => { + const base = status(); + await mount({ + ...base, + jobs: [ + { + ...base.jobs[0]!, + last_finished: new Date().toISOString(), + last_result: "new=3, failed=0", + result: { new: 3, failed: 0 }, + }, + ], + }); + // Key-echo i18n: the keys named, the counts shown. + expect( + await screen.findByText(/scheduler\.result\.new: 3 · scheduler\.result\.failed: 0/), + ).toBeTruthy(); + expect(screen.queryByText(/new=3/)).toBeNull(); + }); +}); diff --git a/frontend/src/components/Scheduler.menu.dom.test.tsx b/frontend/src/components/Scheduler.menu.dom.test.tsx index 2207ef19..cb8e0f26 100644 --- a/frontend/src/components/Scheduler.menu.dom.test.tsx +++ b/frontend/src/components/Scheduler.menu.dom.test.tsx @@ -9,7 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const h = vi.hoisted(() => ({ confirm: vi.fn(async () => true) })); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; + return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) }; }); vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm })); @@ -30,6 +30,7 @@ const status = (over: Partial = {}): SchedulerStatus => ({ last_started: null, last_finished: null, last_result: null, + result: null, last_error: null, progress: null, }, diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index d7a35af5..2adeeecc 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -1,7 +1,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; -import i18n from "../i18n"; +import type { TFunction } from "i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, @@ -35,6 +35,7 @@ import { useContextMenu } from "./ui/useContextMenu"; import { ContextMenu } from "./ui/ContextMenu"; import { MoreButton } from "./ui/MoreButton"; import { type ActionMenuItem } from "./ui/ActionMenu"; +import { jobResultText } from "../lib/jobResult"; const POLL_MS = 4000; // While any job is running, poll faster so short scheduled runs aren't missed between ticks. @@ -46,14 +47,14 @@ function secsUntil(iso: string | null): number | null { return Math.round((new Date(iso).getTime() - Date.now()) / 1000); } -function fmtCountdown(s: number): string { - if (s <= 0) return i18n.t("time.eta.now"); +function fmtCountdown(t: TFunction, s: number, lang: string): string { + if (s <= 0) return t("time.eta.now"); // The countdown speaks the UI language's units (DESIGN Copy), like every other duration. - if (s < 60) return formatUnit(s, "second"); + if (s < 60) return formatUnit(s, "second", lang); const m = Math.floor(s / 60); - if (m < 60) return `${formatUnit(m, "minute")} ${formatUnit(s % 60, "second")}`; + if (m < 60) return `${formatUnit(m, "minute", lang)} ${formatUnit(s % 60, "second", lang)}`; const h = Math.floor(m / 60); - return `${formatUnit(h, "hour")} ${formatUnit(m % 60, "minute")}`; + return `${formatUnit(h, "hour", lang)} ${formatUnit(m % 60, "minute", lang)}`; } const DOT_CLASS: Record = { @@ -103,7 +104,8 @@ function StatusLegend() { // label and an indeterminate sliver — so every active run is visible, not just the ones // that report numbers. function JobProgress({ p }: { p: SchedulerJob["progress"] }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const phase = p?.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : t("scheduler.phase.working"); const pct = p?.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null; @@ -113,9 +115,9 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) { {phase} {p?.total != null - ? `${formatNumber(p.current)} / ${formatNumber(p.total)}` + ? `${formatNumber(p.current, lang)} / ${formatNumber(p.total, lang)}` : p - ? formatNumber(p.current) + ? formatNumber(p.current, lang) : ""} @@ -151,7 +153,8 @@ function JobRow({ /** A run-all in flight: transient, natively disabled. */ runBusy: boolean; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; // The box shows the server's value and holds a draft only while it is being edited, so there is // nothing for an effect to re-sync: `null` means "follow the job". The poll behind this page // refreshes every few seconds, which is exactly when a mirror-plus-effect would have shown the @@ -246,15 +249,17 @@ function JobRow({
{job.running ? t("scheduler.runningNow") : job.last_finished - ? t("scheduler.lastRun", { time: relativeTime(job.last_finished) }) + ? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) }) : t("scheduler.neverRun")} {job.last_error ? ( · {job.last_error} + ) : job.result ? ( + · {jobResultText(t, job.result, lang)} ) : job.last_result ? ( · {job.last_result} ) : null} @@ -267,7 +272,7 @@ function JobRow({ ) : next != null ? ( <>
{t("scheduler.nextRun")}
-
{fmtCountdown(next)}
+
{fmtCountdown(t, next, lang)}
) : ( "—" @@ -300,7 +305,8 @@ function MaintenanceSettings({ onSave: (batch: number) => void; saving: boolean; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; // Same shape as the job row above: a draft while editing, the server's value otherwise. This is // the editor a poll really could overwrite mid-keystroke before E27 S2 — its old effect had no // `editing` guard at all. @@ -360,7 +366,7 @@ function MaintenanceSettings({ )}
{t("scheduler.maintenance.batchNote", { - default: formatNumber(m.revalidate_batch_default), + default: formatNumber(m.revalidate_batch_default, lang), })}
@@ -403,7 +409,8 @@ function Stat({ } export default function Scheduler() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); // Poll faster while any job is running so live progress is smooth, then ease back when @@ -644,31 +651,31 @@ export default function Scheduler() { icon={Database} label={t("scheduler.queue.recentPending")} hint={t("scheduler.queue.recentPendingHint")} - value={formatNumber(data.queue.channels_recent_pending)} + value={formatNumber(data.queue.channels_recent_pending, lang)} /> @@ -681,15 +688,15 @@ export default function Scheduler() { {t("scheduler.quotaUsed", { - used: formatNumber(data.quota.used_today), - budget: formatNumber(data.quota.daily_budget), + used: formatNumber(data.quota.used_today, lang), + budget: formatNumber(data.quota.daily_budget, lang), })} {t("scheduler.quotaRemaining", { count: data.quota.remaining_today, - formatted: formatNumber(data.quota.remaining_today), + formatted: formatNumber(data.quota.remaining_today, lang), })} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index c757bd6a..4fa8ee34 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { withActiveAccount } from "../lib/activeAccount"; import { qk } from "../lib/queryKeys"; -import { formatDate, formatDateTime } from "../lib/format"; +import { formatDate, formatDateTime, formatUnit } from "../lib/format"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react"; @@ -245,7 +245,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) { } function Notifications({ prefs }: { prefs: PrefsController }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; // Controlled by PrefsProvider (live preview via configureNotifications there), which auto-saves // it. The "send test" button below fires immediately. const { notif: cfg, setNotif } = prefs; @@ -270,7 +271,9 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
{t("settings.notifications.dismissAfter")} - {(cfg.durationMs / 1000).toFixed(0)}s + + {formatUnit(Math.round(cfg.durationMs / 1000), "second", lang)} +
api.plexWatchLink() }); const enabled = link.data?.sync_enabled ?? false; @@ -565,7 +569,9 @@ function PlexWatchSync() { // Reflect the toggle's target immediately (optimistic) so the switch responds on click rather than // only flipping once the multi-second import returns — otherwise it reads as "nothing happened". const shown = toggle.isPending ? Boolean(toggle.variables) : enabled; - const last = link.data?.last_watch_sync_at ? formatDateTime(link.data.last_watch_sync_at) : null; + const last = link.data?.last_watch_sync_at + ? formatDateTime(link.data.last_watch_sync_at, lang) + : null; return (
@@ -765,7 +771,8 @@ export function DownloadAuth() { /** Who is signed in: name, address, role — and the latest sign-in on any device, so one the user does * not recognise is something they can see. */ export function AccountHeader({ me }: { me: Me }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; return (
@@ -782,7 +789,7 @@ export function AccountHeader({ me }: { me: Me }) { {!me.is_demo && (
{me.last_login_at - ? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at) }) + ? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) }) : t("settings.account.lastLoginNone")}
)} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index cdfe128b..1de2d9a6 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -21,6 +21,8 @@ import MatchToggle from "./ui/MatchToggle"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useChannelsActions } from "./channelsContext"; import { useMe } from "../lib/useMe"; +import { useTags } from "../lib/useTags"; +import { compareText } from "../lib/collate"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ @@ -46,14 +48,15 @@ export default function Sidebar({ collapsed: boolean; onToggleCollapse: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const isDemo = useMe().is_demo; const { openModal } = useSavedViews(); const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); // "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider. const { focusChannel: onFocusChannel } = useChannelsActions(); - const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); + const tagsQuery = useTags(); const tags = tagsQuery.data ?? []; // Live per-tag channel counts for the current filter context. Debounce the search term so @@ -75,7 +78,9 @@ export default function Sidebar({ const shown = facetsReady ? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id)) : list; - return [...shown].sort((a, b) => chipCount(b) - chipCount(a) || a.name.localeCompare(b.name)); + return [...shown].sort( + (a, b) => chipCount(b) - chipCount(a) || compareText(a.name, b.name, lang), + ); }; const languages = tags.filter((t) => t.category === "language"); diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 9e9e4049..ab825c4d 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -66,7 +66,8 @@ export default function Stats() { // Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions. function Overview({ me }: { me: Me }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); // The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here. const { openWizard } = useWizard(); @@ -125,14 +126,14 @@ function Overview({ me }: { me: Me }) { label={t("stats.sync.fullHistoryEta")} hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })} > - {formatEta(s.deep_eta_seconds)} + {formatEta(t, s.deep_eta_seconds)} )} - {formatNumber(s.my_videos)} + {formatNumber(s.my_videos, lang)} - {formatNumber(s.quota_remaining_today)} + {formatNumber(s.quota_remaining_today, lang)}
) : status.isError ? ( @@ -149,16 +150,16 @@ function Overview({ me }: { me: Me }) { <>
- {formatNumber(usage.data.today)} + {formatNumber(usage.data.today, lang)} - {formatNumber(usage.data.last_7d)} + {formatNumber(usage.data.last_7d, lang)} - {formatNumber(usage.data.last_30d)} + {formatNumber(usage.data.last_30d, lang)} - {formatNumber(usage.data.all_time)} + {formatNumber(usage.data.all_time, lang)}
{Object.keys(usage.data.by_action).length > 0 && ( @@ -168,8 +169,8 @@ function Overview({ me }: { me: Me }) { .sort((a, b) => b[1] - a[1]) .map(([action, units]) => (
- {quotaActionLabel(action)} - {formatNumber(units)} + {quotaActionLabel(t, action)} + {formatNumber(units, lang)}
))}
@@ -218,7 +219,8 @@ function Overview({ me }: { me: Me }) { // Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle. function SystemStats() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [days, setDays] = useState(30); const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) }); @@ -263,7 +265,7 @@ function SystemStats() { className="flex items-center gap-1.5" options={RANGES.map((d) => ({ value: String(d), - label: t("stats.rangeDays", { d: formatUnit(d, "day") }), + label: t("stats.rangeDays", { d: formatUnit(d, "day", lang) }), }))} value={String(days)} onChange={(v) => setDays(Number(v))} @@ -290,8 +292,8 @@ function SystemStats() {
{t("stats.dailyTotal", { - d: formatUnit(data.range_days, "day"), - units: formatNumber(grandTotal), + d: formatUnit(data.range_days, "day", lang), + units: formatNumber(grandTotal, lang), })}
{data.daily.length === 0 ? ( @@ -304,7 +306,10 @@ function SystemStats() { // The day under the pointer is lit by the B3 layer over its whole column, not by // dimming the bar: an opacity dim reads as "disabled". className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer" - title={t("stats.dailyTooltip", { day: d.day, units: formatNumber(d.total) })} + title={t("stats.dailyTooltip", { + day: d.day, + units: formatNumber(d.total, lang), + })} >
b[1] - a[1]); const isSystem = row.user_id === null; @@ -349,7 +355,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) { {isSystem ? t("stats.system") : row.email} - {formatNumber(row.total)} + {formatNumber(row.total, lang)} {/* Proportion bar */} @@ -363,8 +369,8 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
{actions.map(([action, units]) => (
- {quotaActionLabel(action)} - {formatNumber(units)} + {quotaActionLabel(t, action)} + {formatNumber(units, lang)}
))}
diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 0ddc4d4d..19c323e9 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react"; import { api, type MyStatus } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; -import { formatViews } from "../lib/format"; +import { formatCompact } from "../lib/format"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { Button } from "./ui/Button"; @@ -23,7 +23,8 @@ export default function SyncStatus({ isAdmin: boolean; onGoToFullHistory: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [open, setOpen] = useState(false); // A read-out with a couple of controls rather than a list of choices, so it is a `dialog` @@ -114,8 +115,9 @@ export default function SyncStatus({ // popover. An accent dot flags the only two things worth interrupting you for (paused / missing // history) so the collapsed chip still tells you when to look. const needsAttention = data.paused || notFull > 0; - const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews( + const countsText = `${formatCompact(data.my_videos, lang)} ${t("header.sync.yours")} / ${formatCompact( data.total_videos, + lang, )} ${t("header.sync.total")}`; return (
@@ -132,7 +134,9 @@ export default function SyncStatus({ ) : ( )} - {formatViews(data.my_videos)} + + {formatCompact(data.my_videos, lang)} + {needsAttention && ( )} @@ -149,10 +153,10 @@ export default function SyncStatus({ > - {formatViews(data.my_videos)}{" "} + {formatCompact(data.my_videos, lang)}{" "} {t("header.sync.yours")} / - {formatViews(data.total_videos)} {t("header.sync.total")} + {formatCompact(data.total_videos, lang)} {t("header.sync.total")}
{showMain && ( diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx index 8aa66f78..37e136ec 100644 --- a/frontend/src/components/TagManager.tsx +++ b/frontend/src/components/TagManager.tsx @@ -16,6 +16,7 @@ import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { Input } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; +import { useTags } from "../lib/useTags"; // One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a // hover popover on the count listing the tagged channels (each a link that focuses it in the @@ -112,7 +113,7 @@ export default function TagManager({ const confirm = useConfirm(); const listFade = useScrollFade(); const [newName, setNewName] = useState(""); - const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); + const tagsQuery = useTags(); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels }); const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const channelsForTag = (tagId: number) => diff --git a/frontend/src/components/UpNextCard.dom.test.tsx b/frontend/src/components/UpNextCard.dom.test.tsx index f7f6bab5..bea3686a 100644 --- a/frontend/src/components/UpNextCard.dom.test.tsx +++ b/frontend/src/components/UpNextCard.dom.test.tsx @@ -11,7 +11,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); vi.mock("../lib/api", () => ({ diff --git a/frontend/src/components/UpNextCard.tsx b/frontend/src/components/UpNextCard.tsx index 5a72b342..92178c4a 100644 --- a/frontend/src/components/UpNextCard.tsx +++ b/frontend/src/components/UpNextCard.tsx @@ -23,8 +23,8 @@ export function UpNextCard({ onAdvance: () => void; onCancel: () => void; }) { - const { t } = useTranslation(); - const lang = plexLang(); + const { t, i18n } = useTranslation(); + const lang = plexLang(i18n.language); const nextQ = useQuery({ queryKey: qk.plexItem(nextId, lang), queryFn: () => api.plexItem(nextId, lang), diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx index a02de9b8..61a085f7 100644 --- a/frontend/src/components/UploadDialog.dom.test.tsx +++ b/frontend/src/components/UploadDialog.dom.test.tsx @@ -19,7 +19,10 @@ const h = vi.hoisted(() => ({ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); vi.mock("../lib/api", () => ({ api: { uploadDownload: h.uploadDownload, downloadUsage: h.downloadUsage }, diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index 009ac56f..22743bf3 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -21,7 +21,8 @@ import { Input } from "./ui/Input"; // file is streamed to the server via XHR so this dialog can show real upload progress. On success it // lands in the Library as an already-`done` job, so we invalidate + point the user there. export default function UploadDialog({ onClose }: { onClose: () => void }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const qc = useQueryClient(); const [file, setFile] = useState(null); const [name, setName] = useState(""); @@ -148,7 +149,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) { <> {file.name} - {formatBytes(file.size)} + {formatBytes(file.size, lang)} ) : ( <> @@ -172,7 +173,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) { {tooBig && ( )} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index a5887197..c1e4f774 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -17,7 +17,13 @@ import { navItemAttrs } from "../lib/listNav"; import clsx from "clsx"; import type { Video, VideoStatus } from "../lib/api"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; -import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format"; +import { + formatDate, + formatDuration, + formatNumber, + formatCompact, + relativeTime, +} from "../lib/format"; import { isPlainLeftClick } from "../lib/nav"; import { endsTextSelection } from "../lib/textSelection"; import ChannelNameLink from "./ChannelNameLink"; @@ -391,6 +397,7 @@ function VideoCard({ shelf?: boolean; }) { const { t, i18n } = useTranslation(); + const lang = i18n.language; const { openChannel } = useNavigationActions(); const spec = FEED_VIEW_SPEC[view]; const watched = video.status === "watched"; @@ -405,14 +412,14 @@ function VideoCard({ // on every line and the tooltip carries the figure regardless. const viewsCell = (withWord: boolean) => video.view_count != null && ( - - {formatViews(video.view_count)} + + {formatCompact(video.view_count, lang)} {withWord && ` ${t("card.views")}`} ); const published = ( <> - {relativeTime(video.published_at)} + {relativeTime(video.published_at, lang)} {video.published_at && ( <> {" · "} diff --git a/frontend/src/components/VideoInfoCard.tsx b/frontend/src/components/VideoInfoCard.tsx index ab01d41a..ddc74a63 100644 --- a/frontend/src/components/VideoInfoCard.tsx +++ b/frontend/src/components/VideoInfoCard.tsx @@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api, type Video } from "../lib/api"; -import { formatDate, formatDuration, formatViews } from "../lib/format"; +import { formatDate, formatDuration, formatCompact } from "../lib/format"; import { linkify } from "../lib/linkify"; import Avatar from "./ui/Avatar"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; +import { useTags } from "../lib/useTags"; // A video's details (E8·S3, decision D3): full (un-clamped) title, channel, views·duration·date, a // description snippet, and the channel's tags. E28 C1 moved it from a hover ⓘ on the thumbnail into @@ -33,6 +34,7 @@ export default function VideoDetailsAt({ onClose: () => void; }) { const { t, i18n } = useTranslation(); + const lang = i18n.language; const pop = useAnchoredPopover({ open: true, onOpenChange: (next) => { @@ -58,11 +60,7 @@ export default function VideoDetailsAt({ enabled: !!video.channel_id, staleTime: 5 * 60_000, }); - const { data: tags } = useQuery({ - queryKey: qk.tags(), - queryFn: () => api.tags(), - staleTime: 5 * 60_000, - }); + const { data: tags } = useTags({ staleTime: 5 * 60_000 }); // The channel's tags (`tag_ids`), in the channel's own id order, resolved to names via the tag list. // These are the channel-tags the channel actually carries; an unknown id (a just-deleted tag) drops. @@ -75,7 +73,7 @@ export default function VideoDetailsAt({ const description = detail?.description?.trim(); const metaBits = [ - video.view_count != null ? `${formatViews(video.view_count)} ${t("card.views")}` : null, + video.view_count != null ? `${formatCompact(video.view_count, lang)} ${t("card.views")}` : null, video.duration_seconds != null ? formatDuration(video.duration_seconds) : null, video.published_at ? formatDate(video.published_at, i18n.language) : null, ].filter(Boolean); diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx index 3173d002..47e87f30 100644 --- a/frontend/src/components/WatchPage.tsx +++ b/frontend/src/components/WatchPage.tsx @@ -317,7 +317,7 @@ export default function WatchPage() { {meta.title || T.file}
- {[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)] + {[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)] .filter(Boolean) .join(" · ")}
diff --git a/frontend/src/components/adminInvites.dom.test.tsx b/frontend/src/components/adminInvites.dom.test.tsx index fc3454fe..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" }, }), }; @@ -162,6 +164,9 @@ describe("Access requests — the delete affordance", () => { await openAccessTab(); fireEvent.click(await screen.findByText(/settings\.invites\.decided/)); + // E30: the decision is named, not the raw status word ("denied"). + expect(screen.getByText("settings.invites.status.denied")).toBeTruthy(); + expect(screen.queryByText("denied")).toBeNull(); fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com")); await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(1)); @@ -312,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 new file mode 100644 index 00000000..ebea3d16 --- /dev/null +++ b/frontend/src/components/auditColumns.dom.test.tsx @@ -0,0 +1,154 @@ +// @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("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 | 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(""); + }); + + 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..d9181cb6 100644 --- a/frontend/src/components/auditColumns.tsx +++ b/frontend/src/components/auditColumns.tsx @@ -1,5 +1,8 @@ import type { TFunction } from "i18next"; import type { AuditRow } from "../lib/api"; +import { formatNumber } from "../lib/format"; +import type { JobResult } from "../lib/api"; +import { jobResultText, jobRunText } from "../lib/jobResult"; import type { Column } from "./ui/DataTable"; import { RelativeTime } from "./ui/RelativeTime"; @@ -12,6 +15,74 @@ 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, 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, 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, + 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 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 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(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; +} + /** 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 +103,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 +144,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/components/channelCells.tsx b/frontend/src/components/channelCells.tsx index 88381aee..8e5dd932 100644 --- a/frontend/src/components/channelCells.tsx +++ b/frontend/src/components/channelCells.tsx @@ -12,7 +12,8 @@ import { isFullySynced } from "./channelSync"; // full history are both in — folding the retired Sync column's at-a-glance status into the value it // describes. Full detail stays in the hover tooltip. export function StoredCell({ c }: { c: ManagedChannel }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; return ( {isFullySynced(c) && ( @@ -24,7 +25,7 @@ export function StoredCell({ c }: { c: ManagedChannel }) { /> )} - {formatNumber(c.stored_videos)} + {formatNumber(c.stored_videos, lang)} ); } diff --git a/frontend/src/components/channelColumns.dom.test.tsx b/frontend/src/components/channelColumns.dom.test.tsx index 7e96ad00..3128b403 100644 --- a/frontend/src/components/channelColumns.dom.test.tsx +++ b/frontend/src/components/channelColumns.dom.test.tsx @@ -78,6 +78,7 @@ describe("buildChannelColumns — the row's end", () => { const noop = () => {}; const cols = buildChannelColumns({ t: ((k: string) => k) as never, + lang: "en", prioOptions: [], userTags: [], bumpPriority: noop, diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index de5d3939..35a5f466 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -14,7 +14,10 @@ import { RelativeTime } from "./ui/RelativeTime"; /** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical * in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different * channel row shapes. */ -export function subsColumn(t: TFunction): Column { +export function subsColumn( + t: TFunction, + lang: string, +): Column { return { key: "subs", header: t("channels.cols.subs"), @@ -23,7 +26,7 @@ export function subsColumn(t: TFu sortable: true, sortValue: (c) => c.subscriber_count ?? -1, render: (c) => ( - {formatCountOrDash(c.subscriber_count)} + {formatCountOrDash(c.subscriber_count, lang)} ), }; } @@ -50,6 +53,8 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean { * column block lives outside the page component (its cell components are in channelCells). */ export interface ChannelColumnDeps { t: TFunction; + /** The UI language, for the numbers — the compiler re-builds the columns when it changes. */ + lang: string; prioOptions: { value: string; label: string }[]; userTags: ComponentProps["userTags"]; bumpPriority: (id: string, delta: number) => void; @@ -69,6 +74,7 @@ export interface ChannelColumnDeps { export function buildChannelColumns(deps: ChannelColumnDeps): Column[] { const { t, + lang, prioOptions, userTags, bumpPriority, @@ -87,7 +93,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column , }, - { ...subsColumn(t), width: "6rem" }, + { ...subsColumn(t, lang), width: "6rem" }, { key: "lastUpload", header: t("channels.cols.lastUpload"), nowrap: true, - width: "6rem", + width: "8rem", // "Utolsó feltöltés" + a sort arrow, as the priority column above sortable: true, sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0), render: (c) => , @@ -165,7 +173,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.total_duration_seconds, render: (c) => ( - {formatTotalHours(c.total_duration_seconds)} + {formatTotalHours(c.total_duration_seconds, lang)} ), }, diff --git a/frontend/src/components/comfortPin.langSwitch.dom.test.tsx b/frontend/src/components/comfortPin.langSwitch.dom.test.tsx new file mode 100644 index 00000000..e0641de8 --- /dev/null +++ b/frontend/src/components/comfortPin.langSwitch.dom.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +// +// E30 S3: a Plex query follows a language switch. `plexLang()` read the i18n singleton, and under the +// React Compiler a value with no reactive input is computed once: after switching to Hungarian the +// comfort shelf kept asking for its English cards. The language now comes off useTranslation. Real +// i18n (the key-echo mock does not re-render on a switch, which is the very thing under test). +import { describe, it, expect, afterEach, vi } from "vitest"; +import type { ReactNode } from "react"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { setLanguage } from "../i18n"; +import { api } from "../lib/api"; +import { useComfortPin } from "./comfortPin"; + +afterEach(() => { + vi.restoreAllMocks(); + setLanguage("en"); +}); + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe("the comfort shelf after a language switch", () => { + it("asks for its cards in the new language", async () => { + const get = vi.spyOn(api, "comfortPins").mockImplementation(() => new Promise(() => {})); + setLanguage("en"); + renderHook(() => useComfortPin(true), { wrapper }); + await waitFor(() => expect(get).toHaveBeenCalledWith("en")); + act(() => setLanguage("hu")); + await waitFor(() => expect(get).toHaveBeenCalledWith("hu")); + }); +}); diff --git a/frontend/src/components/comfortPin.tsx b/frontend/src/components/comfortPin.tsx index 40890ca1..0c3663cc 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"; @@ -94,7 +95,7 @@ export interface ComfortPin { * every feed load would be a real request for a string almost nobody reads that session. */ export function useComfortPin(open: boolean): ComfortPin { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); // Latch: react-query keeps the data cached when `enabled` goes false again, so this only decides // WHEN the first fetch happens, not whether the answer survives the menu closing. @@ -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(i18n.language)), + queryFn: () => api.comfortPins(plexLang(i18n.language)), 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/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx index 402b7cea..4ed7ecc9 100644 --- a/frontend/src/components/downloadAdmin.tsx +++ b/frontend/src/components/downloadAdmin.tsx @@ -160,7 +160,8 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string } } export function AdminSystem() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 }); const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 }); const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null); @@ -171,10 +172,10 @@ export function AdminSystem() {
{[ [t("downloads.admin.readyFiles"), String(s.ready_files)], - [t("downloads.admin.totalSize"), formatBytes(s.total_bytes)], + [t("downloads.admin.totalSize"), formatBytes(s.total_bytes, lang)], [ t("downloads.admin.cap"), - s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"), + s.total_cap_bytes ? formatBytes(s.total_cap_bytes, lang) : t("downloads.admin.noCap"), ], ].map(([label, val]) => (
@@ -199,7 +200,7 @@ export function AdminSystem() { className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40" > {u.email} - {formatBytes(u.footprint_bytes)} + {formatBytes(u.footprint_bytes, lang)} setQuotaFor({ id: u.user_id, email: u.email ?? "" })} label={t("downloads.admin.editQuota")} @@ -226,7 +227,7 @@ export function AdminSystem() { {j.user_email} - {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""} + {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes, lang) : ""}
))} diff --git a/frontend/src/components/downloadRow.dom.test.tsx b/frontend/src/components/downloadRow.dom.test.tsx index 02bb05ea..12448122 100644 --- a/frontend/src/components/downloadRow.dom.test.tsx +++ b/frontend/src/components/downloadRow.dom.test.tsx @@ -9,7 +9,10 @@ import { render, screen, cleanup, fireEvent } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); import { DownloadRow, IconBtn } from "./downloadRow"; diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 38b7e678..9ea89536 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -240,7 +240,8 @@ export function DownloadRow({ more?: ReactNode; children?: ReactNode; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const running = job.status === "running"; const linkTitle = !hasOwnTitle(job) && !!job.source_url; const sourceLink = linkTitle ? null : job.source_url; @@ -294,16 +295,16 @@ export function DownloadRow({ · {job.asset.container} ) : null} {job.asset?.size_bytes ? ( - · {formatBytes(job.asset.size_bytes)} + · {formatBytes(job.asset.size_bytes, lang)} ) : null} {job.asset?.duration_s ? ( · {formatDuration(job.asset.duration_s)} ) : null} {job.status === "done" && job.created_at ? ( - + {" · "} {" "} - {relativeTime(job.created_at)} + {relativeTime(job.created_at, lang)} ) : null} {job.status === "error" ? ( @@ -360,8 +361,8 @@ export function DownloadRow({
{job.progress}% - {job.speed_bps ? {formatSpeed(job.speed_bps)} : null} - {job.eta_s ? {formatEta(job.eta_s)} : null} + {job.speed_bps ? {formatSpeed(job.speed_bps, lang)} : null} + {job.eta_s ? {formatEta(t, job.eta_s)} : null}
)} @@ -373,7 +374,8 @@ export function DownloadRow({ } export function UsageBar() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; // Poll live (like the library list) so the footprint updates the moment the worker finishes an // edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so // a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint @@ -391,8 +393,8 @@ export function UsageBar() {
{t("downloads.usage.title")} - {formatBytes(u.footprint_bytes)} - {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "} + {formatBytes(u.footprint_bytes, lang)} + {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`} ·{" "} {u.unlimited ? t("downloads.usage.unlimited") : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })} diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx index 49347015..68d25f65 100644 --- a/frontend/src/components/plexBrowseLists.tsx +++ b/frontend/src/components/plexBrowseLists.tsx @@ -19,10 +19,10 @@ import { SURFACE } from "./ui/surfaces"; // A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only // collections have no movie runtime). -function collectionMeta(c: PlexCollection, t: TFunction): string { +function collectionMeta(c: PlexCollection, t: TFunction, lang: string): string { const parts: string[] = []; if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count })); - const rt = formatRuntime(c.total_runtime_s); + const rt = formatRuntime(c.total_runtime_s, lang); if (rt) parts.push(rt); return parts.join(" · "); } @@ -162,7 +162,7 @@ function NewCollectionPanel({ // A grid of Plex collections (searchable — the union can run to hundreds); a tile opens its info page. export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; const [search, setSearch] = useState(""); @@ -223,7 +223,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: navIndex={i} title={c.title} art={c.art} - subtitle={collectionMeta(c, t)} + subtitle={collectionMeta(c, t, i18n.language)} topRight={ isIntrinsicReadOnly(c) ? ( { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards"; 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..d704a9e7 --- /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: () => ({ i18n: { language: "en" }, 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.langSwitch.dom.test.tsx b/frontend/src/components/plexCards.langSwitch.dom.test.tsx new file mode 100644 index 00000000..3496dd59 --- /dev/null +++ b/frontend/src/components/plexCards.langSwitch.dom.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +// +// E30 S3: a card's numbers follow a language switch. The formatters read the UI language, but the +// React Compiler memoizes a call on its ARGUMENTS — `formatRuntime(card.duration_seconds)` stayed +// "2 hr 4 min" after switching to Hungarian while the hero beside it said "2 ó 16 p". Real catalog. +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, screen, cleanup, act } from "@testing-library/react"; +import { setLanguage } from "../i18n"; +import { PlexPosterCard } from "./plexCards"; +import type { PlexCard } from "../lib/api"; + +afterEach(() => { + cleanup(); + setLanguage("en"); +}); + +const movie: PlexCard = { + id: "m1", + type: "movie", + title: "Heat", + thumb: "/api/plex/image/m1", + duration_seconds: 2 * 3600 + 4 * 60, + rating: 8.3, +}; + +describe("a card after a language switch", () => { + it("says its runtime and rating in the new language", () => { + setLanguage("en"); + render(); + expect(screen.getByText("2 hr 4 min")).toBeTruthy(); + act(() => setLanguage("hu")); + expect(screen.getByText("2 ó 4 p")).toBeTruthy(); + expect(screen.getByText("8,3")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index f29e23d1..5b1728f4 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -2,9 +2,10 @@ import { useTranslation } from "react-i18next"; import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react"; import clsx from "clsx"; import { type ReactNode, useRef } from "react"; -import { formatRuntime } from "../lib/format"; +import { formatDecimal, 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"; @@ -286,9 +287,15 @@ export function PlexPosterCard({ /** The show's page, beside a `next` episode. */ onInfo?: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; 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; @@ -296,7 +303,7 @@ export function PlexPosterCard({ const acted = ep ?? card; const watched = card.status === "watched"; const actedWatched = acted.status === "watched"; - const runtime = formatRuntime(card.duration_seconds); + const runtime = formatRuntime(card.duration_seconds, lang); const pct = ep ? progressPct(ep) : watched ? 0 : progressPct(card); const m = more(card, menuButtonProps, menuOpen); const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined; @@ -311,7 +318,7 @@ export function PlexPosterCard({ const rating = card.rating != null && ( - {card.rating.toFixed(1)} + {formatDecimal(card.rating, lang)} ); return ( @@ -320,7 +327,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 +335,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 +402,7 @@ export function PlexPosterCard({ /> {[card.year, corner].filter(Boolean).join(" · ")} @@ -443,6 +462,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 +470,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")} /> @@ -630,11 +650,12 @@ export function EpisodeCard({ menuOpen?: boolean; navIndex?: number; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const watched = ep.status === "watched"; const pct = watched ? 0 : progressPct(ep); const m = more(ep, menuButtonProps, menuOpen); - const runtime = formatRuntime(ep.duration_seconds); + const runtime = formatRuntime(ep.duration_seconds, lang); return ( void; onReset?: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const shown = seconds + ? formatUnit(value, "second", lang) + : `${formatDecimal(value, lang, 1, 0)}${suffix ?? ""}`; return (
{label} - - {Number.isInteger(value) ? value : value.toFixed(1)} - {suffix ?? ""} - + {shown} {onReset && ( { vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); const ep = (id: string, season: number, episode: number, position = 0) => ({ @@ -129,3 +132,28 @@ describe("the restart button on a show and a season", () => { expect(onPlay).toHaveBeenCalledWith("e11", ALL, true); }); }); + +describe("the show page's summary (E30 review)", () => { + it("is clamped with a More, so a long one can still be read in full", async () => { + const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400); + const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60); + Object.assign(detail.show, { summary: "A long summary.\n\nThat goes on." }); + try { + wrap( + {}} + onPlay={() => {}} + onOpenSeason={() => {}} + onOpenShow={() => {}} + />, + ); + fireEvent.click(await screen.findByText("plex.textMore")); + expect(screen.getByText("plex.textLess").getAttribute("aria-expanded")).toBe("true"); + } finally { + tall.mockRestore(); + box.mockRestore(); + Object.assign(detail.show, { summary: undefined }); + } + }); +}); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 9f6d910f..0481054f 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, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -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"; @@ -42,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"; @@ -51,6 +58,8 @@ import { Chip } from "./ui/Chip"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { PageHeading } from "./ui/Headings"; +import { vocab } from "../lib/vocab"; +import { formatDate, formatDecimal } from "../lib/format"; // The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with // shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state, @@ -75,8 +84,8 @@ export function PlexInfoView({ onFilter: (patch: Partial) => void; onOpenPerson: (name: string) => void; }) { - const { t } = useTranslation(); - const lang = plexLang(); + const { t, i18n } = useTranslation(); + const lang = plexLang(i18n.language); const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) }); return (
@@ -111,6 +120,15 @@ 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) 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; + // --- 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 @@ -130,13 +148,35 @@ export function PlexPersonView({ onInfoCard: (card: PlexCard) => void; onFilter: (patch: Partial) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); // ONE menu for this whole page — the "known for" rail and the two filmography grids are three // 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(i18n.language); + 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: 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; + // 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), + refetchInterval: (query) => + 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(); @@ -153,7 +193,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, @@ -187,7 +227,7 @@ export function PlexPersonView({ cards.length > 0 ? (

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

- + {cards.map((c, i) => ( ) : (
- {/* 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-[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 ? ( + + ) : ( +
+ +
+ )}
- )} -
-
- {d.name} - {d.known_for_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")}:{" "} - {[d.birthday, 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")}: {d.deathday} -

- )} - {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 ? ( + + ) : bioOnItsWay ? ( + + ) : ( + // An ask that failed while the bio was on its way is said, not left looking like a + // person with no bio; the retry asks (and waits) again. + d.enriching && + q.isError && ( + { + setWaitOver(false); + setWaitRound((n) => n + 1); + void q.refetch(); + }} + > + {t("plex.person.bioFailed")} + + ) + )}
-
- {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)} - /> -
- ))} -
-
- )} + {/* "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")}

@@ -334,10 +418,10 @@ export function PlexShowView({ onFilter?: (patch: Partial) => void; onOpenPerson?: (name: string) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; - const lang = plexLang(); + const lang = plexLang(i18n.language); const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang), @@ -402,7 +486,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 +519,7 @@ export function PlexShowView({ }, ] : []), - ...copy(se.title, null).map((it) => ({ ...it, separatorBefore: true })), + ...copy(seasonTitle(se, t), null).map((it) => ({ ...it, separatorBefore: true })), ]; }; @@ -524,7 +608,7 @@ export function PlexShowView({ > {/* the rating gold — its own token, not --warn: a score is not a warning */} - {score.toFixed(1)} + {formatDecimal(score, lang)} ); })()} @@ -543,9 +627,13 @@ export function PlexShowView({
)} {show.summary && ( -

- {show.summary} -

+ )}
{show.resume && ( @@ -597,7 +685,7 @@ export function PlexShowView({ style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }} >

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

- + {d.seasons.map((se, i) => ( void; onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); - const lang = plexLang(); + const lang = plexLang(i18n.language); const q = useQuery({ queryKey: qk.plexShow(showId, lang), queryFn: () => api.plexShow(showId, lang), @@ -750,7 +838,9 @@ export function PlexSeasonView({ - {se.title} + + {seasonTitle(se, t)} +

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

@@ -785,7 +875,7 @@ export function PlexSeasonView({ setAddTarget({ kind: "group", ratingKeys: seasonKeys, - title: `${d.show.title} — ${se.title}`, + title: `${d.show.title} — ${seasonTitle(se, t)}`, }) } icon={ListPlus} @@ -795,7 +885,7 @@ export function PlexSeasonView({
- + {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(); + } + }); + + 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; + 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 new file mode 100644 index 00000000..41ddf634 --- /dev/null +++ b/frontend/src/components/ui/ClampedText.tsx @@ -0,0 +1,62 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { Button } from "./Button"; + +// Static class names, so Tailwind sees them. +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 + * 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, + 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 [shown, setShown] = useState(text); + if (shown !== text) { + setShown(text); + setExpanded(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/DataTable.fixedLayout.dom.test.tsx b/frontend/src/components/ui/DataTable.fixedLayout.dom.test.tsx index 866d5e13..55309e12 100644 --- a/frontend/src/components/ui/DataTable.fixedLayout.dom.test.tsx +++ b/frontend/src/components/ui/DataTable.fixedLayout.dom.test.tsx @@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); afterEach(cleanup); diff --git a/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx b/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx index 7adb0eed..42ff32b0 100644 --- a/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx +++ b/frontend/src/components/ui/DataTable.keyboard.dom.test.tsx @@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); afterEach(() => { diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index bf2f83e6..ce2d6296 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -132,7 +132,8 @@ export default function DataTable({ // the hidden-filter cue leaves those out, it would name them twice. surfacedFilters?: string[]; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const initial = loadPersist(persistKey); // Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise // the table keeps its own, seeded from and saved to persistKey. @@ -212,7 +213,7 @@ export default function DataTable({ setPage(0); }, [resetFiltersToken, commitFilters]); - const sorted = sortRows(filterRows(rows, columns, filters), columns, sort); + const sorted = sortRows(filterRows(rows, columns, filters), columns, sort, lang); // size <= 0 means "All" — one page with every row. const allRows = size <= 0; diff --git a/frontend/src/components/ui/HiddenFilterCue.dom.test.tsx b/frontend/src/components/ui/HiddenFilterCue.dom.test.tsx index 00ab192c..7c477db9 100644 --- a/frontend/src/components/ui/HiddenFilterCue.dom.test.tsx +++ b/frontend/src/components/ui/HiddenFilterCue.dom.test.tsx @@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => { return { ...actual, useTranslation: () => ({ + i18n: { language: "en" }, t: (key: string, o?: { name?: string; count?: number }) => o?.name ? `${key}(${o.name})` : o?.count !== undefined ? `${key}#${o.count}` : key, }), diff --git a/frontend/src/components/ui/ItemCount.dom.test.tsx b/frontend/src/components/ui/ItemCount.dom.test.tsx index 8fd0c962..246bd3c3 100644 --- a/frontend/src/components/ui/ItemCount.dom.test.tsx +++ b/frontend/src/components/ui/ItemCount.dom.test.tsx @@ -30,7 +30,7 @@ describe("ItemCount", () => { render(); // Separators come from the shared formatNumber — compare against it, not a fixed en-US string, // so the test asserts ItemCount's formatting path rather than the CI locale. - expect(text()).toBe(`${formatNumber(192045)} items`); + expect(text()).toBe(`${formatNumber(192045, "en")} items`); }); // The pill used `count.toLocaleString()`, which groups in the BROWSER's locale: a Hungarian UI on @@ -52,7 +52,9 @@ describe("ItemCount", () => { it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => { render(); expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent - expect(screen.getByTestId("item-count").getAttribute("title")).toBe(formatNumber(1_000_000)); + expect(screen.getByTestId("item-count").getAttribute("title")).toBe( + formatNumber(1_000_000, "en"), + ); }); it("shows the icon-only fixed box (empty label) while the count is undefined", () => { diff --git a/frontend/src/components/ui/ItemCount.tsx b/frontend/src/components/ui/ItemCount.tsx index 7d1acb2b..38bc47bf 100644 --- a/frontend/src/components/ui/ItemCount.tsx +++ b/frontend/src/components/ui/ItemCount.tsx @@ -14,7 +14,8 @@ const OVERFLOW_AT = 1_000_000; // "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural) // so any surface can reuse it for whatever it counts. export default function ItemCount({ count }: { count?: number }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; // `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just // show the icon until the number arrives. const label = @@ -22,12 +23,12 @@ export default function ItemCount({ count }: { count?: number }) { ? "" : t("common.itemCount", { count, - formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count), + formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count, lang), }); return (
); + 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/components/ui/tableHeader.tsx b/frontend/src/components/ui/tableHeader.tsx index fc2fc8c9..2fe6b7a5 100644 --- a/frontend/src/components/ui/tableHeader.tsx +++ b/frontend/src/components/ui/tableHeader.tsx @@ -229,10 +229,12 @@ export function ColumnHead({ {/* 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 && diff --git a/frontend/src/components/useChannelActions.dom.test.tsx b/frontend/src/components/useChannelActions.dom.test.tsx index 80b385eb..973c9d0d 100644 --- a/frontend/src/components/useChannelActions.dom.test.tsx +++ b/frontend/src/components/useChannelActions.dom.test.tsx @@ -18,7 +18,10 @@ const h = vi.hoisted(() => ({ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; }); vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), diff --git a/frontend/src/components/useChannelActions.tsx b/frontend/src/components/useChannelActions.tsx index 0c123d61..3ba69ccd 100644 --- a/frontend/src/components/useChannelActions.tsx +++ b/frontend/src/components/useChannelActions.tsx @@ -5,6 +5,7 @@ import { qk } from "../lib/queryKeys"; import { invalidateSubscription } from "../lib/subscriptionCache"; import { api, type ManagedChannel, type Tag } from "../lib/api"; import { priorityChoices } from "../lib/channelOrder"; +import { compareText } from "../lib/collate"; import { channelYouTubeUrl } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -40,7 +41,8 @@ export function useChannelActions({ onPickPriority: (c: ManagedChannel, priority: number) => void; }) { const qc = useQueryClient(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; const confirm = useConfirm(); const me = useMe(); const isAdmin = me.role === "admin"; @@ -166,7 +168,7 @@ export function useChannelActions({ return { ...base, items: [...tags] - .sort((a, b) => a.name.localeCompare(b.name)) + .sort((a, b) => compareText(a.name, b.name, lang)) .map((tg) => ({ id: `tag-${tg.id}`, label: tg.name, 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..1259e83b 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"; @@ -12,12 +13,16 @@ import { reinsertAt } from "../lib/reinsert"; * Split from `PlexPlaylistView` so the failure paths are reachable from a test: the reorder is * driven by a dnd-kit drag, and neither of its sensors activates under jsdom. */ export function usePlexPlaylistEdits(playlistId: number) { - const { t } = useTranslation(); + const { t, i18n } = 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(i18n.language); + 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/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", diff --git a/frontend/src/i18n/backendIds.contract.test.ts b/frontend/src/i18n/backendIds.contract.test.ts index a012ac14..96a69fad 100644 --- a/frontend/src/i18n/backendIds.contract.test.ts +++ b/frontend/src/i18n/backendIds.contract.test.ts @@ -1,5 +1,6 @@ import { readdirSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { vocabKey } from "../lib/vocab"; // Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these // up with the raw id as the fallback — `t(\`config.fields.${key}.label\`, key)` — so a missing key never @@ -99,6 +100,29 @@ const routeFiles = (dir = "routes"): string[] => : [], ); const routeSources = (): string[] => routeFiles().map(backend); +// The words a job's result gives for not doing its work → `scheduler.skip.` (the Scheduler +// page, the run notice and the audit row name them; lib/jobResult). Read as the `"skipped": "…"` / +// `"reason": "…"` literals of the non-HTTP backend: under routes/ (and main.py) a `reason` is a refusal +// code, which `errors.*` names. A word from an exception's text is not a literal and is not covered. +const appFiles = (dir: string): string[] => + readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), { + withFileTypes: true, + }).flatMap((e) => + e.isDirectory() + ? e.name === "routes" || e.name === "__pycache__" + ? [] + : appFiles(`${dir}${e.name}/`) + : e.name.endsWith(".py") && `${dir}${e.name}` !== "main.py" + ? [`${dir}${e.name}`] + : [], + ); +const skipWords = [ + ...new Set( + appFiles("").flatMap((f) => + [...backend(f).matchAll(/"(?:skipped|reason)": "([^"]+)"/g)].map((m) => m[1]!), + ), + ), +]; // A target the scan cannot read — `what=SOME_CONSTANT` — is a 404 no locale is checked for (one hid // `debug_session`). Only the helpers' own pass-through in `_common.py` may name it by variable. const opaqueWhats = routeFiles() @@ -147,6 +171,45 @@ const youtubeReasons = [ ).matchAll(/"(\w+)"/g), ].map((m) => m[1]!); +// E30: the closed English vocabularies the backend WRITES into data, named through `vocab.` +// (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named — +// so each list is read from its source here: the TMDB crew jobs kept, every auto-tag the tagger can +// create, and the built-in download profiles of the latest migration that seeds them. +const pyStrings = (block: string | undefined, re: RegExp) => + [...(block ?? "").matchAll(re)].map((m) => m[1]!); +const between = (src: string, start: string, end: string) => src.split(start)[1]?.split(end)[0]; +const tmdbPy = backend("plex/tmdb.py"); +const crewJobs = [ + ...new Set([ + ...pyStrings(between(tmdbPy, "_MOVIE_CREW_JOBS = (", ")"), /"([^"]+)"/g), + ...pyStrings(between(tmdbPy, "_TV_CREW_JOBS = (", ")"), /"([^"]+)"/g), + ]), +]; +const autotagPy = backend("sync/autotag.py"); +const autoTags = [ + ...new Set([ + ...pyStrings(between(autotagPy, "LANG_NAMES = {", "}"), /:\s*"([^"]+)"/g), + ...pyStrings(between(autotagPy, "TOPIC_COLORS = {", "}"), /"([^"]+)":/g), + ...pyStrings(between(autotagPy, "_TOPIC_SLUGS = {", "}"), /:\s*"([^"]+)"/g), + ...pyStrings(between(autotagPy, "_CATEGORY_NAMES = {", "}"), /:\s*"([^"]+)"/g), + ...pyStrings(between(autotagPy, "def map_topic_slug", "\n\n\n"), /return "([^"]+)"/g), + ]), +]; +const migrations = readdirSync(new URL("../../../backend/alembic/versions/", import.meta.url)) + .filter((f) => f.endsWith(".py")) + .sort(); +const seeding = migrations + .map((f) => + readFileSync(new URL(`../../../backend/alembic/versions/${f}`, import.meta.url), "utf-8"), + ) + .filter((src) => src.includes("_BUILTINS = [")) + .pop(); +const builtinProfiles = pyStrings( + between(seeding ?? "", "_BUILTINS = [", "\n]"), + /^\s+\("([^"]+)",/gm, +); +const VOCAB = { crewJob: crewJobs, autoTag: autoTags, profile: builtinProfiles }; + /** The ids under `prefix` a locale names, `prefix.` or `prefix..`. */ const idsUnder = (keys: Set, prefix: string) => new Set( @@ -170,6 +233,21 @@ describe("backend ids have their names in both languages", () => { expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"])); expect(opaqueWhats).toEqual([]); expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]); + expect(crewJobs).toEqual( + expect.arrayContaining(["Director", "Creator", "Director of Photography"]), + ); + expect(autoTags).toEqual( + expect.arrayContaining(["English", "Gaming", "Health & Fitness", "Hobbies", "Sports"]), + ); + expect(builtinProfiles).toEqual(expect.arrayContaining(["720p (MP4)", "Video only (MP4)"])); + expect(skipWords).toEqual( + expect.arrayContaining([ + "disabled", + "sync paused", + "tmdb_api_key not set", + "no service user", + ]), + ); expect(refusedReasons).toEqual( expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]), ); @@ -209,6 +287,10 @@ describe("backend ids have their names in both languages", () => { expect([...idsUnder(keys, prefix)].filter((j) => !jobs.includes(j))).toEqual([]); }); + it(`${lang}: every word a job's result gives for skipping its work`, () => { + expect(missing(skipWords.map((w) => `scheduler.skip.${vocabKey(w)}`))).toEqual([]); + }); + it(`${lang}: every audit and quota action`, () => { expect(auditActions.filter((a) => !idsUnder(keys, "auditActions").has(a))).toEqual([]); expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]); @@ -254,6 +336,17 @@ describe("backend ids have their names in both languages", () => { ).toEqual([]); }); + it(`${lang}: every crew job, auto-tag and built-in profile the backend writes, and no other`, () => { + for (const [group, words] of Object.entries(VOCAB)) { + const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`); + expect(missing(wanted), group).toEqual([]); + expect( + [...keys].filter((k) => k.startsWith(`vocab.${group}.`) && !wanted.includes(k)), + group, + ).toEqual([]); + } + }); + it(`${lang}: every admin refusal reason`, () => { expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]); expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual( diff --git a/frontend/src/i18n/copyRule.test.ts b/frontend/src/i18n/copyRule.test.ts index 1ab5bb39..30e61d86 100644 --- a/frontend/src/i18n/copyRule.test.ts +++ b/frontend/src/i18n/copyRule.test.ts @@ -43,3 +43,32 @@ describe("the copy rule", () => { expect(offenders((v) => /(\d|\}\})\s?(m|h|d|s)(?![\p{L}\d/])/u.test(v))).toEqual([]); }); }); + +describe("one name per thing", () => { + // The options of one choice are told apart by their names alone: two playback classes once shared + // "Közvetlen lejátszás" in the HU filter (E30 review). A set = a `…Opt` object, or one choice key's + // options under config.choices. + const sets = (): [string, Record][] => { + const out: [string, Record][] = []; + const walk = (o: unknown, path: string, parent: string) => { + if (!o || typeof o !== "object") return; + if (/Opt$/.test(path.split(".").pop() ?? "") || parent.endsWith("/config:.choices")) + out.push([path, o as Record]); + for (const [k, v] of Object.entries(o)) walk(v, `${path}.${k}`, path); + }; + for (const [file, json] of Object.entries(files)) + walk(json, file.replace("./locales/", "").replace(".json", ":"), ""); + return out; + }; + + it("names the options of one choice differently", () => { + const found = sets(); + expect(found.some(([p]) => p.endsWith("playableOpt"))).toBe(true); + expect(found.some(([p]) => p.endsWith("choices.log_level"))).toBe(true); + const clashes = found.flatMap(([path, opts]) => { + const names = Object.values(opts).filter((v): v is string => typeof v === "string"); + return names.length === new Set(names).size ? [] : [path]; + }); + expect(clashes).toEqual([]); + }); +}); diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index f3e148ad..d3a8768f 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -67,11 +67,13 @@ export function setLanguage(code: LangCode): void { document.documentElement.lang = code; } -// The Plex overlay serves only EN or HU metadata (E5 S6). Collapse the active UI language to one of -// those for the `?lang=` param / query keys. Components read `i18n.language` off useTranslation (which -// re-renders on switch); this singleton reader is for non-component code (e.g. plexPlayerHooks). -export function plexLang(): "en" | "hu" { - return i18n.language?.startsWith("hu") ? "hu" : "en"; +// The Plex overlay serves only EN or HU metadata (E5 S6). Collapse a UI language to one of those for +// the `?lang=` param / query keys. It takes the language instead of reading the singleton (E30 S3): +// under the React Compiler a component's `plexLang()` was a value with no reactive input, so it kept +// the language the component first rendered in — a switch left every Plex query on the old language. +// A component passes `i18n.language` from useTranslation; non-component code the singleton's. +export function plexLang(lang: string | undefined): "en" | "hu" { + return lang?.startsWith("hu") ? "hu" : "en"; } export default i18n; diff --git a/frontend/src/i18n/locales/en/audit.json b/frontend/src/i18n/locales/en/audit.json index 1d599c19..82df2382 100644 --- a/frontend/src/i18n/locales/en/audit.json +++ b/frontend/src/i18n/locales/en/audit.json @@ -3,12 +3,17 @@ "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", "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)", + "secretReset": "secret reset to its default (the value is never logged)", "cols": { "when": "When", "actor": "Who", diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index de1831a5..242582dd 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -306,7 +306,7 @@ "plexMovies": "Movies", "plexShows": "TV shows", "emailOff": "Email sending is not configured — Siftlode silently skips every message: verification links, password resets and access-request notices. Fill in the fields above, then send a test.", - "issuesTest": "Check the connection", + "issuesTest": "Test connection", "issuesTestHint": "Checks that the repository answers, is private and takes issues. Save your changes first.", "issuesTestOk": "Works: {{repo}} (private)", "issuesTestFailed": "Couldn't check the issue tracker" diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json index c6f8e71a..edff4fac 100644 --- a/frontend/src/i18n/locales/en/inbox.json +++ b/frontend/src/i18n/locales/en/inbox.json @@ -28,11 +28,24 @@ }, "jobDone": { "titleOk": "{{job}} finished", + "titleSkipped": "{{job}} skipped", "titleError": "Couldn't run {{job}}" }, "emailUndelivered": { "title": "Email is not going out", "notConfigured": "SMTP is not configured, so nothing this instance sends is being delivered — verification links, password resets and access requests alike. One of them was “{{subject}}”. Set it up under Configuration → Email / SMTP.", "sendFailed": "Couldn't send email, so messages are not being delivered. One of them was “{{subject}}”. Check Configuration → Email / SMTP and send a test; the server log has the reason." + }, + "downloadShared": { + "title": "{{from}} shared a download with you" + }, + "downloadExpiring": { + "title": "A download expires soon" + }, + "downloadExpired": { + "title": "A download expired and was removed" + }, + "downloadEvicted": { + "title": "A download was removed to free up space" } } diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index ab172c69..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.", @@ -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": { @@ -317,7 +320,6 @@ "removeShow": "Remove whole show", "removeSeason": "Remove whole season", "episodes": "{{count}} episodes", - "season": "Season {{n}}", "seasonUnknown": "Episodes", "addShow": "Add whole show to a playlist", "addSeason": "Add season to a playlist", @@ -349,7 +351,9 @@ "born": "Born", "died": "Died", "aka": "Also known as", - "knownFor": "Known for" + "knownFor": "Known for", + "bioLoading": "Loading the biography", + "bioFailed": "Couldn't load the biography right now" }, "view": { "label": "View", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index 9ffa94f2..1b01dac0 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", @@ -113,5 +115,76 @@ "purgeDiscovery": "Purge discovery", "purgeDiscoveryHint": "Delete all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.", "purgeConfirmTitle": "Purge discovery content?", - "purgedDiscovery": "Deleted {{count}} discovery items" + "purgedDiscovery": "Deleted {{count}} discovery items", + "result": { + "new": "new videos", + "failed": "failed", + "fallback": "read through the API", + "channels_processed": "channels processed", + "videos_added": "videos added", + "reconciled_full": "fully reconciled", + "reason": "reason", + "channels_tagged": "channels tagged", + "orphan_tags_removed": "orphan tags removed", + "probed": "checked", + "shorts": "Shorts", + "users": "users", + "playlists_synced": "playlists synced", + "rechecked": "rechecked", + "recovered": "recovered", + "deleted": "deleted", + "notified": "notified", + "revalidated": "revalidated", + "flagged": "flagged", + "skipped": "skipped", + "playlists_seeded": "playlists seeded", + "expired": "expired", + "videos_deleted": "videos deleted", + "channels_deleted": "channels deleted", + "search_videos_deleted": "search results deleted", + "warned": "warned", + "evicted": "evicted", + "reaped": "cleaned up", + "swept": "swept", + "libraries": "libraries", + "movies": "movies", + "shows": "shows", + "seasons": "seasons", + "episodes": "episodes", + "collections": "collections", + "overlaid": "HU overlay", + "pruned": "pruned", + "people_enriched": "people enriched", + "media_probed": "media probed", + "people_pruned": "people pruned", + "error": "error", + "links": "links", + "watched": "watched", + "in_progress": "in progress", + "repushed": "re-sent", + "movie_probes": "movie probes", + "applied": "applied", + "cleared": "cleared", + "scanned": "scanned", + "enriched": "enriched", + "countries_coded": "countries coded", + "sessions_closed": "sessions closed", + "sessions_reaped": "sessions cleaned up", + "recent": "recent", + "deep": "deep", + "bios": "biographies", + "portraits": "portraits", + "cached": "cached" + }, + "skip": { + "disabled": "disabled", + "PlexserverURLortokenisnotconfigured": "Plex server URL or token is not configured", + "tmdbapikeynotset": "TMDB API key not set", + "nodemoaccount": "no demo account", + "noserviceuser": "no service user", + "syncpaused": "sync paused", + "lowquota": "low quota", + "noreadscope": "no YouTube read access", + "busy": "another sync is running" + } } diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 17b5c5e1..49619177 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -192,7 +192,11 @@ "deleteFailed": "Couldn't delete the entry", "emailOff": "Email sending is not configured, so nobody gets a verification link and no request reaches you by email. Watch this list and your Notifications instead, and fix it under Configuration → Email / SMTP.", "deletedStillAllowed": "Entry deleted, but this address is still allow-listed by the server's configuration, so it keeps access. Remove it from ALLOWED_EMAILS / ADMIN_EMAILS to change that.", - "deleteConfirmStillAllowed": "Forget {{email}}? Careful: this address is also allow-listed in the server's configuration (ALLOWED_EMAILS / ADMIN_EMAILS), so deleting this row will NOT take its access away — and an ADMIN_EMAILS address is made an admin again on its next Google sign-in." + "deleteConfirmStillAllowed": "Forget {{email}}? Careful: this address is also allow-listed in the server's configuration (ALLOWED_EMAILS / ADMIN_EMAILS), so deleting this row will NOT take its access away — and an ADMIN_EMAILS address is made an admin again on its next Google sign-in.", + "status": { + "approved": "Approved", + "denied": "Denied" + } }, "diagnostics": { "title": "Logging level", diff --git a/frontend/src/i18n/locales/en/vocab.json b/frontend/src/i18n/locales/en/vocab.json new file mode 100644 index 00000000..2f30ae4c --- /dev/null +++ b/frontend/src/i18n/locales/en/vocab.json @@ -0,0 +1,169 @@ +{ + "crewJob": { + "Director": "Director", + "Writer": "Writer", + "Screenplay": "Screenplay", + "Story": "Story", + "Producer": "Producer", + "ExecutiveProducer": "Executive Producer", + "Editor": "Editor", + "DirectorofPhotography": "Director of Photography", + "OriginalMusicComposer": "Original Music Composer", + "Creator": "Creator" + }, + "department": { + "Acting": "Acting", + "Directing": "Directing", + "Writing": "Writing", + "Production": "Production", + "Sound": "Sound", + "Camera": "Camera", + "Editing": "Editing", + "Art": "Art", + "CostumeMakeUp": "Costume & Make-Up", + "Crew": "Crew", + "VisualEffects": "Visual Effects", + "Lighting": "Lighting", + "Creator": "Creator" + }, + "autoTag": { + "English": "English", + "Hungarian": "Hungarian", + "German": "German", + "French": "French", + "Spanish": "Spanish", + "Italian": "Italian", + "Portuguese": "Portuguese", + "Dutch": "Dutch", + "Russian": "Russian", + "Polish": "Polish", + "Romanian": "Romanian", + "Czech": "Czech", + "Slovak": "Slovak", + "Turkish": "Turkish", + "Swedish": "Swedish", + "Danish": "Danish", + "Norwegian": "Norwegian", + "Finnish": "Finnish", + "Japanese": "Japanese", + "Korean": "Korean", + "Chinese": "Chinese", + "Hindi": "Hindi", + "Arabic": "Arabic", + "Ukrainian": "Ukrainian", + "Greek": "Greek", + "Gaming": "Gaming", + "Music": "Music", + "Cooking": "Cooking", + "Tech": "Tech", + "Education": "Education", + "Sports": "Sports", + "Film": "Film", + "TV": "TV", + "Politics": "Politics", + "HealthFitness": "Health & Fitness", + "Vehicles": "Vehicles", + "Animals": "Animals", + "FashionBeauty": "Fashion & Beauty", + "Travel": "Travel", + "Comedy": "Comedy", + "Entertainment": "Entertainment", + "Business": "Business", + "Science": "Science", + "News": "News", + "Society": "Society", + "PerformingArts": "Performing Arts", + "Military": "Military", + "Religion": "Religion", + "Hobbies": "Hobbies" + }, + "profile": { + "720pMP4": "720p (MP4)", + "1080pMP4": "1080p (MP4)", + "BestMP4": "Best (MP4)", + "AudioM4A": "Audio (M4A)", + "AudioMP3": "Audio (MP3)", + "VideoonlyMP4": "Video only (MP4)" + }, + "topic": { + "Music": "Music", + "Christianmusic": "Christian music", + "Classicalmusic": "Classical music", + "Countrymusic": "Country music", + "Electronicmusic": "Electronic music", + "Hiphopmusic": "Hip hop music", + "Independentmusic": "Independent music", + "Jazz": "Jazz", + "MusicofAsia": "Music of Asia", + "MusicofLatinAmerica": "Music of Latin America", + "Popmusic": "Pop music", + "Reggae": "Reggae", + "Rhythmandblues": "Rhythm and blues", + "Rockmusic": "Rock music", + "Soulmusic": "Soul music", + "Videogameculture": "Video game culture", + "Actiongame": "Action game", + "Actionadventuregame": "Action-adventure game", + "Casualgame": "Casual game", + "Musicvideogame": "Music video game", + "Puzzlevideogame": "Puzzle video game", + "Racingvideogame": "Racing video game", + "Roleplayingvideogame": "Role-playing video game", + "Simulationvideogame": "Simulation video game", + "Sportsgame": "Sports game", + "Strategyvideogame": "Strategy video game", + "Sport": "Sport", + "Americanfootball": "American football", + "Associationfootball": "Association football", + "Baseball": "Baseball", + "Basketball": "Basketball", + "Boxing": "Boxing", + "Cricket": "Cricket", + "Golf": "Golf", + "Icehockey": "Ice hockey", + "Mixedmartialarts": "Mixed martial arts", + "Motorsport": "Motorsport", + "Professionalwrestling": "Professional wrestling", + "Tennis": "Tennis", + "Volleyball": "Volleyball", + "Entertainment": "Entertainment", + "Humour": "Humour", + "Film": "Film", + "Performingarts": "Performing arts", + "Televisionprogram": "Television program", + "Lifestylesociology": "Lifestyle (sociology)", + "Fashion": "Fashion", + "Physicalfitness": "Physical fitness", + "Food": "Food", + "Hobby": "Hobby", + "Pet": "Pet", + "Physicalattractiveness": "Physical attractiveness", + "Technology": "Technology", + "Tourism": "Tourism", + "Vehicle": "Vehicle", + "Society": "Society", + "Business": "Business", + "Health": "Health", + "Military": "Military", + "Politics": "Politics", + "Religion": "Religion", + "Knowledge": "Knowledge" + }, + "region": { + "AN": "Netherlands Antilles", + "SU": "Soviet Union", + "XC": "Czechoslovakia", + "XG": "East Germany", + "XI": "Northern Ireland", + "YU": "Yugoslavia", + "ZR": "Zaire" + }, + "track": { + "forced": "forced", + "sdh": "SDH", + "stereo": "Stereo", + "mono": "Mono", + "audioN": "Audio {{n}}", + "subtitleN": "Subtitle {{n}}" + } +} diff --git a/frontend/src/i18n/locales/hu/about.json b/frontend/src/i18n/locales/hu/about.json index a1b257ea..3e9698da 100644 --- a/frontend/src/i18n/locales/hu/about.json +++ b/frontend/src/i18n/locales/hu/about.json @@ -1,10 +1,10 @@ { "title": "Névjegy", "tagline": "Saját üzemeltetésű, többfelhasználós olvasó a YouTube-feliratkozásaidhoz.", - "frontend": "Frontend", - "backend": "Backend", + "frontend": "Felület", + "backend": "Szerver", "database": "Adatbázis", - "buildDate": "Build dátuma", + "buildDate": "Összeállítva", "whatsNew": "Mi újság", "releaseNotes": "Verziójegyzék", "new": "Új", diff --git a/frontend/src/i18n/locales/hu/adminLogs.json b/frontend/src/i18n/locales/hu/adminLogs.json index eb657236..6a319a8a 100644 --- a/frontend/src/i18n/locales/hu/adminLogs.json +++ b/frontend/src/i18n/locales/hu/adminLogs.json @@ -1,6 +1,6 @@ { "title": "Szervernapló", - "intro": "A szerver tárolt naplója — az API, a letöltő és a böngészők sorai, amelyeket a megőrzési idő végéig tart meg. Egy sor a helyén nyílik ki; a kérés- vagy job-azonosítójára kattintva csak az ahhoz tartozó sorok maradnak.", + "intro": "A szerver tárolt naplója — az API, a letöltő és a böngészők sorai, amelyeket a megőrzési idő végéig tart meg. Egy sor a helyén nyílik ki; a kérés- vagy feladat-azonosítójára kattintva csak az ahhoz tartozó sorok maradnak.", "search": "Keresés az üzenetben, a kódban, a hívási veremben…", "levelLabel": "Szűrés szintre", "source": "Forrás", @@ -21,10 +21,10 @@ "copied_other": "Kimásolva: {{count}} sor", "copyFailed": "Nem sikerült a vágólapra másolni", "pivotRequest": "kérés {{id}}", - "pivotJob": "job {{id}}", + "pivotJob": "feladat {{id}}", "clearPivot": "Szűrő törlése", "showRequest": "Csak ennek a kérésnek a sorai", - "showJob": "Csak ennek a jobnak a sorai", + "showJob": "Csak ennek a feladatnak a sorai", "empty": "Nincs a szűrésnek megfelelő sor.", "stale": "Nem sikerült frissíteni — amit látsz, elavult lehet", "olderFailed": "Nem sikerült betölteni a régebbi sorokat", @@ -37,7 +37,7 @@ "source": "Forrás", "account": "Fiók", "request": "Kérés", - "job": "Job", + "job": "Feladat", "version": "Verzió" } } diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index 18d00352..907042c2 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -1,14 +1,19 @@ { - "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", - "clear": "Napló ürítése", - "clearTitle": "Üríted a naplót?", + "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", - "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", + "default": "alapérték", + "secretUpdated": "titok frissítve (az értéke sosem kerül a naplóba)", + "secretReset": "titok visszaállítva az alapértékre (az értéke sosem kerül a naplóba)", "cols": { "when": "Mikor", "actor": "Ki", diff --git a/frontend/src/i18n/locales/hu/auditActions.json b/frontend/src/i18n/locales/hu/auditActions.json index 30401aeb..3f27b63d 100644 --- a/frontend/src/i18n/locales/hu/auditActions.json +++ b/frontend/src/i18n/locales/hu/auditActions.json @@ -8,22 +8,22 @@ "user_setpw_link": "Jelszóbeállító link kiadva", "invite_approve": "Hozzáférési kérelem jóváhagyva", "invite_deny": "Hozzáférési kérelem elutasítva", - "invite_add": "Email fehérlistázva", - "demo_add": "Demo email hozzáadva", - "demo_remove": "Demo email eltávolítva", - "demo_reset": "Demo visszaállítva", + "invite_add": "E-mail fehérlistára téve", + "demo_add": "Demó e-mail hozzáadva", + "demo_remove": "Demó e-mail eltávolítva", + "demo_reset": "Demó visszaállítva", "discovery_purge": "Felfedezés ürítve", "config_set": "Beállítás módosítva", "config_reset": "Beállítás visszaállítva", - "scheduler_interval": "Job-intervallum módosítva", + "scheduler_interval": "Feladat-intervallum módosítva", "maintenance_tune": "Karbantartás hangolva", "sync_pause": "Szinkron szüneteltetve", "sync_resume": "Szinkron folytatva", - "job_run": "Ütemező job lefutott", - "audit_clear": "Napló ürítve", + "job_run": "Ütemezett feladat lefutott", + "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": "Whitelista-bejegyzés törölve", + "invite_delete": "Fehérlista-bejegyzés törölve", "invite_send": "Meghívó elküldve", "issue_create": "Hibajegy elküldve" } diff --git a/frontend/src/i18n/locales/hu/card.json b/frontend/src/i18n/locales/hu/card.json index c94c7979..4b819df6 100644 --- a/frontend/src/i18n/locales/hu/card.json +++ b/frontend/src/i18n/locales/hu/card.json @@ -15,7 +15,7 @@ "restartTitle": "Lejátszás az elejétől", "restart": "Elölről", "play": "Lejátszás", - "stream": "stream", + "stream": "közvetítés", "views": "megtekintés", "info": "Részletek", "noDescription": "Nincs leírás", diff --git a/frontend/src/i18n/locales/hu/channel.json b/frontend/src/i18n/locales/hu/channel.json index f5a153ee..0650b732 100644 --- a/frontend/src/i18n/locales/hu/channel.json +++ b/frontend/src/i18n/locales/hu/channel.json @@ -32,9 +32,9 @@ "keywords": "Kulcsszavak", "showInFeed": "Mutasd a Feedemben", "fullHistory": "Teljes előzmény", - "fullHistoryLoading": "Előzmény letöltése…", - "fullHistoryProgress": "Előzmény letöltése… {{stored}} / {{total}}", - "fullHistoryLoadingHint": "A teljes előzmény letöltése folyamatban — a videók feltűnnek, ahogy előkerülnek; egy nagyon nagy csatorna maradékát a háttérben fejezi be.", - "fullHistoryQueued": "A csatorna teljes előzményének letöltése elindult — az új videók feltűnnek, ahogy előkerülnek.", - "fullHistoryFailed": "Nem sikerült elindítani a teljes előzmény letöltését" + "fullHistoryLoading": "Előzmény behúzása…", + "fullHistoryProgress": "Előzmény behúzása… {{stored}} / {{total}}", + "fullHistoryLoadingHint": "A teljes előzmény behúzása folyamatban — a videók feltűnnek, ahogy előkerülnek; egy nagyon nagy csatorna maradékát a háttérben fejezi be.", + "fullHistoryQueued": "A csatorna teljes előzményének behúzása elindult — az új videók feltűnnek, ahogy előkerülnek.", + "fullHistoryFailed": "Nem sikerült elindítani a teljes előzmény behúzását" } diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 3b0e2951..3c6e61bb 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -2,8 +2,8 @@ "actionsLabel": "Katalógus-műveletek", "syncSubscriptions": "Feliratkozások szinkronizálása a YouTube-ról", "syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.", - "backfillEverything": "Minden letöltése", - "backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.", + "backfillEverything": "Minden videó behúzása", + "backfillEverythingHint": "A teljes archívum behúzását kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.", "tabs": { "subscribed": "Feliratkozások", "discovery": "Felfedezés a lejátszási listákból", @@ -75,7 +75,7 @@ "loading": "Csatornák betöltése…", "empty": "Nincsenek csatornák.", "cols": { - "priority": "Prio", + "priority": "Prioritás", "channel": "Csatorna", "about": "Leírás", "stored": "Tárolt", @@ -94,7 +94,7 @@ "fullHistory": "Teljes előzmény", "fullHistoryHint": "Csatornák, amelyek teljes archívuma le van töltve, azok közül, amelyekhez teljes előzményt kértél.", "left": "hátra", - "leftHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltésére, a megosztott napi kvóta alapján.", + "leftHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének behúzására, a megosztott napi kvóta alapján.", "myVideos": "Videóim", "myVideosHint": "A csatornáidon elérhető videók teljes száma.", "quotaLeft": "Maradék kvóta", @@ -105,15 +105,15 @@ "editTags": "Címkék szerkesztése…", "filterFeedByTag": "Feed szűrése: „{{name}}”", "priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a Feedet „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.", - "fullHint": "Teljes előzmény letöltve.", + "fullHint": "Teljes előzmény behúzva.", "fullySynced": "teljesen szinkronban", "fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.", - "backfillThis": "Csatorna teljes letöltése", - "backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés).", - "resetBackfill": "Csatorna resetelése és újraletöltése", - "cancelBackfill": "A teljes letöltés kérésének visszavonása", - "resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.", - "queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi.", + "backfillThis": "A csatorna teljes behúzása", + "backfillThisHint": "A csatorna teljes archívumának behúzása (régebbi videók + teljes keresés).", + "resetBackfill": "Csatorna alaphelyzetbe állítása és újbóli behúzása", + "cancelBackfill": "A teljes behúzás kérésének visszavonása", + "resetHint": "Admin: a csatorna teljes újbóli behúzása a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.", + "queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma bekerül, ahogy a megosztott kvóta engedi.", "queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.", "hideHint": "A csatorna videóinak elrejtése a Feededből. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.", "hideFromFeed": "Elrejtés a Feedből", @@ -134,12 +134,12 @@ "needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod", "connect": "Csatlakoztatás", "filteredByTag": "Feed szűrve címkére: {{name}}", - "resetDone": "{{name}} resetelve — újraletöltés folyamatban", - "resetFailed": "Nem sikerült resetelni a csatornát", + "resetDone": "{{name}} alaphelyzetbe állítva — újbóli behúzás folyamatban", + "resetFailed": "Nem sikerült alaphelyzetbe állítani a csatornát", "hidden": "„{{name}}” elrejtve a Feedből" }, "confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a Feededből szeretnéd eltávolítani, inkább rejtsd el.", - "confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.", + "confirmReset": "Újra behúzod a(z) „{{name}}” csatorna videóit a nulláról? Ez újrafuttatja a videók behúzását (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.", "searchPlaceholder": "Csatornák keresése…", "blocked": { "title": "Tiltott csatornák", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index c6e5e35b..b91fa02c 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -13,11 +13,11 @@ "somethingWrong": "Hiba történt", "itemCount_one": "{{formattedCount}} elem", "itemCount_other": "{{formattedCount}} elem", - "accessRequestsTitle": "Hozzáférési kérések", + "accessRequestsTitle": "Hozzáférési kérelmek", "accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.", "accessRequestsReview": "Áttekintés", - "demoTitle": "Demo fiók", - "demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.", + "demoTitle": "Demó fiók", + "demoSharedNotice": "Egy közös demó fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demó látogatók egyszerre módosíthatják.", "backToTop": "Vissza a tetejére", "retry": "Újra", "retryWithEnter": "Újrapróbáláshoz nyomd meg az Entert.", diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index cd03a09c..75cff01b 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -7,7 +7,7 @@ "youtube": "YouTube API", "google": "Google bejelentkezés", "quota": "Kvóta", - "backfill": "Letöltés (backfill)", + "backfill": "Videók behúzása", "shorts": "Shorts-vizsgálat", "batch": "Kötegméretek", "maintenance": "Karbantartás", @@ -15,7 +15,7 @@ "downloads": "Letöltések", "plex": "Plex", "tmdb": "TMDB", - "audit": "Auditnapló", + "audit": "Eseménynapló", "diagnostics": "Diagnosztika", "issues": "Hibajegyek" }, @@ -45,8 +45,8 @@ "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", - "hint": "Tartalékban tartott egységek, hogy az ütemezett letöltés ne éheztesse ki az interaktív szinkront / gazdagítást." + "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": { "label": "Élő keresés — napi limit / felhasználó", @@ -57,11 +57,11 @@ "hint": "Honnan jönnek az élő YouTube-keresés találatai. A „Weboldal” a YouTube saját keresővégpontját olvassa, és nem fogyaszt API-kvótát (ajánlott). A „Hivatalos API” a search.list-et használja (100 egység/oldal) — válts rá, ha a weboldalas forrás elromlik vagy letiltják." }, "backfill_recent_max_videos": { - "label": "Friss letöltés — max videó", - "hint": "Csatornánként az első körben letöltött legújabb videók száma." + "label": "Friss behúzás — max videó", + "hint": "Csatornánként az első körben behúzott legújabb videók száma." }, "backfill_recent_max_days": { - "label": "Friss letöltés — max kor (nap)", + "label": "Friss behúzás — max kor (nap)", "hint": "Az első kör csatornánként meddig nyúl vissza." }, "rss_fallback_hours": { @@ -77,11 +77,11 @@ "hint": "Futásonként ennyi videót osztályozunk." }, "enrich_batch_size": { - "label": "Gazdagítási kötegméret", + "label": "Dúsítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." }, "autotag_title_sample": { - "label": "Auto-címke címmintavétel", + "label": "Automatikus címkézés: címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." }, "maintenance_revalidate_batch": { @@ -90,7 +90,7 @@ }, "youtube_api_key": { "label": "YouTube API-kulcs", - "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." + "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott behúzás ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." }, "youtube_api_proxy": { "label": "YouTube API kimenő proxy", @@ -106,7 +106,7 @@ }, "allow_registration": { "label": "Regisztráció engedélyezése", - "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." + "hint": "Bekapcsolva bárki beküldhet e-mail + jelszó regisztrációt. Belépéshez továbbra is e-mail-megerősítés és admin-jóváhagyás szükséges." }, "explore_grace_days": { "label": "Felfedezés — türelmi idő (nap)", @@ -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", @@ -158,7 +158,7 @@ }, "audit_retention_days": { "label": "Megőrzés (nap)", - "hint": "Meddig maradnak meg az admin-naplóbejegyzések, mielőtt az auditnapló-takarító feladat törli őket. 0 = örökre. Alapérték 180." + "hint": "Meddig maradnak meg az admin-naplóbejegyzések, mielőtt az eseménynapló-takarító feladat törli őket. 0 = örökre. Alapérték 180." }, "log_level": { "label": "Naplózási szint", @@ -193,20 +193,20 @@ "hint": "Egy magyar Plex-könyvtárat párosít az angol ikerpárjával, hogy a címek magyar metaadattal jelenjenek meg: vesszővel elválasztott huSectionKey:enSectionKey párok, pl. 3:4,5:1. A magyar könyvtár önmagában nem böngészhető; a címeit azonosító alapján párosítjuk az angolokhoz. Üres = nincs átfedés." }, "plex_max_transcodes": { - "label": "Max párhuzamos transcode", - "hint": "A nem-H.264 fájlok egyidejű transcode-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." + "label": "Max. párhuzamos átkódolás", + "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 transcode", - "hint": "GPU-s (VAAPI) kódolási házirend. Az Auto a GPU-t használja, ha elérhető és dekódolni tudja a forrást, különben szoftvert; a GPU kikényszeríti; a Szoftver sosem használja." + "label": "Hardveres átkódolás", + "hint": "GPU-s (VAAPI) kódolási házirend. Az Automatikus a GPU-t használja, ha elérhető és dekódolni tudja a forrást, különben szoftvert; a GPU kikényszeríti; a Szoftver sosem használja." }, "plex_transcode_preset": { "label": "Szoftveres preset", - "hint": "libx264 preset a szoftveres tartalékhoz, a leggyorsabbtól a leglassabbig. A lassabb preset kisebb streamet ad, de több CPU-t eszik. A GPU-úton figyelmen kívül marad." + "hint": "libx264 preset a szoftveres tartalékhoz, a leggyorsabbtól a leglassabbig. A lassabb preset kisebb adatfolyamot ad, de több CPU-t eszik. A GPU-úton figyelmen kívül marad." }, "plex_transcode_crf": { "label": "Szoftveres minőség (CRF)", @@ -214,11 +214,11 @@ }, "plex_transcode_max_height": { "label": "Szoftveres max. magasság", - "hint": "A szoftveres transcode kimeneti magasságának korlátja képpontban (pl. 1080; 0 = forrás megtartása), hogy egy magas forrás újrakódolása megfizethető maradjon. A GPU-út a forrásfelbontást tartja." + "hint": "A szoftveres átkódolás kimeneti magasságának korlátja képpontban (pl. 1080; 0 = forrás megtartása), hogy egy magas forrás újrakódolása megfizethető maradjon. A GPU-út a forrásfelbontást tartja." }, "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 remux/transcode 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", @@ -258,7 +258,7 @@ "ERROR": "ERROR" }, "plex_transcode_hw": { - "auto": "Auto", + "auto": "Automatikus", "on": "GPU", "off": "Szoftver" }, @@ -289,10 +289,10 @@ "secretEnv": "env-érték használatban", "secretUnset": "nincs beállítva", "secretsDisabled": "Állítsd be a TOKEN_ENCRYPTION_KEY-t a titkok adatbázisban tárolásához.", - "testEmail": "Teszt-email küldése", - "testEmailHint": "Teszt-email küldése a saját címedre a fenti beállításokkal, hogy ellenőrizd, működnek-e.", - "testSent": "Teszt-email elküldve ide: {{to}}", - "testFailed": "Nem sikerült elküldeni a teszt-emailt — ellenőrizd a beállításokat", + "testEmail": "Teszt e-mail küldése", + "testEmailHint": "Teszt e-mail küldése a saját címedre a fenti beállításokkal, hogy ellenőrizd, működnek-e.", + "testSent": "Teszt e-mail elküldve ide: {{to}}", + "testFailed": "Nem sikerült elküldeni a teszt e-mailt — ellenőrizd a beállításokat", "plexTest": "Kapcsolat tesztelése", "plexTestHint": "Ellenőrzi a Plex-szerver URL-t és tokent, és betölti a könyvtárlistát. Előbb mentsd a módosításokat.", "testDirty": "Mentsd a változásokat a tesztelés előtt.", @@ -305,8 +305,8 @@ "plexPickHint": "Ha egyiket sem jelölöd be, az az összes könyvtár megjelenítésével egyenértékű. Ne feledj menteni.", "plexMovies": "Filmek", "plexShows": "Sorozatok", - "emailOff": "Az e-mail-küldés nincs beállítva — a Siftlode némán kihagy minden üzenetet: megerősítő linkeket, jelszó-visszaállításokat és hozzáférés-kérés értesítőket. Töltsd ki a fenti mezőket, majd küldj tesztet.", - "issuesTest": "Kapcsolat ellenőrzése", + "emailOff": "Az e-mail-küldés nincs beállítva — a Siftlode némán kihagy minden üzenetet: megerősítő linkeket, jelszó-visszaállításokat és hozzáférési kérelmekről szóló értesítőket. Töltsd ki a fenti mezőket, majd küldj tesztet.", + "issuesTest": "Kapcsolat tesztelése", "issuesTestHint": "Megnézi, hogy a tárhely elérhető, privát, és fogad hibajegyet. Előbb mentsd a módosításokat.", "issuesTestOk": "Rendben: {{repo}} (privát)", "issuesTestFailed": "Nem sikerült ellenőrizni a hibajegy-kezelőt" diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index f40bcd51..6630b03d 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -1,7 +1,7 @@ { "title": "Nem sikerült", "generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.", - "demo": "Ez a bemutató fiókban nem érhető el", + "demo": "Ez a demó fiókban nem érhető el", "validation": "Néhány megadott érték érvénytelen volt. Ellenőrizd, majd próbáld újra.", "tooLarge": "Ez túl nagy, a szerver nem fogadta el", "server": "Szerverhiba", @@ -26,7 +26,7 @@ "link": "Ez a megosztási link már nem létezik", "notification": "Ez az értesítés már nem létezik", "account": "Ez a fiók már nem létezik", - "collection": "Ez a gyűjtemény már nem létezik", + "collection": "Ez a kollekció már nem létezik", "config_key": "Nincs ilyen beállítás", "file": "A fájl már nem érhető el", "filmstrip": "Ehhez nincs képsor", @@ -46,7 +46,7 @@ "not_approved": "Csak jóváhagyott címre küldhető meghívó", "email_off": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Konfiguráció → E-mail / SMTP fület.", "send_failed": "Nem sikerült elküldeni a levelet: a levelezőszerver nem volt elérhető, vagy elutasította a belépést vagy a levelet. Nézd meg a Konfiguráció → E-mail / SMTP fület, majd próbáld újra.", - "demo": "A bemutató fiók közös, ezért nem kaphat jelszóbeállító linket" + "demo": "A demó fiók közös, ezért nem kaphat jelszóbeállító linket" }, "reason": { "unreachable": "A szerver nem volt elérhető. Próbáld újra.", @@ -128,13 +128,13 @@ "already_subscribed": "Erre a csatornára már feliratkoztál", "channel_blocked": "Ezt a csatornát letiltottad", "collection_fields_required": "A címet és egy elemet is meg kell adni", - "collection_pending_sync": "A gyűjtemény a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja", - "collection_read_only": "Ez a gyűjtemény csak olvasható", - "collection_smart": "Az okos és az automatikus listás gyűjtemények nem tehetők szerkeszthetővé", - "demo_activate": "A bemutató fiók nem aktiválható", - "demo_delete": "A bemutató fiók nem törölhető", - "demo_role": "A bemutató fiók szerepköre nem módosítható", - "demo_suspend": "A bemutató fiók nem függeszthető fel", + "collection_pending_sync": "A kollekció a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja", + "collection_read_only": "Ez a kollekció csak olvasható", + "collection_smart": "Az okos és az automatikus listás kollekciók nem tehetők szerkeszthetővé", + "demo_activate": "A demó fiók nem aktiválható", + "demo_delete": "A demó fiók nem törölhető", + "demo_role": "A demó fiók szerepköre nem módosítható", + "demo_suspend": "A demó fiók nem függeszthető fel", "download_not_ready": "Ez a letöltés még nincs kész", "edit_unfinished": "Csak befejezett letöltés szerkeszthető", "filters_not_object": "A nézet szűrői hibás formátumúak", diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index 2d640d4b..38a7c63d 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -18,7 +18,7 @@ "setUp": "Feed beállítása", "browseShared": "Vagy csak böngészd a közös Könyvtárat", "demoEmptyTitle": "Itt a demóban nincs semmi", - "demoEmptyBody": "A demo fióknak nincsenek saját feliratkozásai — de a teljes közös Könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.", + "demoEmptyBody": "A demó fióknak nincsenek saját feliratkozásai — de a teljes közös Könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.", "browseLibrary": "Könyvtár böngészése", "noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.", "channelScopeHint": "Ez a csatorna nincs a Feededben – lehet, hogy el van rejtve. A videói a közös Könyvtárban vannak.", diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index c5273494..9049a448 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -31,7 +31,7 @@ "signOut": "Kijelentkezés", "addAccount": "Másik fiók hozzáadása", "switchTo": "Váltás erre: {{name}}", - "audit": "Auditnapló", + "audit": "Eseménynapló", "logs": "Szervernapló", "googleAddress": "Google-fiók: {{email}}" }, @@ -41,7 +41,7 @@ "countTooltip": "A feliratkozásaid a teljes megosztott katalógushoz képest.", "paused": "szüneteltetve", "syncing": "{{count}} szinkronizálás alatt", - "backfillingHistory": "előzmény letöltése", + "backfillingHistory": "előzmény behúzása", "working": "szinkronizálás…", "recentQueued": "{{count}} sorban", "allSynced": "minden szinkronban", diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json index d8017756..477214bf 100644 --- a/frontend/src/i18n/locales/hu/inbox.json +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -28,11 +28,24 @@ }, "jobDone": { "titleOk": "{{job}} kész", + "titleSkipped": "{{job}} kihagyva", "titleError": "Nem sikerült lefuttatni: {{job}}" }, "emailUndelivered": { "title": "Nem megy ki e-mail", - "notConfigured": "Az SMTP nincs beállítva, így semmi nem kézbesíthető, amit ez a példány küldene — megerősítő linkek, jelszó-visszaállítások és hozzáférés-kérelmek egyaránt. Az egyik ilyen: „{{subject}}”. Állítsd be a Konfiguráció → E-mail / SMTP fülön.", + "notConfigured": "Az SMTP nincs beállítva, így semmi nem kézbesíthető, amit ez a példány küldene — megerősítő linkek, jelszó-visszaállítások és hozzáférési kérelmek egyaránt. Az egyik ilyen: „{{subject}}”. Állítsd be a Konfiguráció → E-mail / SMTP fülön.", "sendFailed": "Nem sikerült e-mailt küldeni, így az üzenetek nem kézbesíthetők. Az egyik ilyen: „{{subject}}”. Nézd meg a Konfiguráció → E-mail / SMTP fület, és küldj tesztet; az okot a szerver naplója tartalmazza." + }, + "downloadShared": { + "title": "{{from}} megosztott veled egy letöltést" + }, + "downloadExpiring": { + "title": "Hamarosan lejár egy letöltés" + }, + "downloadExpired": { + "title": "Egy letöltés lejárt, és törlődött" + }, + "downloadEvicted": { + "title": "Egy letöltés helyhiány miatt törlődött" } } diff --git a/frontend/src/i18n/locales/hu/nav.json b/frontend/src/i18n/locales/hu/nav.json index 2acc8919..bb5e07fb 100644 --- a/frontend/src/i18n/locales/hu/nav.json +++ b/frontend/src/i18n/locales/hu/nav.json @@ -19,7 +19,7 @@ "scheduler": "Ütemező", "config": "Konfig", "users": "Felhasználók", - "audit": "Audit", + "audit": "Eseménynapló", "logs": "Napló", "settings": "Beállítások" } diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json index 1c36416b..0d4be415 100644 --- a/frontend/src/i18n/locales/hu/player.json +++ b/frontend/src/i18n/locales/hu/player.json @@ -5,7 +5,7 @@ "previous": "Előző", "next": "Következő", "autoAdvance": { - "label": "Auto", + "label": "Automatikus", "hint": "A videó végén mi jöjjön. Kattintásra vált: Ki, Következő, Előző, Véletlen. A fiókodhoz mentve.", "off": "Ki", "next": "Következő", @@ -33,7 +33,7 @@ "playInApp": "Lejátszás a beépített lejátszóban", "unavailableTitle": "Ez itt nem játszható le", "unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.", - "embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az auto-generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.", + "embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az automatikusan generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.", "openOnYoutube": "Megnyitás YouTube-on", "shortcutsHint": "Kattintás: lejátszás/szünet · Görgő vagy ↑ ↓: hangerő · F: teljes ablak", "exitMaximize": "Kilépés a teljes ablakból (Esc)", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 4d3771aa..ce7cec69 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -1,5 +1,5 @@ { - "title": "Lejátszási listák", + "title": "Listák", "manageGroup": "Kezelés", "listGroup": "Listáid", "watchLater": "Megnézem később", @@ -61,11 +61,11 @@ "dirAsc": "Növekvő", "dirDesc": "Csökkenő", "groupByChannel": "Csoportosítás csatorna szerint", - "railSortCustom": "Egyéni sorrend", + "railSortCustom": "Saját sorrend", "railSortName": "Név", "railSortCount": "Videók száma", "railSortDuration": "Összhossz", - "railSortRecent": "Utoljára módosított", + "railSortRecent": "Módosítás", "dirtyFirst": "Nem szinkronizáltak elöl", "colTitle": "Cím", "colChannel": "Csatorna", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 0f92d39b..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.", @@ -30,7 +30,7 @@ "nameTaken": "Már létezik ilyen nevű kollekció", "newHint": "Válaszd ki az első címet — egy kollekcióhoz legalább egy elem kell.", "createWith": "Létrehozás", - "deleteFailed": "Nem sikerült törölni a gyűjteményt" + "deleteFailed": "Nem sikerült törölni a kollekciót" }, "searchPlaceholder": "Keresés a Plexben…", "searchField": { @@ -60,7 +60,7 @@ "movie": "Filmek", "show": "Sorozatok", "playlists": "Listák", - "collections": "Gyűjtemények" + "collections": "Kollekciók" }, "show": "Megjelenítés", "showOpt": { @@ -103,7 +103,7 @@ "playable": "Lejátszás", "playableOpt": { "direct": "Közvetlen lejátszás", - "remux": "Közvetlen streamelés", + "remux": "Közvetlen közvetítés", "transcode_hw": "Átkódolás · HW", "transcode_sw": "Átkódolás · SW" } @@ -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": { @@ -161,10 +164,10 @@ "player": { "loading": "Betöltés…", "buffering": "Pufferelés…", - "preparing": "Stream előkészítése…", + "preparing": "Lejátszás előkészítése…", "back": "Vissza", "close": "Bezárás", - "skipIntro": "Intro átugrása", + "skipIntro": "Intró átugrása", "skipCredits": "Stáblista átugrása", "playPause": "Lejátszás / szünet (szóköz)", "restart": "Újra az elejéről", @@ -181,7 +184,7 @@ "subtitles": "Felirat", "subOff": "Ki", "errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.", - "errTranscode": "Ennek a fájlnak a formátuma transzkódolást igényel, ami még nem támogatott.", + "errTranscode": "Ennek a fájlnak a formátuma átkódolást igényel, ami még nem támogatott.", "errGeneric": "Nem sikerült elindítani a lejátszást. Próbáld újra.", "help": "Billentyűparancsok", "infoBtn": "Média infó", @@ -235,9 +238,9 @@ "clockDate": "Dátum", "subShadow": "Árnyék", "subShadowColor": "Árnyék színe", - "autoSkipIntro": "Intro auto-átugrás", - "autoSkipCredits": "Stáblista auto-átugrás", - "autoSkipDelay": "Auto-skip késleltetés" + "autoSkipIntro": "Intró automatikus átugrása", + "autoSkipCredits": "Stáblista automatikus átugrása", + "autoSkipDelay": "Automatikus átugrás késleltetése" }, "info": { "title": "Média infó", @@ -285,7 +288,7 @@ }, "playable": { "direct": "Közvetlenül játszható a böngészőben", - "remux": "Közvetlen streamelés — könnyű remux (nincs videó-újrakódolás)", + "remux": "Közvetlen közvetítés — könnyű remux (nincs videó-újrakódolás)", "transcode_hw": "Hardveres kódolóval átkódolva", "transcode_sw": "Szoftveresen átkódolva (legnehezebb)" }, @@ -310,14 +313,13 @@ "rename": "Átnevezés", "playAll": "Összes lejátszása", "delete": "Lista törlése", - "empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.", + "empty": "Ez a lista üres. Adj hozzá címeket az infóoldalukról.", "layoutLabel": "Elrendezés", "layoutAccordion": "Harmonika nézet", "layoutTree": "Fa nézet", "removeShow": "Egész sorozat eltávolítása", "removeSeason": "Egész évad eltávolítása", "episodes": "{{count}} epizód", - "season": "{{n}}. évad", "seasonUnknown": "Epizódok", "addShow": "Egész sorozat listához adása", "addSeason": "Évad listához adása", @@ -349,7 +351,9 @@ "born": "Született", "died": "Elhunyt", "aka": "Más néven", - "knownFor": "Ismert szerepei" + "knownFor": "Ismert szerepei", + "bioLoading": "Az életrajz betöltése", + "bioFailed": "Az életrajzot most nem sikerült betölteni" }, "view": { "label": "Nézet", diff --git a/frontend/src/i18n/locales/hu/quotaActions.json b/frontend/src/i18n/locales/hu/quotaActions.json index a4226012..2321cb7c 100644 --- a/frontend/src/i18n/locales/hu/quotaActions.json +++ b/frontend/src/i18n/locales/hu/quotaActions.json @@ -4,16 +4,16 @@ "playlists_pull": "Lejátszási listák szinkronizálása", "playlists_push": "Lejátszási listák feltöltése", "playlists_delete": "Lejátszási lista törlése", - "videos_backfill_recent": "Friss feltöltések betöltése", - "videos_backfill_full": "Teljes előzmény betöltése", + "videos_backfill_recent": "Friss feltöltések behúzása", + "videos_backfill_full": "Teljes előzmény behúzása", "videos_feed_fallback": "Feed-pótlás (RSS kiesés)", - "videos_enrich": "Videó-gazdagítás", + "videos_enrich": "Videóadatok dúsítása", "videos_lookup": "Videó-lekérdezés", "videos_search": "Élő keresés", - "channels_discover": "Csatorna-felfedezés", + "channels_discover": "Csatorna-felfedezés a listákból", "channels_subscribe": "Feliratkozás", "channels_unsubscribe": "Leiratkozás", "maintenance_revalidate": "Karbantartó újraellenőrzés", "other": "Egyéb", - "channels_explore": "Csatorna-felfedezés" + "channels_explore": "Nem követett csatorna megnyitása" } diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 0ec76b9b..06755b52 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -5,7 +5,7 @@ "running": "Fut", "paused": "Szüneteltetve", "notHere": "Ebben a példányban nem fut", - "notHereNote": "Az ütemező a szerveren fut. Ez a példány a közös sort és kvótát mutatja, de az élő job-aktivitás csak ott látszik, ahol az ütemező fut.", + "notHereNote": "Az ütemező a szerveren fut. Ez a példány a közös sort és kvótát mutatja, de az élő feladat-aktivitás csak ott látszik, ahol az ütemező fut.", "updating": "frissítés…", "pause": "Szüneteltetés", "resume": "Folytatás", @@ -22,7 +22,7 @@ "nextRun": "köv.", "quotaUsed": "{{used}} / {{budget}} egység ma felhasználva", "quotaRemaining": "{{formatted}} maradt", - "quotaNote": "Közös YouTube Data API-keret; éjfélkor (US Pacific) nullázódik. A backfill hátrébb sorol, hogy maradjon hely az interaktív használatnak.", + "quotaNote": "Közös YouTube Data API-keret; éjfélkor (US Pacific) nullázódik. A videók behúzása hátrébb sorol, hogy maradjon hely az interaktív használatnak.", "minutes": "perc", "range": "{{min}} és {{max}} között lehet", "editInterval": "Kattints a gyakoriság módosításához", @@ -50,8 +50,8 @@ "maintenance": { "title": "Karbantartás", "batchLabel": "Futásonként újraellenőrzött videók", - "batchHint": "Hány, legrégebben ellenőrzött videót ellenőriz újra a karbantartó job futásonként. Magasabb = gyorsabb teljes ciklus, de több API-kvóta (50 videónként 1 egység).", - "batchNote": "Alapérték: {{default}}. ~233k videó ÷ ez = a teljes újraellenőrzési ciklus napokban." + "batchHint": "Hány, legrégebben ellenőrzött videót ellenőriz újra a karbantartó feladat futásonként. Magasabb = gyorsabb teljes ciklus, de több API-kvóta (50 videónként 1 egység).", + "batchNote": "Alapérték: {{default}}. ~233 ezer videó ÷ ez = a teljes újraellenőrzési ciklus napokban." }, "dot": { "running": "most fut", @@ -61,7 +61,7 @@ "skipped": "kihagyva (szinkron szüneteltetve)" }, "jobDesc": { - "rss_poll": "Az egyes csatornák RSS-feedjét nézi új feltöltésekért (ingyenes, nem fogyaszt kvótát). Ha leáll, az új videók csak később jelennek meg, amikor egy backfill behúzza őket — így a Feed lemarad a YouTube mögött.", + "rss_poll": "Az egyes csatornák RSS-feedjét nézi új feltöltésekért (ingyenes, nem fogyaszt kvótát). Ha leáll, az új videók csak később jelennek meg, amikor a videók behúzása eléri őket — így a Feed lemarad a YouTube mögött.", "enrich": "Lekéri a videók adatait (hossz, megtekintés, élő/Shorts jelzők), és frissíti az élő videókat, amíg véget nem érnek. Ha leáll, a videók hossz/statisztika nélkül maradnak, a véget ért adások pedig „élő” állapotban ragadnak.", "backfill": "Behúzza a friss feltöltéseket, majd a kijelölt csatornák teljes előzményét, a közös kvótán belül. Ha leáll, a régebbi videók és a teljes előzmény nem töltődik fel.", "autotag": "Felismeri a csatornák nyelvét/témáit, és automatikus címkéket ad. Ha leáll, az új csatornák címke nélkül maradnak, így a nyelv/téma szűrők kihagyják őket.", @@ -72,31 +72,33 @@ "maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a Feedből, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.", "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ő sync 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_reconcile": "Teljes megtekintés-állapot reconcile, ami 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 végzett un-watch nem tükröződik vissza.", - "audit_gc": "Törli a beállított megőrzési időnél (Konfiguráció → Auditnapló) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul.", + "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, 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." + "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)", "enrich": "Adatdúsítás + élő frissítés", - "backfill": "Backfill (friss + teljes előzmény)", + "backfill": "Videók behúzása (friss + teljes előzmény)", "autotag": "Automatikus címkézés", "shorts": "Shorts-besorolás", "subscriptions": "Feliratkozások szinkronizálása a YouTube-ról", "playlist_sync": "Lejátszási listák szinkronizálása a YouTube-ról", "maintenance": "Karbantartás + ellenőrzés", - "demo_reset": "Demo fiók visszaállítása", + "demo_reset": "Demó fiók visszaállítása", "explore_cleanup": "Felfedezett csatornák takarítása", "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": "Auditnapló-takarítás", - "log_gc": "Naplótakarítás" + "audit_gc": "Eseménynapló-takarítás", + "log_gc": "Naplótakarítás", + "people_enrich": "Személyadatok és portrék (Plex)" }, "queue": { "recentPending": "Szinkronra váró csatorna", @@ -104,7 +106,7 @@ "deepPending": "Teljes előzmény függőben", "deepPendingHint": "Teljes előzményre kijelölt csatornák, amelyek még nincsenek kész.", "enrichPending": "Dúsításra váró videó", - "enrichPendingHint": "Videók, amelyek még adatokra várnak (hossz, statisztikák, live/short besorolás).", + "enrichPendingHint": "Videók, amelyek még adatokra várnak (hossz, statisztikák, élő/short besorolás).", "shortsPending": "Besorolásra váró Shorts", "shortsPendingHint": "Már dúsított videók, amelyek még a Shorts-próbára várnak.", "liveRefresh": "Frissítendő élő", @@ -113,5 +115,76 @@ "purgeDiscovery": "Felfedezés ürítése", "purgeDiscoveryHint": "Most törli az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.", "purgeConfirmTitle": "Felfedezés-tartalom törlése?", - "purgedDiscovery": "{{count}} felfedezés-elem törölve" + "purgedDiscovery": "{{count}} felfedezés-elem törölve", + "result": { + "new": "új videó", + "failed": "sikertelen", + "fallback": "API-n át olvasva", + "channels_processed": "feldolgozott csatorna", + "videos_added": "hozzáadott videó", + "reconciled_full": "teljesen egyeztetve", + "reason": "indok", + "channels_tagged": "címkézett csatorna", + "orphan_tags_removed": "törölt árva címke", + "probed": "ellenőrizve", + "shorts": "Shorts", + "users": "felhasználó", + "playlists_synced": "szinkronizált lejátszási lista", + "rechecked": "újraellenőrizve", + "recovered": "helyreállt", + "deleted": "törölve", + "notified": "értesítve", + "revalidated": "újraérvényesítve", + "flagged": "megjelölve", + "skipped": "kihagyva", + "playlists_seeded": "létrehozott lejátszási lista", + "expired": "lejárt", + "videos_deleted": "törölt videó", + "channels_deleted": "törölt csatorna", + "search_videos_deleted": "törölt keresési találat", + "warned": "figyelmeztetve", + "evicted": "helyhiány miatt törölve", + "reaped": "eltakarítva", + "swept": "kisöpörve", + "libraries": "könyvtár", + "movies": "film", + "shows": "sorozat", + "seasons": "évad", + "episodes": "epizód", + "collections": "kollekció", + "overlaid": "magyar átfedés", + "pruned": "eltávolítva", + "people_enriched": "dúsított személy", + "media_probed": "vizsgált média", + "people_pruned": "eltávolított személy", + "error": "hiba", + "links": "kapcsolat", + "watched": "megnézve", + "in_progress": "folyamatban", + "repushed": "újraküldve", + "movie_probes": "filmvizsgálat", + "applied": "alkalmazva", + "cleared": "törölve", + "scanned": "átvizsgálva", + "enriched": "dúsítva", + "countries_coded": "országkód pótolva", + "sessions_closed": "lezárt munkamenet", + "sessions_reaped": "eltakarított munkamenet", + "recent": "friss", + "deep": "mély", + "bios": "életrajzok", + "portraits": "portrék", + "cached": "elmentve" + }, + "skip": { + "disabled": "kikapcsolva", + "PlexserverURLortokenisnotconfigured": "a Plex-szerver címe vagy tokenje nincs beállítva", + "tmdbapikeynotset": "nincs TMDB API-kulcs", + "nodemoaccount": "nincs demófiók", + "noserviceuser": "nincs szolgáltatásfiók", + "syncpaused": "a szinkron szünetel", + "lowquota": "kevés a kvóta", + "noreadscope": "nincs YouTube-olvasási jogosultság", + "busy": "egy másik szinkron fut" + } } diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index cf7e648e..5585ae23 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -73,7 +73,7 @@ "deleteTitle": "Törlöd a fiókodat?", "deleteConfirm": "Ez véglegesen törli a fiókodat és minden adatodat (feliratkozások, címkék, megnézett/mentett/elrejtett állapot, lejátszási listák, beállítások). Nem vonható vissza.", "deleteConfirmButton": "Minden törlése", - "demoNotice": "Ez a közös demo fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös Könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz.", + "demoNotice": "Ez a közös demó fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös Könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz.", "signInMethods": "Bejelentkezési módok", "googleLink": { "title": "Google-fiók", @@ -133,25 +133,25 @@ "loadError": "Nem sikerült betölteni a letöltési hitelesítési beállításokat" }, "demo": { - "title": "Demo hozzáférés", - "intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demo fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.", + "title": "Demó hozzáférés", + "intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demó fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.", "addPlaceholder": "E-mail fehérlistára a demóhoz…", "add": "Hozzáadás", - "added": "{{email}} hozzáadva a demo fehérlistához", + "added": "{{email}} hozzáadva a demó fehérlistához", "addFailed": "Nem sikerült hozzáadni ezt az e-mailt", "removeFailed": "Nem sikerült eltávolítani ezt az e-mailt", "remove": "Eltávolítás", - "removeHint": "E-mail eltávolítása a demo fehérlistáról.", + "removeHint": "E-mail eltávolítása a demó fehérlistáról.", "addedBy": "hozzáadta: {{who}}", - "reset": "Demo állapot visszaállítása", - "resetHint": "Törli a demo fiók lejátszási listáit, elrejtett/megnézett/mentett videóit és beállításait, majd újra létrehoz pár minta lejátszási listát.", - "resetTitle": "Visszaállítod a demo állapotot?", - "resetConfirm": "Ez törli mindazt, amit a demo fiók összegyűjtött — lejátszási listák, elrejtett/megnézett/mentett videók és beállítások —, és újra létrehoz pár minta lejátszási listát. Nem vonható vissza.", - "resetDone": "Demo visszaállítva — {{count}} minta lejátszási lista létrehozva", - "resetFailed": "Nem sikerült visszaállítani a demo fiókot", - "removed": "Eltávolítva a demo fehérlistáról", - "removeTitle": "Visszavonod a demo hozzáférést?", - "removeConfirm": "Eltávolítod {{email}} címet a demo fehérlistáról? Többé nem tud belépni a demo fiókba." + "reset": "Demó állapot visszaállítása", + "resetHint": "Törli a demó fiók lejátszási listáit, elrejtett/megnézett/mentett videóit és beállításait, majd újra létrehoz pár minta lejátszási listát.", + "resetTitle": "Visszaállítod a demó állapotot?", + "resetConfirm": "Ez törli mindazt, amit a demó fiók összegyűjtött — lejátszási listák, elrejtett/megnézett/mentett videók és beállítások —, és újra létrehoz pár minta lejátszási listát. Nem vonható vissza.", + "resetDone": "Demó visszaállítva — {{count}} minta lejátszási lista létrehozva", + "resetFailed": "Nem sikerült visszaállítani a demó fiókot", + "removed": "Eltávolítva a demó fehérlistáról", + "removeTitle": "Visszavonod a demó hozzáférést?", + "removeConfirm": "Eltávolítod {{email}} címet a demó fehérlistáról? Többé nem tud belépni a demó fiókba." }, "invites": { "title": "Hozzáférési kérelmek", @@ -183,16 +183,20 @@ "denyTitle": "Elutasítod a hozzáférési kérelmet?", "denyConfirm": "Elutasítod {{email}} hozzáférési kérelmét?", "delete": "Törlés", - "deleteLabel": "{{email}} törlése a whitelistáról", + "deleteLabel": "{{email}} törlése a fehérlistáról", "deleteHint": "A cím elfelejtése. Egy későbbi kérelme új kérelemként jelenik meg.", - "deleteTitle": "Törlöd a whitelista-bejegyzést?", + "deleteTitle": "Törlöd a fehérlista-bejegyzést?", "deleteConfirm": "Elfelejted {{email}} címet? A Siftlode eldobja a bejegyzést, így az adott címről újra kérelmezni kell.", "deleteHasAccount": "Erre a címre már regisztrált valaki — a Felhasználók és szerepek fülön kezelhető, ahol a fiók törlése ezt a bejegyzést is eltávolítja.", - "deleted": "Whitelista-bejegyzés törölve", + "deleted": "Fehérlista-bejegyzés törölve", "deleteFailed": "Nem sikerült törölni a bejegyzést", "emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az Értesítéseket figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.", "deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál.", - "deleteConfirmStillAllowed": "Elfelejted {{email}} címet? Figyelem: ezt a címet a szerver konfigurációja is engedélyezi (ALLOWED_EMAILS / ADMIN_EMAILS), így a bejegyzés törlése NEM veszi el a hozzáférését — sőt egy ADMIN_EMAILS cím a következő Google-belépéskor újra adminná válik." + "deleteConfirmStillAllowed": "Elfelejted {{email}} címet? Figyelem: ezt a címet a szerver konfigurációja is engedélyezi (ALLOWED_EMAILS / ADMIN_EMAILS), így a bejegyzés törlése NEM veszi el a hozzáférését — sőt egy ADMIN_EMAILS cím a következő Google-belépéskor újra adminná válik.", + "status": { + "approved": "Jóváhagyva", + "denied": "Elutasítva" + } }, "diagnostics": { "title": "Naplózási szint", @@ -222,7 +226,7 @@ }, "status": "{{level}} naplózás · {{time}} múlva visszaáll „Alap” szintre", "revert": "Visszaállítás most", - "demo": "A demo fiókot többen használják, ezért itt nem kapcsolható be részletes naplózás.", + "demo": "A demó fiókot többen használják, ezért itt nem kapcsolható be részletes naplózás.", "failed": "Nem sikerült módosítani a naplózási szintet" }, "myLog": { @@ -234,7 +238,7 @@ "all": "Mind", "error": "Hibák", "warning": "Figyelmeztetések", - "info": "Info", + "info": "Infó", "detail": "Részletes" }, "count_one": "{{count}} esemény · a legújabb felül", diff --git a/frontend/src/i18n/locales/hu/setup.json b/frontend/src/i18n/locales/hu/setup.json index f2113f6e..e1b8e5ed 100644 --- a/frontend/src/i18n/locales/hu/setup.json +++ b/frontend/src/i18n/locales/hu/setup.json @@ -21,7 +21,7 @@ }, "smtp": { "title": "E-mail / SMTP (opcionális)", - "desc": "Állíts be egy SMTP-szervert, hogy a Siftlode tudjon verifikációs, reset- és értesítő e-mailt küldeni. Hagyd üresen a hostot a kihagyáshoz — e-mail nélkül a regisztrációkat az admin hagyja jóvá automatikusan.", + "desc": "Állíts be egy SMTP-szervert, hogy a Siftlode tudjon megerősítő, jelszó-visszaállító és értesítő e-mailt küldeni. Hagyd üresen a hostot a kihagyáshoz — e-mail nélkül a regisztrációkat az admin hagyja jóvá automatikusan.", "host": "SMTP-host (pl. smtp.gmail.com)", "port": "Port", "user": "Felhasználónév", @@ -43,8 +43,8 @@ "finish": "Befejezés" }, "token": { - "title": "Setup-link szükséges", - "body": "Nyisd meg a varázslót a konténer logjaiba az első indításkor kiírt linkkel (így néz ki: …/setup?token=…). Ez azért kell, hogy csak az üzemeltető tudja beállítani a példányt." + "title": "Telepítési link szükséges", + "body": "Nyisd meg a varázslót a konténer naplójába az első indításkor kiírt linkkel (így néz ki: …/setup?token=…). Ez azért kell, hogy csak az üzemeltető tudja beállítani a példányt." }, "genericError": "Valami hiba történt — próbáld újra" } diff --git a/frontend/src/i18n/locales/hu/stats.json b/frontend/src/i18n/locales/hu/stats.json index fc559d08..3a325ebf 100644 --- a/frontend/src/i18n/locales/hu/stats.json +++ b/frontend/src/i18n/locales/hu/stats.json @@ -8,7 +8,7 @@ "rangeLabel": "Időtartomány", "introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ", "introSystem": "Rendszer", - "introAfter": " a megosztott háttérmunka (ütemezett letöltés, gazdagítás, újraszinkronizálás), amely nem köthető egy személyhez.", + "introAfter": " a megosztott háttérmunka (ütemezett behúzás, dúsítás, újraszinkronizálás), amely nem köthető egy személyhez.", "loading": "Betöltés…", "noData": "Nincs adat.", "dailyTotal": "Napi összesen ({{d}} · {{units}} egység)", @@ -22,13 +22,13 @@ "recentSynced": "Friss szinkronizálva", "recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a Feededben.", "fullHistory": "Teljes előzmény", - "fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma letöltődött, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).", + "fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma be van húzva, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).", "fullHistoryEta": "Teljes előzmény becsült ideje", - "fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltéséhez, a megosztott napi kvóta alapján.", + "fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének behúzásához, a megosztott napi kvóta alapján.", "myVideos": "Saját videók", "myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.", "quotaLeft": "Ma maradt kvóta", - "quotaLeftHint": "A ma fennmaradó megosztott YouTube API-keret (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A letöltés ennek alárendelődik.", + "quotaLeftHint": "A ma fennmaradó megosztott YouTube API-keret (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A behúzás ennek alárendelődik.", "loading": "Betöltés…", "apiUsage": "Saját API-használat", "today": "Ma", @@ -41,8 +41,8 @@ "actions": "Műveletek", "syncSubscriptions": "Feliratkozások szinkronizálása a YouTube-ról", "syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.", - "backfillEverything": "Minden letöltése", - "backfillEverythingHint": "Teljes archívum letöltésének kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.", + "backfillEverything": "Minden videó behúzása", + "backfillEverythingHint": "A teljes archívum behúzásának kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.", "synced": "{{count}} feliratkozás szinkronizálva", "syncFailed": "Nem sikerült szinkronizálni a feliratkozásokat", "fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához", diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json index 71272888..b9718050 100644 --- a/frontend/src/i18n/locales/hu/users.json +++ b/frontend/src/i18n/locales/hu/users.json @@ -1,8 +1,8 @@ { "tabs": { "roles": "Felhasználók és szerepek", - "access": "Hozzáférési kérések", - "demo": "Demo" + "access": "Hozzáférési kérelmek", + "demo": "Demó" }, "pendingAccess": { "title": "Regisztrált, de még kívül van", @@ -26,14 +26,14 @@ "empty": "Még nincs felhasználó.", "you": "te", "admin": "Admin", - "demo": "Demo", + "demo": "Demó", "suspended": "Felfüggesztve", "pending": "Jóváhagyásra vár", "unverified": "Nem igazolt e-mail", "lastLogin": "Utolsó belépés: {{time}}", "lastLoginNone": "Nincs még rögzített belépés", - "lastLoginDemo": "A demo fióknál nem rögzítjük", - "demoLocked": "A demo fiók szerepe nem módosítható.", + "lastLoginDemo": "A demó fióknál nem rögzítjük", + "demoLocked": "A demó fiók szerepe nem módosítható.", "selfLocked": "A saját szerepedet nem módosíthatod.", "makeAdmin": "Adminná tesz", "makeUser": "Felhasználóvá tesz", @@ -53,7 +53,7 @@ "undoConfirm": "Feloldod a felfüggesztést: {{email}}? Újra be tud majd lépni.", "done": "{{email}} felfüggesztve", "undone": "{{email}} felfüggesztése feloldva", - "demoLocked": "A demo fiók nem függeszthető fel.", + "demoLocked": "A demó fiók nem függeszthető fel.", "selfLocked": "A saját fiókodat nem függesztheted fel.", "failed": "Nem sikerült módosítani a felfüggesztést" }, @@ -86,8 +86,8 @@ "invite": "Meghívó", "lastLogin": "Utolsó belépés", "lastLoginNone": "még nincs rögzítve", - "lastLoginDemo": "a demo fióknál nem rögzítjük", - "inviteMissing": "ez a cím nincs az engedélyezési listán", + "lastLoginDemo": "a demó fióknál nem rögzítjük", + "inviteMissing": "ez a cím nincs a fehérlistán", "linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.", "mailOff": "nincs beállítva levelezés", "open": "Fiókpanel megnyitása", diff --git a/frontend/src/i18n/locales/hu/vocab.json b/frontend/src/i18n/locales/hu/vocab.json new file mode 100644 index 00000000..36efe96e --- /dev/null +++ b/frontend/src/i18n/locales/hu/vocab.json @@ -0,0 +1,169 @@ +{ + "crewJob": { + "Director": "Rendező", + "Writer": "Író", + "Screenplay": "Forgatókönyv", + "Story": "Történet", + "Producer": "Producer", + "ExecutiveProducer": "Vezető producer", + "Editor": "Vágó", + "DirectorofPhotography": "Operatőr", + "OriginalMusicComposer": "Zeneszerző", + "Creator": "Alkotó" + }, + "department": { + "Acting": "Színészet", + "Directing": "Rendezés", + "Writing": "Írás", + "Production": "Produkció", + "Sound": "Hang", + "Camera": "Operatőri munka", + "Editing": "Vágás", + "Art": "Látványtervezés", + "CostumeMakeUp": "Jelmez és smink", + "Crew": "Stáb", + "VisualEffects": "Vizuális effektek", + "Lighting": "Világítás", + "Creator": "Alkotó" + }, + "autoTag": { + "English": "Angol", + "Hungarian": "Magyar", + "German": "Német", + "French": "Francia", + "Spanish": "Spanyol", + "Italian": "Olasz", + "Portuguese": "Portugál", + "Dutch": "Holland", + "Russian": "Orosz", + "Polish": "Lengyel", + "Romanian": "Román", + "Czech": "Cseh", + "Slovak": "Szlovák", + "Turkish": "Török", + "Swedish": "Svéd", + "Danish": "Dán", + "Norwegian": "Norvég", + "Finnish": "Finn", + "Japanese": "Japán", + "Korean": "Koreai", + "Chinese": "Kínai", + "Hindi": "Hindi", + "Arabic": "Arab", + "Ukrainian": "Ukrán", + "Greek": "Görög", + "Gaming": "Videojátékok", + "Music": "Zene", + "Cooking": "Főzés", + "Tech": "Technika", + "Education": "Oktatás", + "Sports": "Sport", + "Film": "Film", + "TV": "TV", + "Politics": "Politika", + "HealthFitness": "Egészség és fitnesz", + "Vehicles": "Járművek", + "Animals": "Állatok", + "FashionBeauty": "Divat és szépség", + "Travel": "Utazás", + "Comedy": "Humor", + "Entertainment": "Szórakozás", + "Business": "Üzlet", + "Science": "Tudomány", + "News": "Hírek", + "Society": "Társadalom", + "PerformingArts": "Előadóművészet", + "Military": "Katonaság", + "Religion": "Vallás", + "Hobbies": "Hobbik" + }, + "profile": { + "720pMP4": "720p (MP4)", + "1080pMP4": "1080p (MP4)", + "BestMP4": "Legjobb (MP4)", + "AudioM4A": "Hang (M4A)", + "AudioMP3": "Hang (MP3)", + "VideoonlyMP4": "Csak videó (MP4)" + }, + "topic": { + "Music": "Zene", + "Christianmusic": "Keresztény zene", + "Classicalmusic": "Klasszikus zene", + "Countrymusic": "Country", + "Electronicmusic": "Elektronikus zene", + "Hiphopmusic": "Hiphop", + "Independentmusic": "Független zene", + "Jazz": "Jazz", + "MusicofAsia": "Ázsiai zene", + "MusicofLatinAmerica": "Latin-amerikai zene", + "Popmusic": "Popzene", + "Reggae": "Reggae", + "Rhythmandblues": "R&B", + "Rockmusic": "Rockzene", + "Soulmusic": "Soul", + "Videogameculture": "Videojáték-kultúra", + "Actiongame": "Akciójáték", + "Actionadventuregame": "Akció-kalandjáték", + "Casualgame": "Casual játék", + "Musicvideogame": "Zenei videojáték", + "Puzzlevideogame": "Logikai játék", + "Racingvideogame": "Autóversenyzős játék", + "Roleplayingvideogame": "Szerepjáték", + "Simulationvideogame": "Szimulátor", + "Sportsgame": "Sportjáték", + "Strategyvideogame": "Stratégiai játék", + "Sport": "Sport", + "Americanfootball": "Amerikai futball", + "Associationfootball": "Labdarúgás", + "Baseball": "Baseball", + "Basketball": "Kosárlabda", + "Boxing": "Ökölvívás", + "Cricket": "Krikett", + "Golf": "Golf", + "Icehockey": "Jégkorong", + "Mixedmartialarts": "MMA", + "Motorsport": "Motorsport", + "Professionalwrestling": "Pankráció", + "Tennis": "Tenisz", + "Volleyball": "Röplabda", + "Entertainment": "Szórakozás", + "Humour": "Humor", + "Film": "Film", + "Performingarts": "Előadóművészet", + "Televisionprogram": "Tévéműsor", + "Lifestylesociology": "Életmód", + "Fashion": "Divat", + "Physicalfitness": "Fitnesz", + "Food": "Gasztronómia", + "Hobby": "Hobbi", + "Pet": "Háziállatok", + "Physicalattractiveness": "Szépség", + "Technology": "Technológia", + "Tourism": "Turizmus", + "Vehicle": "Járművek", + "Society": "Társadalom", + "Business": "Üzlet", + "Health": "Egészség", + "Military": "Katonaság", + "Politics": "Politika", + "Religion": "Vallás", + "Knowledge": "Tudás" + }, + "region": { + "AN": "Holland Antillák", + "SU": "Szovjetunió", + "XC": "Csehszlovákia", + "XG": "Német Demokratikus Köztársaság", + "XI": "Észak-Írország", + "YU": "Jugoszlávia", + "ZR": "Zaire" + }, + "track": { + "forced": "kényszerített", + "sdh": "hallássérülteknek", + "stereo": "sztereó", + "mono": "monó", + "audioN": "{{n}}. hangsáv", + "subtitleN": "{{n}}. felirat" + } +} diff --git a/frontend/src/i18n/locales/hu/welcome.json b/frontend/src/i18n/locales/hu/welcome.json index 4f6b3ca8..baebf1e9 100644 --- a/frontend/src/i18n/locales/hu/welcome.json +++ b/frontend/src/i18n/locales/hu/welcome.json @@ -99,7 +99,7 @@ "resendCta": "Új link küldése", "resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.", "oauthCancelled": "A Google-belépés megszakadt. Próbáld újra, vagy lépj be e-maillel és jelszóval.", - "accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.", + "accessRequested": "Köszönjük — rögzítettük a kérelmedet. Egy admin felülvizsgálja.", "accessRequestedFor": "Rögzítettük a hozzáférési kérelmedet erre a címre:", "accessRequestedReview": "Egy admin átnézi, és e-mailben szólunk a döntésről.", "accessLinked": "Ehhez a postafiókhoz már volt kérelmed, csak más írásmóddal:", diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index eae19490..7441b2ce 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -190,6 +190,8 @@ function logParams(query: LogQuery, extra: Record { 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..ea60bdbc 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -73,7 +73,8 @@ export interface PlexTmdb { keywords: string[]; crew: PlexTmdbCrew[]; studios: string[]; - production_countries: string[]; + // ISO code (null on a row enriched before E30 whose name TMDB's list lacked) + TMDB's English name. + production_countries: { code: string | null; name: string }[]; recommendations: { tmdb_id?: number | null; title?: string | null; in_library: boolean }[]; backdrops: string[]; } @@ -132,6 +133,16 @@ export interface PlexMarker { start_s: number; end_s: number; } +// What the player names an audio/subtitle track from (E30, `lib/vocab` trackLabel). Plex's own title +// (`label`) is in the Plex SERVER's language, so it is only the fallback for a track with no language tag. +export interface PlexTrackNaming { + ord: number; + label: string | null; + lang_tag?: string | null; + forced?: boolean; + hearing_impaired?: boolean; + detail?: string | null; // the codec tail, "AC3 5.1" +} export interface PlexItemDetail { id: string; kind: "movie" | "episode"; @@ -157,15 +168,13 @@ export interface PlexItemDetail { studio?: string | null; tagline?: string | null; markers: PlexMarker[]; - audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[]; + audio_streams: (PlexTrackNaming & { language?: string | null; default: boolean })[]; // `text`=true → offered as a WebVTT ; image subs (PGS/VobSub) are text=false (hidden). - subtitle_streams: { - ord: number; - label: string; + subtitle_streams: (PlexTrackNaming & { language?: string | null; codec?: string; text: boolean; - }[]; + })[]; status: string; position_seconds: number; show_title?: string | null; @@ -230,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; @@ -495,12 +507,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 +565,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 +650,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/channelOrder.dom.test.ts b/frontend/src/lib/channelOrder.dom.test.ts new file mode 100644 index 00000000..479b8751 --- /dev/null +++ b/frontend/src/lib/channelOrder.dom.test.ts @@ -0,0 +1,72 @@ +// @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)), + ).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"], + ); + }); + + 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([ + "x", + "y", + ]); + }); + + 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"]); + }); + + it("returns an unknown id's list unchanged", () => { + 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"]); + 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", + ]); + }); + + 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"]); + }); +}); + +describe("priorityChoices", () => { + it("offers the fixed steps, and an off-scale current value in its place", () => { + expect(priorityChoices(3)).toEqual([10, 5, 3, 2, 1, 0]); + expect(priorityChoices(7)).toEqual([10, 7, 5, 3, 2, 1, 0]); + expect(priorityChoices(-2)).toEqual([10, 5, 3, 2, 1, 0, -2]); + }); +}); diff --git a/frontend/src/lib/channelOrder.test.ts b/frontend/src/lib/channelOrder.test.ts deleted file mode 100644 index 3805f51c..00000000 --- a/frontend/src/lib/channelOrder.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -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); - -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"))).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"))).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"))).toEqual(["z", "x", "y"]); - expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y"))).toEqual(["x", "y"]); - }); - - 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"))).toEqual(["r", "p", "q"]); - }); - - it("returns an unknown id's list unchanged", () => { - expect(ids(placeByPriority(list, "nope"))).toEqual(ids(list)); - }); -}); - -describe("priorityChoices", () => { - it("offers the fixed steps, and an off-scale current value in its place", () => { - expect(priorityChoices(3)).toEqual([10, 5, 3, 2, 1, 0]); - expect(priorityChoices(7)).toEqual([10, 7, 5, 3, 2, 1, 0]); - expect(priorityChoices(-2)).toEqual([10, 5, 3, 2, 1, 0, -2]); - }); -}); diff --git a/frontend/src/lib/channelOrder.ts b/frontend/src/lib/channelOrder.ts index 1a2f1192..5eb877f0 100644 --- a/frontend/src/lib/channelOrder.ts +++ b/frontend/src/lib/channelOrder.ts @@ -1,3 +1,6 @@ +import { compareText } from "./collate"; +import { serverOrderLang } from "./saveLanguage"; + // The channel manager's list order, and the priority steps its menu offers (E28 S3e3b). /** The steps a channel's menu offers (owner, 2026-09-26): fixed levels, so bubbling a channel up @@ -19,23 +22,29 @@ interface Ordered { title?: string | null; } -/** The server's order (`routes/channels.py`: priority desc, then the title case-folded, a missing - * title last). */ -function before(a: Ordered, b: Ordered): boolean { +/** The server's order (`routes/channels.py`: priority desc, then the title in the reader's alphabet — + * the ICU collation of `app/collation.py`, which `compareText` shares — a missing title last). */ +function before(a: Ordered, b: Ordered, lang: string): boolean { if (a.priority !== b.priority) return a.priority > b.priority; if (a.title == null) return false; if (b.title == null) return true; - return a.title.toLowerCase() < b.title.toLowerCase(); + return compareText(a.title, b.title, lang) < 0; } /** `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. */ -export function placeByPriority(rows: readonly T[], id: string): T[] { + * 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, + 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)); + const at = rest.findIndex((r) => before(moved, r, lang)); rest.splice(at < 0 ? rest.length : at, 0, moved); return rest; } diff --git a/frontend/src/lib/collate.ts b/frontend/src/lib/collate.ts new file mode 100644 index 00000000..e829818c --- /dev/null +++ b/frontend/src/lib/collate.ts @@ -0,0 +1,13 @@ +// Alphabetical order in the UI language (E30 S3): Hungarian puts "Ö" after "O" and "Cs" after "Cu". A +// bare `localeCompare` compares in the BROWSER's locale; the server sorts by the same ICU rules +// (backend `app/collation.py`), so a list sorted here agrees with one the server sorted. Its own +// module, apart from lib/format.ts, because it needs no i18n: the pure sort helpers import it. +const collators = new Map(); +export function compareText(a: string, b: string, lang: string): number { + let c = collators.get(lang); + if (!c) { + c = new Intl.Collator(lang); + collators.set(lang, c); + } + return c.compare(a, b); +} diff --git a/frontend/src/lib/columnSort.test.ts b/frontend/src/lib/columnSort.test.ts index 6263493c..e0441999 100644 --- a/frontend/src/lib/columnSort.test.ts +++ b/frontend/src/lib/columnSort.test.ts @@ -27,37 +27,51 @@ const columns = [ describe("sortRows", () => { it("returns the same array reference when there is no sort", () => { - expect(sortRows(rows, columns, null)).toBe(rows); + expect(sortRows(rows, columns, null, "en")).toBe(rows); }); it("returns the input unchanged for a column that has no sortValue", () => { - expect(sortRows(rows, columns, { key: "nosort", dir: "asc" })).toBe(rows); + expect(sortRows(rows, columns, { key: "nosort", dir: "asc" }, "en")).toBe(rows); }); it("sorts numbers by numeric difference, not string order", () => { // string order would put "100" before "30"; numeric must not. - const asc = sortRows(rows, columns, { key: "views", dir: "asc" }).map((r) => r.views); + const asc = sortRows(rows, columns, { key: "views", dir: "asc" }, "en").map((r) => r.views); expect(asc).toEqual([5, 30, 100]); - const desc = sortRows(rows, columns, { key: "views", dir: "desc" }).map((r) => r.views); + const desc = sortRows(rows, columns, { key: "views", dir: "desc" }, "en").map((r) => r.views); expect(desc).toEqual([100, 30, 5]); }); it("sorts strings with locale compare, ascending and descending", () => { - expect(sortRows(rows, columns, { key: "name", dir: "asc" }).map((r) => r.name)).toEqual([ + expect(sortRows(rows, columns, { key: "name", dir: "asc" }, "en").map((r) => r.name)).toEqual([ "alpha", "beta", "gamma", ]); - expect(sortRows(rows, columns, { key: "name", dir: "desc" }).map((r) => r.name)).toEqual([ + expect(sortRows(rows, columns, { key: "name", dir: "desc" }, "en").map((r) => r.name)).toEqual([ "gamma", "beta", "alpha", ]); }); + // E30 S3: text sorts in the UI language's alphabet, not the browser's — Hungarian puts "Ö" after + // "O" and "Cs" after "Cu". Both languages are asserted and differ, so a bare `localeCompare` (one + // runtime locale for both) cannot pass on any machine. + it("sorts text in the UI language's alphabet", () => { + const words = ["Zorró", "Ötödik", "oroszlán", "Csillag", "Életrajzi", "Cukor"].map((name) => ({ + name, + views: 0, + })); + const by = (lang: string) => + sortRows(words, columns, { key: "name", dir: "asc" }, lang).map((r) => r.name); + expect(by("hu")).toEqual(["Cukor", "Csillag", "Életrajzi", "oroszlán", "Ötödik", "Zorró"]); + expect(by("en")).toEqual(["Csillag", "Cukor", "Életrajzi", "oroszlán", "Ötödik", "Zorró"]); + }); + it("does not mutate the input array", () => { const before = rows.map((r) => r.name); - sortRows(rows, columns, { key: "name", dir: "asc" }); + sortRows(rows, columns, { key: "name", dir: "asc" }, "en"); expect(rows.map((r) => r.name)).toEqual(before); }); }); diff --git a/frontend/src/lib/columnSort.ts b/frontend/src/lib/columnSort.ts index cd2a9a71..df344b43 100644 --- a/frontend/src/lib/columnSort.ts +++ b/frontend/src/lib/columnSort.ts @@ -2,6 +2,8 @@ // manager's card layout) can sort the same rows the same way — one comparator, one source of truth. // Kept structural (no `Column` import) so it has zero coupling to DataTable. +import { compareText } from "./collate"; + export type SortState = { key: string; dir: "asc" | "desc" } | null; interface Sortable { @@ -9,9 +11,10 @@ interface Sortable { sortValue?: (row: T) => string | number; } -/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff or locale compare). - * Returns `rows` unchanged when there's no sort or the column has no `sortValue`. */ -export function sortRows(rows: T[], columns: Sortable[], sort: SortState): T[] { +/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff, or text in the UI + * language's alphabet — `lang`). Returns `rows` unchanged when there's no sort or the column has no + * `sortValue`. */ +export function sortRows(rows: T[], columns: Sortable[], sort: SortState, lang: string): T[] { if (!sort) return rows; const get = columns.find((c) => c.key === sort.key)?.sortValue; if (!get) return rows; @@ -21,7 +24,7 @@ export function sortRows(rows: T[], columns: Sortable[], sort: SortState): const cmp = typeof av === "number" && typeof bv === "number" ? av - bv - : String(av).localeCompare(String(bv)); + : compareText(String(av), String(bv), lang); return sort.dir === "asc" ? cmp : -cmp; }); } diff --git a/frontend/src/lib/dateFormat.contract.test.ts b/frontend/src/lib/dateFormat.contract.test.ts deleted file mode 100644 index 266ab09b..00000000 --- a/frontend/src/lib/dateFormat.contract.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { eslint } from "./lintProbe.testkit"; - -// `local/date-format` (eslint.config.js): docs/DESIGN.md "Copy" — a date is shown in the forms -// lib/format.ts builds, in the UI language. This pins that the rule fires on the ways a date used to -// be formatted by hand, and where it stands down. -const RULE = "local/date-format"; - -async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise { - const [result] = await eslint.lintText(source, { filePath }); - return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; -} - -describe("a hand-formatted date", () => { - it("is refused as toLocale*String or an Intl date formatter", async () => { - expect(await hits("export const a = (d: Date) => d.toLocaleString('en');\n")).toBe(1); - expect(await hits("export const b = (d: Date) => d.toLocaleDateString();\n")).toBe(1); - expect(await hits("export const c = (d: Date) => d.toLocaleTimeString();\n")).toBe(1); - expect( - await hits("export const e = new Intl.DateTimeFormat('en', { month: 'short' });\n"), - ).toBe(1); - expect(await hits("export const f = new Intl.RelativeTimeFormat('en');\n")).toBe(1); - }); - - it("is left alone in lib/format.ts, in a test and for a number format", async () => { - const src = "export const g = (d: Date) => d.toLocaleString('en');\n"; - expect(await hits(src, "src/lib/format.ts")).toBe(0); - expect(await hits(src, "src/lib/format.test.ts")).toBe(0); - expect(await hits("export const h = new Intl.NumberFormat('en');\n")).toBe(0); - }); -}); diff --git a/frontend/src/lib/feedSort.ts b/frontend/src/lib/feedSort.ts index 745e6556..df594ba6 100644 --- a/frontend/src/lib/feedSort.ts +++ b/frontend/src/lib/feedSort.ts @@ -3,6 +3,7 @@ // chips can label a sort without re-deriving the mapping. import type { Video } from "./api"; import type { SortOption } from "./columnSort"; +import { compareText } from "./collate"; export type SortKey = | "date" @@ -100,7 +101,12 @@ const YT_SORT_VALUE: Partial number | string | nul * Results missing the value sink to the END in both directions (a video whose duration YouTube did * not report is not "the shortest"), and ties keep the provider's ranking, in both directions — * hence a signed comparator rather than sorting and reversing, which would flip the ties too. */ -export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc"): Video[] { +export function sortYtResults( + items: Video[], + key: SortKey, + dir: "asc" | "desc", + lang: string, +): Video[] { const value = YT_SORT_VALUE[key]; if (!value) return items; // relevance, and anything a stale pref names that we no longer offer const sign = dir === "asc" ? 1 : -1; @@ -109,7 +115,7 @@ export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc") have.sort((a, b) => { const x = value(a)!; const y = value(b)!; - return sign * (typeof x === "string" ? x.localeCompare(String(y)) : x - Number(y)); + return sign * (typeof x === "string" ? compareText(x, String(y), lang) : x - Number(y)); }); return [...have, ...missing]; } diff --git a/frontend/src/lib/feedSort.yt.test.ts b/frontend/src/lib/feedSort.yt.test.ts index 2e76d4d2..83e2a6dd 100644 --- a/frontend/src/lib/feedSort.yt.test.ts +++ b/frontend/src/lib/feedSort.yt.test.ts @@ -46,7 +46,7 @@ describe("YT_SORT_KEYS", () => { describe("sortYtResults", () => { it("leaves the provider's ranking alone on `relevance`", () => { - expect(titles(sortYtResults(RESULTS, "relevance", "desc"))).toEqual([ + expect(titles(sortYtResults(RESULTS, "relevance", "desc", "en"))).toEqual([ "b-old-short", "a-new-long", "c-mid-mid", @@ -54,12 +54,12 @@ describe("sortYtResults", () => { }); it("orders by upload date in both directions", () => { - expect(titles(sortYtResults(RESULTS, "date", "desc"))).toEqual([ + expect(titles(sortYtResults(RESULTS, "date", "desc", "en"))).toEqual([ "a-new-long", "c-mid-mid", "b-old-short", ]); - expect(titles(sortYtResults(RESULTS, "date", "asc"))).toEqual([ + expect(titles(sortYtResults(RESULTS, "date", "asc", "en"))).toEqual([ "b-old-short", "c-mid-mid", "a-new-long", @@ -67,14 +67,18 @@ describe("sortYtResults", () => { }); it("orders by views and by length", () => { - expect(titles(sortYtResults(RESULTS, "popular", "desc"))[0]).toBe("c-mid-mid"); - expect(titles(sortYtResults(RESULTS, "duration", "desc"))[0]).toBe("a-new-long"); - expect(titles(sortYtResults(RESULTS, "duration", "asc"))[0]).toBe("b-old-short"); + expect(titles(sortYtResults(RESULTS, "popular", "desc", "en"))[0]).toBe("c-mid-mid"); + expect(titles(sortYtResults(RESULTS, "duration", "desc", "en"))[0]).toBe("a-new-long"); + expect(titles(sortYtResults(RESULTS, "duration", "asc", "en"))[0]).toBe("b-old-short"); }); it("orders by title alphabetically, not by code point", () => { const accented = [v({ title: "Zebra" }), v({ title: "Ábel" }), v({ title: "alma" })]; - expect(titles(sortYtResults(accented, "title", "asc"))).toEqual(["Ábel", "alma", "Zebra"]); + expect(titles(sortYtResults(accented, "title", "asc", "en"))).toEqual([ + "Ábel", + "alma", + "Zebra", + ]); }); it("sinks results with no value to the END in both directions", () => { @@ -84,8 +88,16 @@ describe("sortYtResults", () => { v({ title: "long", duration_seconds: 900 }), v({ title: "short", duration_seconds: 60 }), ]; - expect(titles(sortYtResults(mixed, "duration", "desc"))).toEqual(["long", "short", "unknown"]); - expect(titles(sortYtResults(mixed, "duration", "asc"))).toEqual(["short", "long", "unknown"]); + expect(titles(sortYtResults(mixed, "duration", "desc", "en"))).toEqual([ + "long", + "short", + "unknown", + ]); + expect(titles(sortYtResults(mixed, "duration", "asc", "en"))).toEqual([ + "short", + "long", + "unknown", + ]); }); it("keeps the provider's ranking inside a tie, in both directions", () => { @@ -95,19 +107,27 @@ describe("sortYtResults", () => { v({ title: "second", view_count: 100 }), v({ title: "third", view_count: 5 }), ]; - expect(titles(sortYtResults(tied, "popular", "desc"))).toEqual(["first", "second", "third"]); - expect(titles(sortYtResults(tied, "popular", "asc"))).toEqual(["third", "first", "second"]); + expect(titles(sortYtResults(tied, "popular", "desc", "en"))).toEqual([ + "first", + "second", + "third", + ]); + expect(titles(sortYtResults(tied, "popular", "asc", "en"))).toEqual([ + "third", + "first", + "second", + ]); }); it("does not reorder anything for a key it cannot honour", () => { // A pref stored before this view offered a control can name `shuffle` or `priority`. - expect(titles(sortYtResults(RESULTS, "priority", "desc"))).toEqual(titles(RESULTS)); - expect(titles(sortYtResults(RESULTS, "shuffle", "desc"))).toEqual(titles(RESULTS)); + expect(titles(sortYtResults(RESULTS, "priority", "desc", "en"))).toEqual(titles(RESULTS)); + expect(titles(sortYtResults(RESULTS, "shuffle", "desc", "en"))).toEqual(titles(RESULTS)); }); it("does not mutate the array it was given", () => { const input = [...RESULTS]; - sortYtResults(input, "date", "asc"); + sortYtResults(input, "date", "asc", "en"); expect(titles(input)).toEqual(titles(RESULTS)); }); }); diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index bff43bc4..b4aec1d3 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RELATIVE_UNITS, + formatBytes, + formatCompact, + formatCountOrDash, + formatDecimal, + formatMoney, formatDate, formatDateTime, formatDateTimeSec, @@ -69,29 +74,29 @@ describe("relativeFromMs", () => { for (const [secs, tier, expectedEn, expectedHu] of CASES) { it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => { - expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedEn); + expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedEn); i18nStub.language = "hu"; - expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedHu); + expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedHu); }); } it("follows a language switch at runtime", () => { const twoHoursAgo = NOW - 2 * HOUR * 1000; - expect(relativeFromMs(twoHoursAgo)).toBe("2h ago"); + expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago"); i18nStub.language = "hu"; - expect(relativeFromMs(twoHoursAgo)).toBe("2 órája"); + expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2 órája"); i18nStub.language = "en"; - expect(relativeFromMs(twoHoursAgo)).toBe("2h ago"); + expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago"); }); it("clamps a future timestamp instead of counting up", () => { - expect(relativeFromMs(NOW + 5 * DAY * 1000)).toBe("time.justNow"); + expect(relativeFromMs(NOW + 5 * DAY * 1000, i18nStub.language)).toBe("time.justNow"); }); it("renders nothing for a timestamp it cannot read", () => { - expect(relativeTime("not-a-date")).toBe(""); - expect(relativeTime(null)).toBe(""); - expect(relativeFromMs(NaN)).toBe(""); + expect(relativeTime("not-a-date", i18nStub.language)).toBe(""); + expect(relativeTime(null, i18nStub.language)).toBe(""); + expect(relativeFromMs(NaN, i18nStub.language)).toBe(""); }); // Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering @@ -135,9 +140,9 @@ describe("formatNumber", () => { }); it("groups in the UI language, not in the runtime's", () => { - const asEnglish = formatNumber(1234567); + const asEnglish = formatNumber(1234567, i18nStub.language); i18nStub.language = "hu"; - const asHungarian = formatNumber(1234567); + const asHungarian = formatNumber(1234567, i18nStub.language); expect(asEnglish).toBe(new Intl.NumberFormat("en").format(1234567)); expect(asHungarian).toBe(new Intl.NumberFormat("hu").format(1234567)); // The load-bearing half: under `toLocaleString()` both come out in the runtime's one locale. @@ -145,24 +150,26 @@ describe("formatNumber", () => { }); it("follows a language switch at runtime (the per-language cache is keyed, not frozen)", () => { - expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000)); + expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000)); i18nStub.language = "hu"; - expect(formatNumber(1000)).toBe(new Intl.NumberFormat("hu").format(1000)); + expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("hu").format(1000)); i18nStub.language = "en"; - expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000)); + expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000)); }); it("renders nothing for a value it cannot read, so a caller's dash fallback covers it", () => { - expect(formatNumber(null)).toBe(""); - expect(formatNumber(undefined)).toBe(""); - expect(formatNumber(NaN)).toBe(""); - expect(formatNumber(Infinity)).toBe(""); - expect(formatNumber(0)).toBe("0"); // a real zero is a value, not a missing one + expect(formatNumber(null, i18nStub.language)).toBe(""); + expect(formatNumber(undefined, i18nStub.language)).toBe(""); + expect(formatNumber(NaN, i18nStub.language)).toBe(""); + expect(formatNumber(Infinity, i18nStub.language)).toBe(""); + expect(formatNumber(0, i18nStub.language)).toBe("0"); // a real zero is a value, not a missing one }); it("carries the UI language into the channel total-hours label", () => { i18nStub.language = "hu"; - expect(formatTotalHours(12345 * 3600)).toBe(`${new Intl.NumberFormat("hu").format(12345)} ó`); + expect(formatTotalHours(12345 * 3600, i18nStub.language)).toBe( + `${new Intl.NumberFormat("hu").format(12345)} ó`, + ); }); }); @@ -185,18 +192,18 @@ describe("formatRuntime", () => { ]; it.each(CASES)("formats %o as %o", (sec, expected) => { - expect(formatRuntime(sec)).toBe(expected); + expect(formatRuntime(sec, i18nStub.language)).toBe(expected); }); it("speaks the UI language's units (DESIGN Copy: Intl short), not English letters", () => { i18nStub.language = "hu"; - expect(formatRuntime(5025)).toBe("1 ó 23 p"); + expect(formatRuntime(5025, i18nStub.language)).toBe("1 ó 23 p"); i18nStub.language = "en"; }); it("never reports a non-zero duration as zero", () => { for (let sec = 1; sec < 3600; sec++) { - expect(formatRuntime(sec), `${sec}s`).not.toBe("0 min"); + expect(formatRuntime(sec, i18nStub.language), `${sec}s`).not.toBe("0 min"); } }); }); @@ -216,9 +223,9 @@ describe("formatDateTime / formatDate", () => { it("formats a timestamp in the UI language, not in the runtime's", () => { const iso = "2026-09-17T08:30:14Z"; - const asEnglish = formatDateTime(iso); + const asEnglish = formatDateTime(iso, i18nStub.language); i18nStub.language = "hu"; - const asHungarian = formatDateTime(iso); + const asHungarian = formatDateTime(iso, i18nStub.language); expect(asEnglish).not.toBe(asHungarian); expect(formatDate(iso, "en")).not.toBe(formatDate(iso, "hu")); }); @@ -226,10 +233,10 @@ describe("formatDateTime / formatDate", () => { it("names the month and stops at the minute; seconds only in the log form (DESIGN Copy)", () => { process.env.TZ = "UTC"; const iso = "2026-09-17T08:30:14Z"; - expect(formatDateTime(iso)).toBe("Sep 17, 2026, 08:30 AM"); - expect(formatDateTimeSec(iso)).toBe("Sep 17, 2026, 08:30:14 AM"); + expect(formatDateTime(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30 AM"); + expect(formatDateTimeSec(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30:14 AM"); i18nStub.language = "hu"; - expect(formatDateTime(iso)).toBe("2026. szept. 17. 08:30"); + expect(formatDateTime(iso, i18nStub.language)).toBe("2026. szept. 17. 08:30"); expect(formatDate(iso, "hu")).toBe("2026. szept. 17."); }); @@ -240,3 +247,39 @@ describe("formatDateTime / formatDate", () => { expect(formatDate("2026-09-17", "en")).toBe("Sep 17, 2026"); }); }); + +// E30 S3: every number a reader sees is in the UI language — the decimal mark, the grouping and the +// compact suffix. `toFixed` and hand-built "1.2K" wrote English in both languages. Both languages are +// asserted and differ, so an implementation that ignores the language cannot pass. +describe("numbers in the UI language", () => { + it("writes a rating with the language's decimal mark", () => { + expect(formatDecimal(8.25, "en")).toBe("8.3"); + expect(formatDecimal(8.25, "hu")).toBe("8,3"); + expect(formatDecimal(8, "hu")).toBe("8,0"); // a rating keeps its decimal + expect(formatDecimal(5, "hu", 1, 0)).toBe("5"); // unless asked to drop a zero one + }); + + it("writes a big count compact, with the language's suffix", () => { + // Intl keeps the number and its suffix together with a no-break space + const hu = (n: number) => formatCompact(n, "hu").replace(/\u00a0/g, " "); + expect(formatCompact(1234, "en")).toBe("1.2K"); + expect(hu(1234)).toBe("1,2 E"); + expect(formatCompact(3_400_000, "en")).toBe("3.4M"); + expect(hu(3_400_000)).toBe("3,4 M"); + expect(formatCompact(999, "hu")).toBe("999"); + expect(formatCompact(null, "hu")).toBe(""); + expect(formatCountOrDash(null, "hu")).toBe("—"); + }); + + it("writes a file size with the language's decimal mark", () => { + expect(formatBytes(5 * 1024 ** 3, "en")).toBe("5.0 GB"); + expect(formatBytes(5 * 1024 ** 3, "hu")).toBe("5,0 GB"); + expect(formatBytes(501747, "hu")).toBe("490 KB"); + expect(formatBytes(0, "hu")).toBe("0 B"); + }); + + it("writes a dollar sum compact, never padded", () => { + expect(formatMoney(160_000_000, "en")).toBe("$160M"); + expect(formatMoney(160_000_000, "hu")).not.toBe(formatMoney(160_000_000, "en")); + }); +}); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 5aed0a32..92a2999e 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -1,12 +1,36 @@ +import type { TFunction } from "i18next"; import i18n from "../i18n"; -export function relativeTime(iso: string | null): string { - if (!iso) return ""; - return relativeFromMs(new Date(iso).getTime()); +// The one home of the app's LOCALE forms — numbers, units, dates (alphabetical order: `collate.ts`) — +// all in the UI language, never the browser's (lint: `local/locale-format` refuses a hand-built one elsewhere). +// +// Every formatter takes the language (or `t`) as an ARGUMENT, and that is load-bearing, not style +// (E30 S3): the React Compiler memoizes a call on its arguments, so a formatter that read the language +// off the i18n singleton kept its old answer after a language switch — a card still said "2 hr 4 min" +// beside a hero saying "2 ó 16 p". Passing `i18n.language` (from useTranslation) makes the language a +// dependency the compiler can see; the signature makes forgetting it a type error. + +// Intl formatters are expensive to build and every grid formats one per card: one per language × +// options, built lazily. Keyed by language because the user can switch it at runtime. +const numberFormats = new Map(); +function numberFormat(lang: string, opts: Intl.NumberFormatOptions = {}): Intl.NumberFormat { + const key = `${lang}|${JSON.stringify(opts)}`; + let f = numberFormats.get(key); + if (!f) { + f = new Intl.NumberFormat(lang, opts); + numberFormats.set(key, f); + } + return f; } -/** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */ -export function formatBytes(bytes: number | null | undefined): string { +export function relativeTime(iso: string | null, lang: string): string { + if (!iso) return ""; + return relativeFromMs(new Date(iso).getTime(), lang); +} + +/** Human file size, binary units (1024), the number in the UI language: 501747 -> "490 KB", + * 5368709120 -> "5.0 GB" / "5,0 GB". The unit symbols are the same in both languages. */ +export function formatBytes(bytes: number | null | undefined, lang: string): string { const n = Number(bytes); if (!n || n < 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; @@ -16,13 +40,14 @@ export function formatBytes(bytes: number | null | undefined): string { v /= 1024; i++; } - return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; + const num = v >= 100 || i === 0 ? formatNumber(Math.round(v), lang) : formatDecimal(v, lang); + return `${num} ${units[i]}`; } /** Download speed from bytes/sec, e.g. "2.4 MB/s". */ -export function formatSpeed(bps: number | null | undefined): string { +export function formatSpeed(bps: number | null | undefined, lang: string): string { if (!bps) return ""; - return `${formatBytes(bps)}/s`; + return `${formatBytes(bps, lang)}/s`; } /** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces, @@ -65,18 +90,18 @@ function relativeFormatter(lang: string): Intl.RelativeTimeFormat { * client-side notifications whose timestamps are already numbers). One implementation shared * instead of re-implemented per component. Empty string for a timestamp that can't be read, so a * caller's `|| "—"` fallback covers it like a missing one. */ -export function relativeFromMs(ms: number): string { +export function relativeFromMs(ms: number, lang: string): string { const secs = Math.max(0, (Date.now() - ms) / 1000); if (!Number.isFinite(secs)) return ""; // Sub-minute is the one case Intl has no wording for — it would say "0m ago". - if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow"); + if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow", { lng: lang }); let [div, unit] = RELATIVE_UNITS[0]; for (const [unitSecs, unitName] of RELATIVE_UNITS) { if (secs < unitSecs) break; div = unitSecs; unit = unitName; } - return relativeFormatter(i18n.language).format(-Math.floor(secs / div), unit); + return relativeFormatter(lang).format(-Math.floor(secs / div), unit); } // The canonical YouTube URL for a channel: its @handle when known (nicer), else the @@ -100,15 +125,17 @@ export function formatDuration(sec: number | null): string { // S3g): "90 min", "2 hr" / "90 p", "2 ó". Hand-typed "90m" / "2h" were English in both languages and // came in three spellings. One formatter per language × unit, built lazily (the grids format one per card). export type DurationUnit = "second" | "minute" | "hour" | "day"; -const unitFormatters = new Map(); -export function formatUnit(value: number, unit: DurationUnit): string { - const key = `${i18n.language}|${unit}`; - let f = unitFormatters.get(key); - if (!f) { - f = new Intl.NumberFormat(i18n.language, { style: "unit", unit, unitDisplay: "short" }); - unitFormatters.set(key, f); - } - return f.format(value); +export function formatUnit( + value: number, + unit: DurationUnit, + lang: string, + digits?: number, +): string { + const fraction = + digits == null ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits }; + return numberFormat(lang, { style: "unit", unit, unitDisplay: "short", ...fraction }).format( + value, + ); } /** Compact human runtime ("1 hr 30 min", "45 min", "45 sec"; HU "1 ó 30 p") from seconds. Formats @@ -118,70 +145,84 @@ export function formatUnit(value: number, unit: DurationUnit): string { * The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land * between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0 min" made a * non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */ -export function formatRuntime(sec?: number | null): string { +export function formatRuntime(sec: number | null | undefined, lang: string): string { if (!sec) return ""; - if (sec < 60) return formatUnit(Math.floor(sec), "second"); + if (sec < 60) return formatUnit(Math.floor(sec), "second", lang); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); - return h ? `${formatUnit(h, "hour")} ${formatUnit(m, "minute")}` : formatUnit(m, "minute"); + return h + ? `${formatUnit(h, "hour", lang)} ${formatUnit(m, "minute", lang)}` + : formatUnit(m, "minute", lang); } // Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved // from i18n (quotaActions.) so it's translated in all UI languages; falls back to the raw // key for any unmapped/legacy value. -export function quotaActionLabel(action: string): string { - return i18n.t(`quotaActions.${action}`, { defaultValue: action }); +export function quotaActionLabel(t: TFunction, action: string): string { + return t(`quotaActions.${action}`, { defaultValue: action }); } /** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated * via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */ -export function formatEta(seconds: number): string { - if (seconds <= 0) return i18n.t("time.eta.done"); +export function formatEta(t: TFunction, seconds: number): string { + if (seconds <= 0) return t("time.eta.done"); const hours = seconds / 3600; - if (hours < 1) return i18n.t("time.eta.lessThanHour"); - if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) }); - return i18n.t("time.eta.days", { count: Math.round(hours / 24) }); + if (hours < 1) return t("time.eta.lessThanHour"); + if (hours < 24) return t("time.eta.hours", { count: Math.round(hours) }); + return t("time.eta.days", { count: Math.round(hours / 24) }); } /** Compact whole-channel total length ("12 hr", "45 min"), hours-scale so H:MM:SS isn't huge. */ -export function formatTotalHours(sec: number): string { +export function formatTotalHours(sec: number, lang: string): string { if (!sec) return "—"; const h = Math.round(sec / 3600); - return h >= 1 ? formatUnit(h, "hour") : formatUnit(Math.max(1, Math.round(sec / 60)), "minute"); + return h >= 1 + ? formatUnit(h, "hour", lang) + : formatUnit(Math.max(1, Math.round(sec / 60)), "minute", lang); } -// One grouped-number formatter per language, built lazily — the same reason as the relative-time -// formatters above: the stats tables and the feed format a number per row, and constructing an Intl -// formatter is the expensive part. Keyed by language because the user can switch it at runtime. -const numberFormatters = new Map(); - /** A grouped number in the UI language ("1,234,567" in English, "1 234 567" in Hungarian). A bare * `n.toLocaleString()` groups in the BROWSER's locale instead, so an English UI in a Hungarian * browser showed space-grouped figures and vice versa — the number half of the same defect * `formatDateTime` fixes for dates. Empty string for a missing/non-finite value, so a caller's * `|| "—"` fallback covers it like a missing one. */ -export function formatNumber(n: number | null | undefined): string { +export function formatNumber(n: number | null | undefined, lang: string): string { if (n == null || !Number.isFinite(n)) return ""; - const lang = i18n.language; - let formatter = numberFormatters.get(lang); - if (!formatter) { - formatter = new Intl.NumberFormat(lang); - numberFormatters.set(lang, formatter); - } - return formatter.format(n); + return numberFormat(lang).format(n); } -export function formatViews(n: number | null): string { - if (n == null) return ""; - if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`; - if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`; - if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`; - return String(n); +/** A number with `digits` decimals in the UI language — a rating "8.3" / "8,3" (a bare `toFixed` + * always wrote the English decimal point). `minDigits` below `digits` drops trailing zeros: "5" GB. */ +export function formatDecimal(n: number, lang: string, digits = 1, minDigits = digits): string { + const opts = { minimumFractionDigits: minDigits, maximumFractionDigits: digits }; + return numberFormat(lang, opts).format(n); +} + +/** A big count, compact, in the UI language: "1.2K" / "1,2 E", "3.4M" / "3,4 M". The minimum is + * pinned for the reason `formatMoney` gives. Empty for null. */ +export function formatCompact(n: number | null | undefined, lang: string): string { + if (n == null || !Number.isFinite(n)) return ""; + const opts = { notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 } as const; + return numberFormat(lang, opts).format(n); +} + +/** A US-dollar sum, compact ("$160M"): TMDB budgets and revenues are USD whatever the reader's + * language. The minimum is load-bearing, not a default written out: given only a maximum, a compact + * format is free to pad to it. The same options render "$160.0M" on Node 22 and "$160M" on Node 24 + * (measured 2026-09-20), and browsers split along the same line. Pin it rather than inherit it. */ +export function formatMoney(n: number, lang: string): string { + return numberFormat(lang, { + notation: "compact", + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }).format(n); } // A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null). -export function formatCountOrDash(n: number | null | undefined): string { - return n != null ? formatViews(n) : "—"; +export function formatCountOrDash(n: number | null | undefined, lang: string): string { + return n != null ? formatCompact(n, lang) : "—"; } // The app's date forms (docs/DESIGN.md "Copy", E28 S3g), all in the UI language — never a bare @@ -195,12 +236,12 @@ export function formatCountOrDash(n: number | null | undefined): string { const DAY: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" }; const MINUTE: Intl.DateTimeFormatOptions = { ...DAY, hour: "2-digit", minute: "2-digit" }; -export function formatDateTime(iso: string | null | undefined): string { - return iso ? new Date(iso).toLocaleString(i18n.language, MINUTE) : ""; +export function formatDateTime(iso: string | null | undefined, lang: string): string { + return iso ? new Date(iso).toLocaleString(lang, MINUTE) : ""; } -export function formatDateTimeSec(iso: string | null | undefined): string { - return iso ? new Date(iso).toLocaleString(i18n.language, { ...MINUTE, second: "2-digit" }) : ""; +export function formatDateTimeSec(iso: string | null | undefined, lang: string): string { + return iso ? new Date(iso).toLocaleString(lang, { ...MINUTE, second: "2-digit" }) : ""; } // Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null. diff --git a/frontend/src/lib/jobResult.dom.test.ts b/frontend/src/lib/jobResult.dom.test.ts new file mode 100644 index 00000000..4a328d4c --- /dev/null +++ b/frontend/src/lib/jobResult.dom.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +// +// E30: a scheduler job's result in the reader's language, through the REAL catalog. The server's own +// one-liner was "recent={'channels_processed': 0, …}, deep={…}" — English keys and a Python repr. +import { afterEach, describe, expect, it } from "vitest"; +import i18n, { setLanguage } from "../i18n"; +import { jobResultText, parseSummary } from "./jobResult"; + +afterEach(() => setLanguage("en")); + +describe("a job's result", () => { + it("names each count, and a phase's own counts inside it", () => { + setLanguage("hu"); + const t = i18n.getFixedT("hu"); + expect(jobResultText(t, { new: 3, failed: 0 }, "hu")).toBe("új videó: 3 · sikertelen: 0"); + expect( + jobResultText( + t, + { + recent: { channels_processed: 0, videos_added: 0, reason: "no service user" }, + deep: { channels_processed: 2, videos_added: 5, reconciled_full: 1 }, + }, + "hu", + ), + ).toBe( + "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)", + ); + }); + + it("names a skip reason; an upstream error stays its own text", () => { + const t = i18n.getFixedT("hu"); + expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" }, "hu")).toBe( + "dúsítva: 0 · kihagyva: nincs TMDB API-kulcs", + ); + expect(jobResultText(t, { error: "Plex returned 500" }, "hu")).toBe("hiba: Plex returned 500"); + }); + + it("shows a key or word it does not know as it is, readable", () => { + const t = i18n.getFixedT("hu"); + expect(jobResultText(t, { brand_new_count: 3, skipped: "moon is full" }, "hu")).toBe( + "brand new count: 3 · kihagyva: moon is full", + ); + }); +}); + +describe("an older notice's one-liner", () => { + it("reads back into a result when flat", () => { + expect(parseSummary("new=403, failed=396, fallback=375")).toEqual({ + new: 403, + failed: 396, + fallback: 375, + }); + expect(parseSummary("enriched=0, skipped=tmdb_api_key not set")).toEqual({ + 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(null)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/jobResult.ts b/frontend/src/lib/jobResult.ts new file mode 100644 index 00000000..53365236 --- /dev/null +++ b/frontend/src/lib/jobResult.ts @@ -0,0 +1,83 @@ +import type { TFunction } from "i18next"; +import type { JobResult } from "./api"; +import { formatNumber } from "./format"; +import { vocabKey } from "./vocab"; + +/** 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); + if (!m) return null; + const [, key, raw] = m; + out[key!] = /^-?\d+$/.test(raw!) ? Number(raw) : raw!; + } + return out; +} + +/** A scheduler job's result in the reader's language (E30): "új videó: 3 · sikertelen: 0". The server + * stored "new=3, failed=0" — English keys, a Python dict repr for a nested phase. A count's key is + * named from `scheduler.result`, a `skipped` / `reason` word from `scheduler.skip`; an unnamed key or + * word shows as it is, and an `error` stays the upstream's own text (it is diagnostic). */ +export function jobResultText(t: TFunction, result: JobResult, lang: string): string { + return Object.entries(result) + .map(([key, value]) => { + const label = t(`scheduler.result.${key}`, { defaultValue: key.replace(/_/g, " ") }); + if (typeof value === "number") return `${label}: ${formatNumber(value, lang)}`; + if (typeof value === "string") + return key === "error" + ? `${label}: ${value}` + : `${label}: ${t(`scheduler.skip.${vocabKey(value)}`, { defaultValue: value })}`; + return `${label} (${jobResultText(t, value, lang)})`; + }) + .join(" · "); +} + +/** 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 skipped run names only why; a failed run keeps the + * upstream's own error text (it is diagnostic). */ +export function jobRunText( + t: TFunction, + run: { job_id: string; status?: unknown; summary?: unknown; result?: unknown }, + lang: string, +): { title: string; body: string | null } { + const job = t(`scheduler.jobs.${run.job_id}`, run.job_id); + const summary = typeof run.summary === "string" && run.summary ? run.summary : null; + const titleKey = + run.status === "error" + ? "inbox.jobDone.titleError" + : run.status === "skipped" + ? "inbox.jobDone.titleSkipped" + : "inbox.jobDone.titleOk"; + let result: JobResult | null = null; + if (run.status !== "error") { + result = + run.result && typeof run.result === "object" + ? (run.result as JobResult) + : (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: + 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/localeFormat.contract.test.ts b/frontend/src/lib/localeFormat.contract.test.ts new file mode 100644 index 00000000..807f8cf8 --- /dev/null +++ b/frontend/src/lib/localeFormat.contract.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { eslint } from "./lintProbe.testkit"; + +// `local/locale-format` (eslint.config.js): docs/DESIGN.md "Copy" — a date, a number and an +// alphabetical order are in the UI language, built in lib/format.ts (text compared in lib/collate.ts), +// and a component takes the language off useTranslation, never the i18n singleton. This pins that the +// rule fires on the ways each used to be done by hand, and where it stands down. +const RULE = "local/locale-format"; + +async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise { + const [result] = await eslint.lintText(source, { filePath }); + return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; +} + +describe("a hand-formatted date or number", () => { + it("is refused as toLocale*String or an Intl date or number formatter", async () => { + expect(await hits("export const a = (d: Date) => d.toLocaleString('en');\n")).toBe(1); + expect(await hits("export const b = (d: Date) => d.toLocaleDateString();\n")).toBe(1); + expect(await hits("export const c = (d: Date) => d.toLocaleTimeString();\n")).toBe(1); + expect( + await hits("export const e = new Intl.DateTimeFormat('en', { month: 'short' });\n"), + ).toBe(1); + expect(await hits("export const f = new Intl.RelativeTimeFormat('en');\n")).toBe(1); + expect(await hits("export const h = new Intl.NumberFormat('en');\n")).toBe(1); + }); + + it("is left alone in lib/format.ts and in a test", async () => { + const src = "export const g = (d: Date) => d.toLocaleString('en');\n"; + expect(await hits(src, "src/lib/format.ts")).toBe(0); + expect(await hits(src, "src/lib/format.test.ts")).toBe(0); + expect(await hits("export const h = new Intl.NumberFormat('en');\n", "src/lib/format.ts")).toBe( + 0, + ); + }); +}); + +describe("a hand-rolled alphabetical order", () => { + it("is refused as localeCompare, or an Intl.Collator outside lib/collate.ts", async () => { + expect(await hits("export const s = (a: string, b: string) => a.localeCompare(b);\n")).toBe(1); + expect( + await hits( + "export const s = (a: string, b: string) => a.localeCompare(b);\n", + "src/lib/x.ts", + ), + ).toBe(1); + expect(await hits("export const c = new Intl.Collator('hu');\n")).toBe(1); + expect(await hits("export const c = new Intl.Collator('hu');\n", "src/lib/collate.ts")).toBe(0); + }); +}); + +describe("the language in a component", () => { + it("is refused off the i18n singleton", async () => { + const src = 'import i18n from "../i18n";\nexport const L = () => {i18n.language};\n'; + expect(await hits(src)).toBe(1); + const next = + 'import i18next from "i18next";\nexport const L = () => {i18next.language};\n'; + expect(await hits(next)).toBe(1); + }); + + it("is taken from useTranslation, and the singleton stays usable outside components", async () => { + const hook = + 'import { useTranslation } from "react-i18next";\n' + + "export const L = () => {\n const { i18n } = useTranslation();\n return {i18n.language};\n};\n"; + expect(await hits(hook)).toBe(0); + const lib = 'import i18n from "../i18n";\nexport const lang = () => i18n.language;\n'; + expect(await hits(lib, "src/lib/__probe.ts")).toBe(0); + }); +}); diff --git a/frontend/src/lib/playlistView.test.ts b/frontend/src/lib/playlistView.test.ts index 6367aedf..3624373c 100644 --- a/frontend/src/lib/playlistView.test.ts +++ b/frontend/src/lib/playlistView.test.ts @@ -46,17 +46,17 @@ describe("applyView", () => { ]; it("returns the stored order untouched with no sort and no grouping", () => { - expect(applyView(rows, columns, null, false)).toBe(rows); + expect(applyView(rows, columns, null, false, "en")).toBe(rows); }); it("sorts by the named column", () => { - expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, false))).toEqual([ + expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, false, "en"))).toEqual([ "alpha", "bravo", "charlie", "delta", ]); - expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, false))).toEqual([ + expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, false, "en"))).toEqual([ "delta", "charlie", "bravo", @@ -65,7 +65,7 @@ describe("applyView", () => { }); it("groups by channel, ordering the groups by name and sorting within each", () => { - expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, true))).toEqual([ + expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, true, "en"))).toEqual([ "alpha", // Alpha "bravo", // Alpha "charlie", // Beta @@ -74,7 +74,7 @@ describe("applyView", () => { }); it("reverses the GROUP order too when the sort is descending", () => { - expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, true))).toEqual([ + expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, true, "en"))).toEqual([ "delta", // Beta first, because the direction flips the group order as well "charlie", "bravo", // then Alpha @@ -83,7 +83,7 @@ describe("applyView", () => { }); it("groups without a sort by keeping each group's stored order", () => { - expect(titles(applyView(rows, columns, null, true))).toEqual([ + expect(titles(applyView(rows, columns, null, true, "en"))).toEqual([ "alpha", "bravo", "delta", @@ -93,11 +93,9 @@ describe("applyView", () => { it("parks unknown durations at the end of an ascending sort, not at zero", () => { const mixed = [v("1", "long", "A", 900), v("2", "live", "A", null), v("3", "short", "A", 30)]; - expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false))).toEqual([ - "short", - "long", - "live", - ]); + expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false, "en"))).toEqual( + ["short", "long", "live"], + ); }); }); diff --git a/frontend/src/lib/playlistView.ts b/frontend/src/lib/playlistView.ts index be9a647e..2e3a3a38 100644 --- a/frontend/src/lib/playlistView.ts +++ b/frontend/src/lib/playlistView.ts @@ -1,4 +1,5 @@ import { sortRows, type SortState } from "./columnSort"; +import { compareText } from "./collate"; // The playlist detail's VIEW derivation: what the reader sees, as opposed to the playlist's stored // order. Kept structural (no `Column`, no component imports) like `columnSort.ts`, so it stays pure @@ -21,8 +22,9 @@ export function applyView( columns: Sortable[], sort: SortState, group: boolean, + lang: string, ): T[] { - const sorted = sortRows(rows, columns, sort); + const sorted = sortRows(rows, columns, sort, lang); if (!group) return sorted; const groups = new Map(); for (const v of sorted) { @@ -32,7 +34,7 @@ export function applyView( else groups.set(k, [v]); } const sign = sort?.dir === "desc" ? -1 : 1; - const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b)); + const keys = [...groups.keys()].sort((a, b) => sign * compareText(a, b, lang)); return keys.flatMap((k) => groups.get(k)!); } 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/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index ff2a228e..31d97f15 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -17,7 +17,7 @@ import { } from "./fullscreen"; import { api, type PlexItemDetail, type PlexMarker } from "./api"; import { qk } from "./queryKeys"; -import { plexLang } from "../i18n"; +import i18n, { plexLang } from "../i18n"; import { type PlexPlayerPrefs } from "./plexPlayerPrefs"; import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume"; @@ -264,7 +264,7 @@ export function usePlexProgress(opts: { // checkpoint (the resume point when leaving). const settle = () => { // Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands. - const key = qk.plexItem(id, plexLang()); + const key = qk.plexItem(id, plexLang(i18n.language)); if (finished) { // A refused finish claims nothing: the server has the item unwatched at the position the // rollback restored, and the catch below has already dropped this entry, so the next open @@ -311,7 +311,7 @@ export function usePlexProgress(opts: { // `settle` may already have patched the detail to watched / 0 on the way out. Dropping that // entry rather than invalidating it: an invalidated query with no observer keeps serving its // stale data, and this one is a lie — no data is better, the next open fetches the truth. - qc.removeQueries({ queryKey: qk.plexItem(id, plexLang()) }); + qc.removeQueries({ queryKey: qk.plexItem(id, plexLang(i18n.language)) }); }); }; return () => { diff --git a/frontend/src/lib/plexSort.ts b/frontend/src/lib/plexSort.ts index a886e787..2b61de12 100644 --- a/frontend/src/lib/plexSort.ts +++ b/frontend/src/lib/plexSort.ts @@ -1,4 +1,5 @@ import { healSort, type SortOption, type SortState } from "./columnSort"; +import { compareText } from "./collate"; // Plex's sort vocabulary, source-agnostic like the feed's — a `SortOption[]` fed to the shared // SortControl. Direction lives in the SortState (no more sibling `sortDir` on PlexFilters); the wire @@ -25,12 +26,16 @@ function plexSortKeys(scope: string): string[] { * SortState would otherwise corrupt the shared fallback for every caller. */ export const PLEX_SORT_FALLBACK: SortState = Object.freeze({ key: "title", dir: "asc" as const }); -/** Labelled, alpha-ordered options for the SortControl. `label` resolves each key (i18n - * `plex.sort.`). */ -export function plexSortOptions(scope: string, label: (key: string) => string): SortOption[] { +/** Labelled, alpha-ordered options for the SortControl, in the UI language's alphabet. `label` + * resolves each key (i18n `plex.sort.`). */ +export function plexSortOptions( + scope: string, + label: (key: string) => string, + lang: string, +): SortOption[] { return plexSortKeys(scope) .map((key) => ({ key, label: label(key) })) - .sort((a, b) => a.label.localeCompare(b.label)); + .sort((a, b) => compareText(a.label, b.label, lang)); } /** Heal a stored sort against a scope's available keys, collapsing an out-of-scope key to the default. diff --git a/frontend/src/lib/plexStats.test.ts b/frontend/src/lib/plexStats.test.ts index 8455134c..f2235b6d 100644 --- a/frontend/src/lib/plexStats.test.ts +++ b/frontend/src/lib/plexStats.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +// format.ts boots i18n on import (localStorage); the formatters here take the language as an argument. +vi.mock("../i18n", () => ({ default: { t: (k: string) => k } })); import { buildStatRows, createFragMeter, @@ -17,25 +20,28 @@ const DASH = "—"; describe("plexStats formatters", () => { it("fmtBitrate scales kbps/Mbps and dashes zero/undefined", () => { - expect(fmtBitrate(undefined)).toBe(DASH); - expect(fmtBitrate(0)).toBe(DASH); - expect(fmtBitrate(820_000)).toBe("820 kbps"); - expect(fmtBitrate(4_200_000)).toBe("4.2 Mbps"); - expect(fmtBitrate(1_000_000)).toBe("1.0 Mbps"); + expect(fmtBitrate(undefined, "en")).toBe(DASH); + expect(fmtBitrate(0, "en")).toBe(DASH); + expect(fmtBitrate(820_000, "en")).toBe("820 kbps"); + expect(fmtBitrate(4_200_000, "en")).toBe("4.2 Mbps"); + expect(fmtBitrate(1_000_000, "en")).toBe("1.0 Mbps"); + expect(fmtBitrate(4_200_000, "hu")).toBe("4,2 Mbps"); // E30 S3: the UI language's decimal mark }); it("fmtBufferSecs shows one decimal, dashes negatives/non-finite", () => { - expect(fmtBufferSecs(12.34)).toBe("12.3 s"); - expect(fmtBufferSecs(0)).toBe("0.0 s"); - expect(fmtBufferSecs(-1)).toBe(DASH); - expect(fmtBufferSecs(Infinity)).toBe(DASH); + expect(fmtBufferSecs(12.34, "en")).toBe("12.3 sec"); + expect(fmtBufferSecs(0, "en")).toBe("0.0 sec"); + expect(fmtBufferSecs(12.34, "hu")).toBe("12,3 mp"); + expect(fmtBufferSecs(-1, "en")).toBe(DASH); + expect(fmtBufferSecs(Infinity, "en")).toBe(DASH); }); it("fmtFrames shows ratio + drop %, dashes when nothing decoded", () => { - expect(fmtFrames(0, 0)).toBe(DASH); - expect(fmtFrames(0, 1000)).toBe("0 / 1000 (0.00%)"); - expect(fmtFrames(5, 1000)).toBe("5 / 1000 (0.50%)"); - expect(fmtFrames(30, 1000)).toBe("30 / 1000 (3.0%)"); + expect(fmtFrames(0, 0, "en")).toBe(DASH); + expect(fmtFrames(0, 1000, "en")).toBe("0 / 1,000 (0.00%)"); + expect(fmtFrames(5, 1000, "en")).toBe("5 / 1,000 (0.50%)"); + expect(fmtFrames(30, 1000, "en")).toBe("30 / 1,000 (3.0%)"); + expect(fmtFrames(30, 1000, "hu")).toBe("30 / 1000 (3,0%)"); }); it("fmtRes dashes an undecoded 0×0 frame", () => { @@ -68,6 +74,7 @@ describe("plexStats formatters", () => { describe("buildStatRows", () => { const base: PlexStatsInput = { + lang: "en", playable: "remux", transport: "hls", videoWidth: 1280, @@ -199,6 +206,7 @@ describe("measured bitrate", () => { describe("buildStatRows — what is actually playing", () => { const hls = (over: Partial = {}): PlexStatsInput => ({ + lang: "en", playable: "remux", transport: "hls", videoWidth: 1920, diff --git a/frontend/src/lib/plexStats.ts b/frontend/src/lib/plexStats.ts index 6a59705e..dbb094da 100644 --- a/frontend/src/lib/plexStats.ts +++ b/frontend/src/lib/plexStats.ts @@ -2,8 +2,13 @@ // React/DOM so the number-crunching is unit-testable: the component gathers raw values off videoRef / // hlsRef and hands them here; this returns i18n-keyed rows the overlay renders. No data pipeline — the // HTML5