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

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

@@ -785,7 +795,7 @@ export function PlexSeasonView({ setAddTarget({ kind: "group", ratingKeys: seasonKeys, - title: `${d.show.title} — ${se.title}`, + title: `${d.show.title} — ${seasonTitle(se, t)}`, }) } icon={ListPlus} diff --git a/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx b/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx index 5aedae94..d3e3c96a 100644 --- a/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx +++ b/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx @@ -75,7 +75,7 @@ describe("usePlexPlaylistEdits · the reorder", () => { act(() => hook.result.current.commit([movie("2"), movie("1")])); await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1"])); await waitFor(() => - expect(ids(qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))!.items)).toEqual([ + expect(ids(qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5, "en"))!.items)).toEqual([ "2", "1", ]), @@ -100,7 +100,11 @@ describe("usePlexPlaylistEdits · the reorder", () => { // the same `q.data`, and could never correct a stale rollback. detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never); act(() => - qc.setQueryData(qk.plexPlaylist(5), { id: 5, title: "F", items: [movie("1"), movie("3")] }), + qc.setQueryData(qk.plexPlaylist(5, "en"), { + id: 5, + title: "F", + items: [movie("1"), movie("3")], + }), ); // The view has taken the refreshed list in BEFORE the reorder fails — the race as it happens. // (react-query batches its notification, so without this wait the refresh landed only after the diff --git a/frontend/src/components/usePlexPlaylistEdits.ts b/frontend/src/components/usePlexPlaylistEdits.ts index 7f497f47..273cbfb0 100644 --- a/frontend/src/components/usePlexPlaylistEdits.ts +++ b/frontend/src/components/usePlexPlaylistEdits.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { plexLang } from "../i18n"; import { api, type PlexCard } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { notify } from "../lib/notifications"; @@ -15,9 +16,13 @@ export function usePlexPlaylistEdits(playlistId: number) { const { t } = useTranslation(); const qc = useQueryClient(); const [busy, setBusy] = useState(false); + // The rows' titles are in the reader's language (E30), so the cache entry is per language — and ONE + // key serves the read and every write below (setQueryData matches exactly). + const lang = plexLang(); + const key = qk.plexPlaylist(playlistId, lang); const q = useQuery({ - queryKey: qk.plexPlaylist(playlistId), - queryFn: () => api.plexPlaylist(playlistId), + queryKey: key, + queryFn: () => api.plexPlaylist(playlistId, lang), }); // Local optimistic copy so drag/remove are instant; re-synced whenever the query returns. const [items, setItems] = useState([]); @@ -43,7 +48,7 @@ export function usePlexPlaylistEdits(playlistId: number) { // it, and the next drag persisted an order containing it (deep-review round 5). `Playlists.tsx`'s // sibling `restoreFromCache` reads it live for the same reason. const putBack = () => { - const cur = qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId)); + const cur = qc.getQueryData<{ items: PlexCard[] }>(key); if (cur) setItems(cur.items); invalidate(); }; @@ -63,9 +68,7 @@ export function usePlexPlaylistEdits(playlistId: number) { // next drag from there persisted it, silently undoing the one the server took. The // YouTube sibling (`Playlists.tsx`) got this in round 5; this half was left behind (round // 6). One accepted write, one cache entry that matches it. - qc.setQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId), (old) => - old ? { ...old, items: next } : old, - ); + qc.setQueryData<{ items: PlexCard[] }>(key, (old) => (old ? { ...old, items: next } : old)); qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) }); // …and then REFETCH it, exactly as the sibling does. `next` is this render's membership, and // the server only reorders the keys it was given — so anything that landed meanwhile (a row @@ -112,8 +115,8 @@ export function usePlexPlaylistEdits(playlistId: number) { onClick: () => void qc .fetchQuery({ - queryKey: qk.plexPlaylist(playlistId), - queryFn: () => api.plexPlaylist(playlistId), + queryKey: key, + queryFn: () => api.plexPlaylist(playlistId, lang), staleTime: 0, }) .then((now) => { diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index ab172c69..20c5eeb5 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -124,6 +124,9 @@ "markNextWatched": "Mark S{{season}}·E{{episode}} watched", "markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched", "nextUp": "Next: S{{season}}·E{{episode}}", + "episodeLine": "S{{season}}·E{{episode}} · {{title}}", + "seasonTitle": "Season {{n}}", + "specials": "Specials", "watchedFailed": "Couldn't update the watched state — please try again", "seasons": "{{count}} seasons", "unified": { diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 0f92d39b..de19570e 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -124,6 +124,9 @@ "markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl", "markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl", "nextUp": "Következik: S{{season}}·E{{episode}}", + "episodeLine": "S{{season}}·E{{episode}} · {{title}}", + "seasonTitle": "{{n}}. évad", + "specials": "Különkiadások", "watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra", "seasons": "{{count}} évad", "unified": { diff --git a/frontend/src/lib/api/discover.dom.test.ts b/frontend/src/lib/api/discover.dom.test.ts index 960e2e82..880ba3aa 100644 --- a/frontend/src/lib/api/discover.dom.test.ts +++ b/frontend/src/lib/api/discover.dom.test.ts @@ -50,3 +50,16 @@ describe("discoverApi query strings", () => { expect(lastUrl()).not.toContain("exclude="); }); }); + +describe("the reader's language (E30)", () => { + it("asks for Hungarian Plex cards on every Home read, and leaves English implied", async () => { + await discoverApi.discover("any", 0, "hu"); + expect(lastUrl()).toContain("lang=hu"); + await discoverApi.discoverPick("any", 1, [], 0, "hu"); + expect(lastUrl()).toContain("lang=hu"); + await discoverApi.comfortPins("hu"); + expect(lastUrl()).toContain("lang=hu"); + await discoverApi.discover("any", 0, "en"); + expect(lastUrl()).not.toContain("lang="); + }); +}); diff --git a/frontend/src/lib/api/discover.ts b/frontend/src/lib/api/discover.ts index e2dfab03..c69e1118 100644 --- a/frontend/src/lib/api/discover.ts +++ b/frontend/src/lib/api/discover.ts @@ -71,8 +71,9 @@ export interface DiscoverPayload { } export const discoverApi = { - discover: (budget: DiscoverBudget, seed: number): Promise => - req(`/api/discover?budget=${budget}&seed=${seed}`), + // `lang` "hu" serves a Plex card's Hungarian title (E30); en is the server default. + discover: (budget: DiscoverBudget, seed: number, lang?: string): Promise => + req(`/api/discover?budget=${budget}&seed=${seed}${lang === "hu" ? "&lang=hu" : ""}`), // The "Mást kérek" reroll: a new hero alone, so pressing it does not re-fetch the rails under the // user's cursor. // `exclude` is the rolling list of `:` keys already shown. Without it a reroll only @@ -84,12 +85,15 @@ export const discoverApi = { seed: number, exclude: string[] = [], press = 0, + lang?: string, ): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> => req( `/api/discover/pick?budget=${budget}&seed=${seed}&start=${press}` + - (exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : ""), + (exclude.length ? `&exclude=${encodeURIComponent(exclude.join(","))}` : "") + + (lang === "hu" ? "&lang=hu" : ""), ), - comfortPins: (): Promise<{ items: DiscoverCard[] }> => req("/api/discover/pins"), + comfortPins: (lang?: string): Promise<{ items: DiscoverCard[] }> => + req(`/api/discover/pins${lang === "hu" ? "?lang=hu" : ""}`), addComfortPin: ( kind: PinKind, ref: string, diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index ed84d057..5941e6a8 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -495,12 +495,13 @@ export const plexApi = { }, // One collection's hero + its members in the requested order (the Collection info page). The sort // vocabulary is the browse grid's, so the same Sort control means the same thing on both. - plexCollection: (rk: string, sort?: SortState): Promise => { + plexCollection: (rk: string, sort?: SortState, lang?: string): Promise => { const u = new URLSearchParams(); if (sort) { u.set("sort", sort.key); u.set("sort_dir", sort.dir); } + if (lang === "hu") u.set("lang", "hu"); const qs = u.toString(); return req(`/api/plex/collections/${encodeURIComponent(rk)}${qs ? `?${qs}` : ""}`); }, @@ -552,7 +553,8 @@ export const plexApi = { const qs = p.toString(); return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`); }, - plexPlaylist: (id: number): Promise => req(`/api/plex/playlists/${id}`), + plexPlaylist: (id: number, lang?: string): Promise => + req(`/api/plex/playlists/${id}${lang === "hu" ? "?lang=hu" : ""}`), plexCreatePlaylist: (title: string, item_rating_key?: string): Promise => req(`/api/plex/playlists`, { method: "POST", @@ -636,8 +638,8 @@ export const plexApi = { `/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`, ), // One cast/crew member's filmography: cached headshot + movies and shows (cast OR directors). - plexPerson: (name: string): Promise => - req(`/api/plex/person/${encodeURIComponent(name)}`), + plexPerson: (name: string, lang?: string): Promise => + req(`/api/plex/person/${encodeURIComponent(name)}${lang === "hu" ? "?lang=hu" : ""}`), plexSession: ( id: string, start = 0, diff --git a/frontend/src/lib/api/plexLang.dom.test.ts b/frontend/src/lib/api/plexLang.dom.test.ts new file mode 100644 index 00000000..8add679c --- /dev/null +++ b/frontend/src/lib/api/plexLang.dom.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +// +// E30: every Plex read that returns cards carries the reader's language, so the server can serve the +// Hungarian titles. The grid and the detail pages always did; these reads did not. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { plexApi } from "./plex"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({}), + text: async () => "{}", + }); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => vi.unstubAllGlobals()); + +const lastUrl = (): string => { + const calls = fetchMock.mock.calls; + return String(calls[calls.length - 1]?.[0]); +}; + +describe("Plex card reads in the reader's language", () => { + it.each([ + ["a playlist", () => plexApi.plexPlaylist(5, "hu"), () => plexApi.plexPlaylist(5, "en")], + ["a person", () => plexApi.plexPerson("Anna", "hu"), () => plexApi.plexPerson("Anna", "en")], + [ + "a collection", + () => plexApi.plexCollection("c1", { key: "title", dir: "asc" }, "hu"), + () => plexApi.plexCollection("c1", { key: "title", dir: "asc" }, "en"), + ], + ])("%s asks for Hungarian, and leaves English implied", async (_what, hu, en) => { + await hu(); + expect(lastUrl()).toContain("lang=hu"); + await en(); + expect(lastUrl()).not.toContain("lang="); + }); +}); diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts index 20d5872f..c4d2e692 100644 --- a/frontend/src/lib/plexDetail.ts +++ b/frontend/src/lib/plexDetail.ts @@ -1,5 +1,6 @@ import { useLayoutEffect } from "react"; import { usePref, useSavePref } from "./usePref"; +import type { TFunction } from "i18next"; import type { PlexCard } from "./api"; // The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display @@ -149,3 +150,19 @@ export function epLabel(ep?: PlexCard | null): string | undefined { return `S${ep.season_number} · E${ep.episode_number}`; return ep.title; } + +// Plex names seasons in the library's language ("Season 1", "Specials"), so a Hungarian reader got +// English there (E30). A generic name is rebuilt from the number in the reader's language; a season +// with a name of its own ("Book One: Water") keeps it. +const GENERIC_SEASON = /^(season \d+|specials)$/i; + +export function seasonTitle( + se: { title?: string | null; season_number?: number | null }, + t: TFunction, +): string { + const n = se.season_number; + if (se.title && !GENERIC_SEASON.test(se.title.trim())) return se.title; + if (n === 0) return t("plex.specials"); + if (n != null) return t("plex.seasonTitle", { n }); + return se.title || t("plex.playlist.seasonUnknown"); +} diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 2877880c..ec8eca20 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -125,7 +125,7 @@ export const qk = { // finish it, and it was still the answer on screen with a zeroed progress bar. discover: (...rest: unknown[]) => ["discover", ...rest], discoverPick: (...rest: unknown[]) => ["discover", "pick", ...rest], - comfortPins: () => ["comfort-pins"] as const, + comfortPins: (lang?: string) => ["comfort-pins", ...(lang ? [lang] : [])] as const, plex: () => ["plex"] as const, plexWatchLink: () => ["plex-watch-link"] as const, plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest], @@ -150,11 +150,14 @@ export const qk = { // that steers the background extraction, not part of the track's identity. plexSubtitle: (id: string, ord: number) => ["plex-subtitle", id, ord] as const, plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest], - plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const, + // `lang` as on plexShow: omit it for a prefix that matches both languages. + plexPlaylist: (playlistId: number, lang?: string) => + ["plex-playlist", playlistId, ...(lang ? [lang] : [])] as const, plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest], // facetKey is a filters-derived OBJECT segment (not a scalar), so this key is heterogeneous too. plexFacets: (...rest: unknown[]) => ["plex-facets", ...rest], plexPeopleCards: (scope: string, actors: string[]) => ["plex-people-cards", scope, actors] as const, - plexPerson: (name: string) => ["plex-person", name] as const, + plexPerson: (name: string, lang?: string) => + ["plex-person", name, ...(lang ? [lang] : [])] as const, };