feat(plex): every card rail in the reader's language; an episode in a poster rail stands as its show (E30 S1b)

The grid served Hungarian titles, but every other card surface (Discover rails, the
show page's episodes, search, collections, the person page, playlists, Home's resume
rail, hero and comfort shelf) served English. The card serializers now take a
required keyword-only lang (a forgotten call site fails instead of going English), and
each of those reads takes ?lang=hu, with the language in its query key. A Hungarian
search finds an episode by its Hungarian title too.

An episode on a poster rail showed its 16:9 still cropped into a poster, titled only
with the episode name; it now shows the show's poster and name, with S·E · title under
it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the
number in the reader's language; a season with a name of its own keeps it.

Home's comfort-shelf move wrote its optimistic order to the key without the language,
which nothing read any more; it writes the query's own key.
This commit is contained in:
2026-09-29 02:12:32 +02:00
parent 13d95ef70e
commit 632e39d458
37 changed files with 673 additions and 203 deletions
+5 -4
View File
@@ -48,6 +48,7 @@ def pick(
shelf: list[dict] | None = None, shelf: list[dict] | None = None,
exclude: set[str] | None = None, exclude: set[str] | None = None,
start: int = 0, start: int = 0,
lang: str,
) -> dict | None: ) -> dict | None:
"""`{"tier": ..., "card": ...}`, or None when nothing at all fits. """`{"tier": ..., "card": ...}`, or None when nothing at all fits.
@@ -70,8 +71,8 @@ def pick(
skip = exclude or set() skip = exclude or set()
def _resume() -> dict | None: def _resume() -> dict | None:
for card in rails.continue_watching(db, user) if resume is None else resume: for card in rails.continue_watching(db, user, lang=lang) if resume is None else resume:
playable = rails.playable(db, user.id, card) 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 # 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. # 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): 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: def _comfort() -> dict | None:
if shelf is 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: else:
own = shelf own = shelf
for card in pins.playables(own): for card in pins.playables(own):
@@ -93,7 +94,7 @@ def pick(
def _surprise() -> dict | None: def _surprise() -> dict | None:
# A wider draw than the one card we need: the shuffle is seeded, so without the headroom an # 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. # 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: if cards.key(card) not in skip:
return card return card
return None return None
+6 -6
View File
@@ -170,7 +170,7 @@ class _Batch:
return b 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 """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).""" cascade removes the pin itself, so this is only the window between a delete and a refetch)."""
card: dict | None = None card: dict | None = None
@@ -196,7 +196,7 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict
if it is None: if it is None:
return None return None
st = rollup.item_state_map(db, user.id, [it.id]).get(it.id) 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 nxt = dict(card) # a movie or a single episode plays itself
elif pin.plex_show_id is not None: 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: if sh is None:
return None return None
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new") status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
card = cards.from_plex(show_card(sh, status)) card = cards.from_plex(show_card(sh, status, lang=lang))
ep = queries.next_episode(db, user.id, sh.id) ep = queries.next_episode(db, user.id, sh.id, lang=lang)
nxt = cards.from_plex(ep) if ep else None nxt = cards.from_plex(ep) if ep else None
elif pin.playlist_id is not 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") _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. """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, `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)] pins = [p for p in pins if all(getattr(p, c) is None for c in _PLEX_COLUMNS)]
batch = _Batch.load(db, user, pins) batch = _Batch.load(db, user, pins)
for pin in 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: if card is not None:
out.append(card) out.append(card)
return out return out
+16 -8
View File
@@ -40,7 +40,7 @@ from app.models import (
VideoState, VideoState,
) )
from app.plex import rollup 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 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 # 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 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). """In-progress movies + partially-watched shows as (progressed_at, card).
Mirrors `routes/plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a 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) .limit(limit)
) )
for it, st in db.execute(mq).all(): 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: if show_libs:
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery() 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( for sh, resumed_at in db.execute(
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit) sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
).all(): ).all():
out.append((resumed_at, show_card(sh, "in_progress"))) out.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
return out 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. """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 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) q = q.where(PlexItem.duration_s <= hi)
items = db.execute(q).scalars().all() items = db.execute(q).scalars().all()
states = rollup.item_state_map(db, uid, [it.id for it in items]) 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". # Watch states that take an episode out of the running for "what would play next".
_DONE = ("watched", "hidden") _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 """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. 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 ep, st = pick
show = db.get(PlexShow, show_id) show = db.get(PlexShow, show_id)
return leaf_card( 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,
) )
+14 -7
View File
@@ -22,7 +22,7 @@ def plex_available(db: Session) -> bool:
return sysconfig.get_bool(db, "plex_enabled") 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 """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). 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"]) sh = by_rating_key(db, PlexShow, card["id"])
if sh is None: if sh is None:
return 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 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. """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 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) (ts, cards.from_video(r)) for ts, r in queries.yt_in_progress(db, user, limit)
] ]
if plex_available(db): 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) rows.sort(key=lambda r: r[0] or NO_TS, reverse=True)
out = [card for _, card in rows[:limit]] out = [card for _, card in rows[:limit]]
# Resolve AFTER the slice: only the rows that survive it are worth a query. # Resolve AFTER the slice: only the rows that survive it are worth a query.
for card in out: for card in out:
if card["kind"] == "show": if card["kind"] == "show":
card["next"] = playable(db, user.id, card) card["next"] = playable(db, user.id, card, lang=lang)
return out 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. """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 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_video, queries.yt_surprise(db, user, budget, seed, limit)),
( (
cards.from_plex, 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: if seed % 2:
+32 -14
View File
@@ -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 # 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 # stays a few hundred bytes each rather than carrying full synopses.
# the EN summary/rating; the main grid coalesces the HU twin in browse.unified_library.)
def _card_summary(summary: str | None) -> str | None: def _card_summary(summary: str | None) -> str | None:
return (summary or "")[:300] or 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 { return {
"id": it.rating_key, "id": it.rating_key,
"type": "movie", "type": "movie",
"title": it.title, "title": display_title(it, lang),
"year": it.year, "year": it.year,
"rating": it.rating, "rating": it.rating,
"summary": _card_summary(it.summary), "summary": _card_summary(display_summary(it, lang)),
"duration_seconds": it.duration_s, "duration_seconds": it.duration_s,
"thumb": f"/api/plex/image/{it.rating_key}", "thumb": f"/api/plex/image/{it.rating_key}",
"art": _art_url(it.rating_key, it.art_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 { return {
"id": sh.rating_key, "id": sh.rating_key,
"type": "show", "type": "show",
"title": sh.title, "title": display_title(sh, lang),
"year": sh.year, "year": sh.year,
"rating": sh.rating, "rating": sh.rating,
"summary": _card_summary(sh.summary), "summary": _card_summary(display_summary(sh, lang)),
"thumb": f"/api/plex/image/{sh.rating_key}", "thumb": f"/api/plex/image/{sh.rating_key}",
"art": _art_url(sh.rating_key, sh.art_key), "art": _art_url(sh.rating_key, sh.art_key),
"season_count": sh.child_count, "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 { return {
"id": e.rating_key, "id": e.rating_key,
"type": "episode", "type": "episode",
"title": e.title, "title": display_title(e, lang),
"summary": e.summary, "summary": display_summary(e, lang),
"season_number": e.season_number, "season_number": e.season_number,
"episode_number": e.episode_number, "episode_number": e.episode_number,
"duration_seconds": e.duration_s, "duration_seconds": e.duration_s,
@@ -77,12 +92,15 @@ def leaf_card(
st: PlexState | None, st: PlexState | None,
show_title: str | None = None, show_title: str | None = None,
show_rk: str | None = None, show_rk: str | None = None,
*,
lang: str,
) -> dict: ) -> dict:
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds. """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": if it.kind == "episode":
card = episode_card(it, st) card = episode_card(it, st, lang=lang)
card["show_title"] = show_title card["show_title"] = show_title
card["show_id"] = show_rk card["show_id"] = show_rk
return card return card
return movie_card(it, st) return movie_card(it, st, lang=lang)
+16 -5
View File
@@ -23,6 +23,7 @@ from app.discover import hero as hero_mod
from app.discover import pins, rails from app.discover import pins, rails
from app.discover.budget import Budget, DEFAULT_BUDGET from app.discover.budget import Budget, DEFAULT_BUDGET
from app.models import ComfortPin, User from app.models import ComfortPin, User
from app.plex.cards import norm_lang
from app.routes._common import owned_or_404, not_found from app.routes._common import owned_or_404, not_found
router = APIRouter(prefix="/api/discover", tags=["discover"]) router = APIRouter(prefix="/api/discover", tags=["discover"])
@@ -45,6 +46,7 @@ class ReorderIn(BaseModel):
def get_discover( def get_discover(
budget: Budget = DEFAULT_BUDGET, budget: Budget = DEFAULT_BUDGET,
seed: int = 0, seed: int = 0,
lang: str = "en",
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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 `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.""" 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 # 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`). # 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 { return {
"budget": budget, "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, "continue_watching": resume,
"comfort": shelf, "comfort": shelf,
} }
@@ -80,6 +83,7 @@ def get_pick(
seed: int = 0, seed: int = 0,
exclude: str = "", exclude: str = "",
start: int = 0, start: int = 0,
lang: str = "en",
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@@ -99,18 +103,25 @@ def get_pick(
return { return {
"budget": budget, "budget": budget,
"hero": hero_mod.pick( "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") @router.get("/pins")
def list_pins( def list_pins(
lang: str = "en",
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""The shelf on its own — for the shelf editor, which needs it without the rest of the page.""" """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") @router.post("/pins")
+1 -5
View File
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
from app import logs from app import logs
from app.models import PlexItem 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.plex.lookups import by_rating_key
from app.routes._common import not_found 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: if it is None:
raise not_found("plex_item") raise not_found("plex_item")
return it 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"
+18 -11
View File
@@ -10,6 +10,7 @@ from sqlalchemy import (
func, func,
literal, literal,
null, null,
or_,
select, select,
) )
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
@@ -25,7 +26,7 @@ from app.models import (
User, User,
) )
from app.plex import rollup 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 ( from app.plex.query import (
FIELD_COLUMNS, FIELD_COLUMNS,
PLAYBACK_CLASSES, PLAYBACK_CLASSES,
@@ -281,26 +282,32 @@ def unified_library(
episodes = [] episodes = []
if tsq is not None and want_shows and field in ("all", "episode"): if tsq is not None and want_shows and field in ("all", "episode"):
ep_st = aliased(PlexState) ep_st = aliased(PlexState)
ep_vec = ( # In HU an episode is found by its Hungarian title too (E30: the overlay now fills it) — and
func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, "")) # still by its English one, like `vector_match` does for the grid.
if field == "episode" if field == "episode":
else PlexItem.search_vector 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( 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(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id))
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id) .outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
.where( .where(
PlexItem.kind == "episode", PlexItem.kind == "episode",
PlexItem.library_id.in_(show_lib_ids), PlexItem.library_id.in_(show_lib_ids),
ep_vec.op("@@")(tsq), hit,
) )
.order_by(func.ts_rank(ep_vec, tsq).desc()) .order_by(func.ts_rank(ep_vec, tsq).desc())
.limit(24) .limit(24)
).all() ).all()
for e, s, show_title in eps: for e, s, sh in eps:
card = episode_card(e, s) card = episode_card(e, s, lang=lang)
card["show_title"] = show_title card["show_title"] = display_title(sh, lang) if sh is not None else None
episodes.append(card) episodes.append(card)
return { return {
+7 -5
View File
@@ -22,7 +22,7 @@ from app.models import (
) )
from app.plex import rollup from app.plex import rollup
from app.plex import sync as plex_sync 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.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key 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, top_art_keys
@@ -229,6 +229,7 @@ def collection_detail(
rating_key: str, rating_key: str,
sort: str = DEFAULT_MEMBER_SORT, sort: str = DEFAULT_MEMBER_SORT,
sort_dir: str = "asc", sort_dir: str = "asc",
lang: str = "en",
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@@ -237,6 +238,7 @@ def collection_detail(
control means the same thing on both surfaces; the default is release year ascending, which is 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 what this page did before it had a control. Read-only here; edits go through the admin mutation
endpoints below.""" endpoints below."""
lang = norm_lang(lang)
col = by_rating_key(db, PlexCollection, rating_key) col = by_rating_key(db, PlexCollection, rating_key)
if col is None: if col is None:
raise not_found("collection") raise not_found("collection")
@@ -285,7 +287,7 @@ def collection_detail(
Member( Member(
kind="movie", kind="movie",
obj=m, obj=m,
title=(m.title or "").lower(), title=display_title(m, lang).lower(),
year=m.year, year=m.year,
rating=m.rating, rating=m.rating,
added=m.added_at, added=m.added_at,
@@ -298,7 +300,7 @@ def collection_detail(
Member( Member(
kind="show", kind="show",
obj=sh, obj=sh,
title=(sh.title or "").lower(), title=display_title(sh, lang).lower(),
year=sh.year, year=sh.year,
rating=sh.rating, rating=sh.rating,
added=sh.added_at, added=sh.added_at,
@@ -310,9 +312,9 @@ def collection_detail(
] ]
members = sort_members(members, sort, sort_dir) members = sort_members(members, sort, sort_dir)
items = [ 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" 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 for m in members
] ]
years = [m.year for m in members if m.year is not None] years = [m.year for m in members if m.year is not None]
+11 -8
View File
@@ -107,7 +107,8 @@ def show_detail(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""A show's seasons + episodes (the drill-down view) with per-user watch state.""" """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) sh = by_rating_key(db, PlexShow, rating_key)
if sh is None: if sh is None:
raise not_found("show") raise not_found("show")
@@ -144,7 +145,7 @@ def show_detail(
by_season: dict[int | None, list] = {} by_season: dict[int | None, list] = {}
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
for e in eps: 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) by_season.setdefault(e.season_id, []).append(card)
all_cards.append(card) all_cards.append(card)
show_roll = rollup.rollup(all_cards) show_roll = rollup.rollup(all_cards)
@@ -156,7 +157,7 @@ def show_detail(
related: list[dict] = [] related: list[dict] = []
try: try:
meta, related_raw = _show_enrichment(db, sh.rating_key) 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: if sh.people_enriched_at is None and meta:
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(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 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), """Map Plex 'related' metadata to show cards for the shows we actually mirror (so they're openable),
excluding the current show. Order preserved; capped.""" excluding the current show. Order preserved; capped."""
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")] 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: if sh is None or rk in seen:
continue continue
seen.add(rk) 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: if len(out) >= 20:
break break
return out 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 """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).""" 'Avatar Collection' strip on the info page. Pure local lookup (no Plex call)."""
keys = it.collection_keys or [] keys = it.collection_keys or []
@@ -305,7 +308,7 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]:
"id": rk, "id": rk,
"title": col.title, "title": col.title,
"source": _collection_source(col), "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 return out
@@ -512,7 +515,7 @@ def item_detail(
"episode_number": it.episode_number, "episode_number": it.episode_number,
"prev_id": prev_id, "prev_id": prev_id,
"next_id": next_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 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) if it.kind == "movie" else None,
} }
+22 -17
View File
@@ -22,7 +22,7 @@ from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User from app.models import PlexItem, PlexShow, PlexState, User
from app.plex import rollup 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 ( from app.plex.query import (
LibraryFilters, LibraryFilters,
apply_meta_filters, apply_meta_filters,
@@ -61,14 +61,14 @@ def _movie_meta(q, p: dict, lang: str, f: LibraryFilters):
return q 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]) 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]) 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( def _recently_added(
@@ -123,8 +123,8 @@ def _recently_added(
# share one), so the merge doesn't depend on object identity staying alive. # 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_movies = [o for _, kind, o in top if kind == "movie"]
top_shows = [o for _, kind, o in top if kind == "show"] 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))} 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))} 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] 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()) .order_by(PlexState.watched_at.desc().nullslast())
.limit(RAIL_LIMIT) .limit(RAIL_LIMIT)
).all(): ).all():
rows.append((st.watched_at, leaf_card(it, st))) rows.append((st.watched_at, leaf_card(it, st, lang=lang)))
if show_ids: if show_ids:
eq = ( 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(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
.join(PlexShow, PlexShow.id == PlexItem.show_id) .join(PlexShow, PlexShow.id == PlexItem.show_id)
.where( .where(
@@ -170,8 +170,11 @@ def _recently_watched(
.order_by(PlexState.watched_at.desc().nullslast()) .order_by(PlexState.watched_at.desc().nullslast())
.limit(RAIL_LIMIT) .limit(RAIL_LIMIT)
) )
for ep, st, sh_title, sh_rk in db.execute(eq).all(): for ep, st, sh in db.execute(eq).all():
rows.append((st.watched_at, leaf_card(ep, st, show_title=sh_title, show_rk=sh_rk))) 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) rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
return [card for _, card in rows[:RAIL_LIMIT]] return [card for _, card in rows[:RAIL_LIMIT]]
@@ -214,7 +217,7 @@ def _continue_watching(
.limit(RAIL_LIMIT) .limit(RAIL_LIMIT)
).all() ).all()
for it, st in mrows: 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: if show_ids:
# Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus # 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) .limit(RAIL_LIMIT)
).all() ).all()
for sh, resumed_at in srows: 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) rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
return [card for _, card in rows[:RAIL_LIMIT]] 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] 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()). # rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
mcards = { 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] = [] items: list[dict] = []
for rk in rks: for rk in rks:
if rk in mcards: if rk in mcards:
@@ -456,8 +461,8 @@ def surprise(
.scalars() .scalars()
.all() .all()
) )
mcards = _movie_cards(db, user.id, movies) mcards = _movie_cards(db, user.id, movies, lang)
scards = _show_cards(db, user.id, shows) scards = _show_cards(db, user.id, shows, lang)
# Interleave movie/show cards, then trim to `limit`. # Interleave movie/show cards, then trim to `limit`.
items: list[dict] = [] items: list[dict] = []
for i in range(max(len(mcards), len(scards))): for i in range(max(len(mcards), len(scards))):
+9 -6
View File
@@ -18,7 +18,7 @@ from app.models import (
) )
from app.plex import rollup from app.plex import rollup
from app.plex import tmdb as plex_tmdb 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 from app.plex.query import content_libs, split_csv
router = APIRouter(prefix="/api/plex", tags=["plex"]) router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -31,6 +31,7 @@ def _resolve_known_for(
uid: int, uid: int,
movie_lib_ids: list[int], movie_lib_ids: list[int],
show_lib_ids: list[int], show_lib_ids: list[int],
lang: str,
) -> list[dict]: ) -> list[dict]:
"""The person's TMDB "Known for" credits (top by popularity) that are IN our library, as cards, in """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:<id>`; out-of-library credits are dropped.""" popularity order. Matched by the canonical guid `tmdb:<id>`; 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. # A credit resolves against its OWN media_type's bucket only.
if k.get("media_type") == "tv": if k.get("media_type") == "tv":
if g in shows: 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: 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 return out
@@ -159,6 +160,7 @@ def _enrich_person_bg(person_id: int) -> None:
def person_detail( def person_detail(
name: str, name: str,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
lang: str = "en",
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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 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. (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() 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. # 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) # 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() .all()
) )
states = rollup.item_state_map(db, user.id, [it.id for it in items]) 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] = [] shows: list[dict] = []
if show_lib_ids: if show_lib_ids:
@@ -227,10 +230,10 @@ def person_detail(
.all() .all()
) )
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows]) 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( 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 { return {
"name": name, "name": name,
+8 -3
View File
@@ -19,7 +19,7 @@ from app.models import (
PlexState, PlexState,
User, 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.plex.lookups import by_rating_key
from app.routes._common import owned_or_404, not_found from app.routes._common import owned_or_404, not_found
from app.refusal import refused from app.refusal import refused
@@ -137,8 +137,12 @@ def create_playlist(
@router.get("/playlists/{pid}") @router.get("/playlists/{pid}")
def playlist_detail( 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: ) -> dict:
lang = norm_lang(lang)
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist") pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
rows = db.execute(_playlist_item_query(pid)).all() rows = db.execute(_playlist_item_query(pid)).all()
items = [it for (_pi, it) in rows] items = [it for (_pi, it) in rows]
@@ -163,8 +167,9 @@ def playlist_detail(
leaf_card( leaf_card(
it, it,
states.get(it.id), 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, shows[it.show_id].rating_key if it.show_id in shows else None,
lang=lang,
) )
for it in items for it in items
] ]
+53 -39
View File
@@ -180,7 +180,7 @@ def test_continue_watching_merges_both_sources_by_recency(db):
) )
db.commit() 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 [(c["source"], c["id"]) for c in got] == [("plex", "pm1"), ("youtube", "vmed")]
assert got[0]["remaining_seconds"] == 100 * 60 - 600 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() 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): 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() 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.add(BlockedChannel(user_id=user.id, channel_id=ch.id))
db.commit() 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. # 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): def test_a_null_timestamp_sorts_last(db):
@@ -263,7 +263,7 @@ def test_a_null_timestamp_sorts_last(db):
] ]
) )
db.commit() db.commit()
got = rails.continue_watching(db, user) got = rails.continue_watching(db, user, lang="en")
assert got[0]["id"] == "vshort" assert got[0]["id"] == "vshort"
assert len(got) == 3 assert len(got) == 3
@@ -283,7 +283,7 @@ def test_hero_prefers_resume_and_labels_the_tier(db):
) )
) )
db.commit() 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["tier"] == "resume"
assert got["card"]["id"] == "vmed" assert got["card"]["id"] == "vmed"
@@ -302,7 +302,7 @@ def test_hero_skips_a_resume_that_does_not_fit_the_budget(db):
) )
) )
db.commit() 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["tier"] == "surprise"
assert got["card"]["duration_seconds"] <= BANDS["short"][1] 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) user, ch = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0)) db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
db.commit() 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["tier"] == "comfort"
assert got["card"]["id"] == "vmed" assert got["card"]["id"] == "vmed"
@@ -332,7 +332,7 @@ def test_hero_resolves_a_show_to_the_episode_that_would_play(db):
) )
) )
db.commit() 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["tier"] == "resume"
assert got["card"]["kind"] == "episode" assert got["card"]["kind"] == "episode"
assert got["card"]["id"] == "ps1e2" # ep1 is finished → ep2 is what plays 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) user, ch = _seed_yt(db)
db.execute(text("DELETE FROM videos WHERE id = 'vlong'")) db.execute(text("DELETE FROM videos WHERE id = 'vlong'"))
db.commit() 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): 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 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.""" the same answer and a different seed must be free to give another."""
user, _ = _seed_yt(db) user, _ = _seed_yt(db)
a = 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) 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] 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 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() 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): 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) user, _ = _seed_yt(db)
_seed_plex(db, user) _seed_plex(db, user)
_enable_plex(db) _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"} assert leaders == {"youtube", "plex"}
@@ -413,10 +419,10 @@ def test_the_reroll_must_be_able_to_move_past_the_resume_tier(db):
) )
db.commit() 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" assert first["card"]["id"] == "vmed"
# Same seed, same everything — only the exclusion differs. # 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" 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.add(ComfortPin(user_id=user.id, video_id="vlong", position=0))
db.commit() 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"] 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.""" Here there is no shelf at all, so the comfort-led press has to answer from somewhere else."""
user, _ = _seed_yt(db) user, _ = _seed_yt(db)
assert db.execute(select(func.count()).select_from(ComfortPin)).scalar_one() == 0 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" 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() 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 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. # 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" 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) user, ch = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=0)) db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=0))
db.commit() db.commit()
(card,) = pins.shelf(db, user) (card,) = pins.shelf(db, user, lang="en")
assert card["kind"] == "channel" assert card["kind"] == "channel"
assert card["unwatched_count"] == 3 assert card["unwatched_count"] == 3
assert card["next"]["id"] == "vshort" # newest 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(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.add(ComfortPin(user_id=user.id, playlist_id=pl.id, position=0))
db.commit() db.commit()
(card,) = pins.shelf(db, user) (card,) = pins.shelf(db, user, lang="en")
assert card["kind"] == "playlist" assert card["kind"] == "playlist"
assert card["item_count"] == 2 assert card["item_count"] == 2
assert card["next"]["id"] == "vmed" assert card["next"]["id"] == "vmed"
@@ -585,7 +591,7 @@ def test_the_shelf_resolves_each_channel_and_playlist_on_its_own_terms(db):
got = [ got = [
(c.get("unwatched_count", c.get("item_count")), (c["next"] or {}).get("id")) (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)] 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, channel_id=ch.id, position=0))
db.add(ComfortPin(user_id=user.id, playlist_id=lists[0].id, position=1)) db.add(ComfortPin(user_id=user.id, playlist_id=lists[0].id, position=1))
db.commit() 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): for i, c in enumerate(extra):
db.add(ComfortPin(user_id=user.id, channel_id=c.id, position=10 + i)) db.add(ComfortPin(user_id=user.id, channel_id=c.id, position=10 + i))
for i, p in enumerate(lists[1:]): for i, p in enumerate(lists[1:]):
db.add(ComfortPin(user_id=user.id, playlist_id=p.id, position=20 + i)) db.add(ComfortPin(user_id=user.id, playlist_id=p.id, position=20 + i))
db.commit() 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): 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.commit()
db.execute(delete(Subscription)) db.execute(delete(Subscription))
db.commit() db.commit()
(card,) = pins.shelf(db, user) (card,) = pins.shelf(db, user, lang="en")
assert card["id"] == "vmed" assert card["id"] == "vmed"
assert card["next"]["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) _seed_plex(db, user)
_enable_plex(db) _enable_plex(db)
drawn = lambda budget: { # noqa: E731 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") assert ("plex", "pm1") not in drawn("short")
# …and it IS offered where it fits, so this cannot pass by the rail being empty. # …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() db.commit()
drawn = { 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 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.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=pins.next_position(db, user)))
db.commit() 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 = ( positions = (
db.execute(select(ComfortPin.position).order_by(ComfortPin.position)).scalars().all() 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) user, _ = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0)) db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
db.commit() db.commit()
(card,) = pins.shelf(db, user) (card,) = pins.shelf(db, user, lang="en")
assert card["next"] is not card assert card["next"] is not card
assert "next" not in card["next"] assert "next" not in card["next"]
@@ -718,7 +728,7 @@ def test_the_resume_rail_says_what_would_actually_play(db):
) )
) )
db.commit() 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") tile = next(c for c in rail if c["kind"] == "show")
assert tile["id"] == "ps1" # still the show assert tile["id"] == "ps1" # still the show
assert tile["next"]["kind"] == "episode" assert tile["next"]["kind"] == "episode"
@@ -762,7 +772,7 @@ def test_only_a_show_row_pays_for_a_resolution(db):
) )
db.commit() 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 # 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. # actually exercises both sides of the equality.
assert {c["kind"] for c in rail} == {"video", "movie", "show"} 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) _, show, ep1 = _seed_plex(db, user)
db.add(PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW)) db.add(PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW))
db.commit() # ep2 has NO PlexState row 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): 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() 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): 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() 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" assert got["id"] == "ps1e2"
# Asserted against the other implementation rather than against a literal, so the two cannot # Asserted against the other implementation rather than against a literal, so the two cannot
# drift apart again without this failing. # drift apart again without this failing.
ordered = [ 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) for ep in (ep1, ep2)
] ]
assert rollup.rollup(ordered)["resume"]["id"] == got["id"] assert rollup.rollup(ordered)["resume"]["id"] == got["id"]
@@ -848,7 +862,7 @@ def test_next_episode_prefers_the_one_in_progress(db):
] ]
) )
db.commit() 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 ────────────────────────────────────────────────────────────────────────────────── # ── HTTP surface ──────────────────────────────────────────────────────────────────────────────────
+4 -4
View File
@@ -11,21 +11,21 @@ from app.plex.cards import movie_card, show_card
def test_movie_card_emits_art_when_art_key_present(): 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") 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["thumb"] == "/api/plex/image/m1"
assert card["art"] == "/api/plex/image/m1?variant=art" assert card["art"] == "/api/plex/image/m1?variant=art"
def test_movie_card_art_is_none_without_art_key(): def test_movie_card_art_is_none_without_art_key():
it = PlexItem(rating_key="m2", kind="movie", title="M2") 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(): def test_show_card_emits_art_when_art_key_present():
sh = PlexShow(rating_key="s1", title="S", art_key="/library/art/2") 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(): def test_show_card_art_is_none_without_art_key():
sh = PlexShow(rating_key="s2", title="S2") sh = PlexShow(rating_key="s2", title="S2")
assert show_card(sh)["art"] is None assert show_card(sh, lang="en")["art"] is None
+108 -1
View File
@@ -12,7 +12,18 @@ import app.state as state
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.main import app 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): 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) assert "High IMDb" in _titles(hit)
miss = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&rating_min=9") miss = _get(db, user, monkeypatch, override_dep, "/api/plex/library?scope=movie&rating_min=9")
assert "High IMDb" not in _titles(miss) 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"),
]
+27 -6
View File
@@ -9,6 +9,7 @@
// identical card straight back and the button looks dead. // 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 // i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. // a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { setLanguage } from "../i18n";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest"; import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react"; import { act, render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
@@ -461,7 +462,7 @@ describe("Home", () => {
mount(); mount();
await screen.findByText("Hero v1"); await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.budget.short")); // the empty-state branch needs a band 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. // A press that RESOLVES with nothing left: the honest empty state.
discoverPick.mockResolvedValueOnce({ budget: "short", hero: null }); 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. // A never-resolving refetch: the point is what the page shows WHILE it waits.
discover.mockReturnValue(new Promise(() => {})); discover.mockReturnValue(new Promise(() => {}));
fireEvent.click(screen.getByText("home.budget.short")); 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. // Still the old answer — not a spinner that would take the chips away with it.
expect(screen.getByText("Hero v1")).toBeTruthy(); expect(screen.getByText("Hero v1")).toBeTruthy();
}); });
@@ -523,7 +524,7 @@ describe("Home", () => {
await screen.findByText("Hero v1"); await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll")); fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => 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(); expect(await screen.findByText("Rerolled")).toBeTruthy();
}); });
@@ -584,7 +585,7 @@ describe("Home", () => {
mount(); mount();
expect(await screen.findByText("Rerolled")).toBeTruthy(); 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 // 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. // just the budget: the server answers each of them differently.
await waitFor(() => expect(discoverPick.mock.lastCall).toEqual(asked)); 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 // …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) // 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). // 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 // 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"); await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll")); fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => 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"); await screen.findByText("Rerolled");
fireEvent.click(screen.getByText("home.hero.reroll")); fireEvent.click(screen.getByText("home.hero.reroll"));
@@ -767,6 +774,7 @@ describe("Home", () => {
// reinstate the measured nine-card cycle) left the whole suite green. // reinstate the measured nine-card cycle) left the whole suite green.
["youtube:v1", "youtube:v9"], ["youtube:v1", "youtube:v9"],
2, 2,
"en",
), ),
); );
}); });
@@ -872,3 +880,16 @@ describe("Home's hero text", () => {
expect(text.querySelector("p.measure")).toBeNull(); 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");
}
});
});
+9 -6
View File
@@ -14,6 +14,7 @@ import {
Star, Star,
} from "lucide-react"; } from "lucide-react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { plexLang } from "../i18n";
import { import {
api, api,
DISCOVER_BUDGETS, DISCOVER_BUDGETS,
@@ -720,17 +721,19 @@ export default function Home() {
}, [budget, seed, press, seen]); }, [budget, seed, press, seen]);
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null); 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({ const q = useQuery({
queryKey: qk.discover(budget), queryKey: qk.discover(budget, lang),
queryFn: () => api.discover(budget, 0), queryFn: () => api.discover(budget, 0, lang),
// Keep the previous budget's answer on screen while the new one loads. Without this the page // 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 // 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.) // control you just clicked vanishes under your cursor. (Same reason the feed does it.)
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
const pickQ = useQuery({ const pickQ = useQuery({
queryKey: qk.discoverPick(budget, seed, seen, press), queryKey: qk.discoverPick(budget, lang, seed, seen, press),
queryFn: () => api.discoverPick(budget, seed, seen, press), queryFn: () => api.discoverPick(budget, seed, seen, press, lang),
enabled: seed !== 0, enabled: seed !== 0,
staleTime: 0, staleTime: 0,
// Hold the previous hero while the next one is in flight. Without this the reroll's new query key // 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( const moveInShelf = useCallback(
(card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => { (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => {
if (shelfSavingRef.current) return; 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 cards = rendered;
const from = cards.findIndex((c) => c.pin_id === card.pin_id); const from = cards.findIndex((c) => c.pin_id === card.pin_id);
const target = shelfMoveTarget(from, to, cards.length); const target = shelfMoveTarget(from, to, cards.length);
@@ -965,7 +968,7 @@ export default function Home() {
void qc.invalidateQueries({ queryKey: qk.discover() }); void qc.invalidateQueries({ queryKey: qk.discover() });
}); });
}, },
[budget, qc, t], [budget, lang, qc, t],
); );
const reroll = useCallback( const reroll = useCallback(
@@ -5,6 +5,7 @@
// expanded (lazily, on click) into its episodes in season→episode order; a show that expands to zero // 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 / // 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. // 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 { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -119,7 +120,7 @@ describe("PlexCollectionView — Play-all", () => {
const onPlay = mount([card("show1", "show")]); const onPlay = mount([card("show1", "show")]);
fireEvent.click(await screen.findByText("plex.collection.playAll")); fireEvent.click(await screen.findByText("plex.collection.playAll"));
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1)); await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
expect(spyShow).toHaveBeenCalledWith("show1"); expect(spyShow).toHaveBeenCalledWith("show1", "en");
expect(onPlay).toHaveBeenCalledWith("s1e1", ["s1e1", "s1e2", "s2e1"]); 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(); 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");
}
});
});
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react"; import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { plexLang } from "../i18n";
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors"; 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 // 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 // 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. // 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({ const q = useQuery({
queryKey: detailKey, 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 // 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 // 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 // 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) => { items.map(async (c) => {
if (c.type !== "show") return [c.id]; if (c.type !== "show") return [c.id];
const detail = await qc.fetchQuery({ const detail = await qc.fetchQuery({
queryKey: qk.plexShow(c.id), queryKey: qk.plexShow(c.id, lang),
queryFn: () => api.plexShow(c.id), queryFn: () => api.plexShow(c.id, lang),
}); });
return showEpisodeKeys(detail); return showEpisodeKeys(detail);
}), }),
@@ -5,6 +5,7 @@
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero, // 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 // 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. // 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 { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -123,7 +124,7 @@ describe("PlexPersonView", () => {
fireEvent.click(screen.getByText("plex.person.filterBy")); fireEvent.click(screen.getByText("plex.person.filterBy"));
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] }); 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 () => { it("shows the empty message when the person has no titles", async () => {
@@ -148,3 +149,33 @@ describe("PlexPersonView", () => {
await screen.findByText("plex.person.empty"); 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(
<PlexPersonView
name="Jane Doe"
onBack={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
onFilter={() => {}}
/>,
);
await screen.findByText("Egy film");
expect(plexPerson).toHaveBeenCalledWith("Jane Doe", "hu");
} finally {
setLanguage("en");
}
});
});
@@ -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. // staleTime showed the reorder undone, and the next drag persisted that.
await waitFor(() => await waitFor(() =>
expect( 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"]), ).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(screen.queryByText("Movie 3")).toBeNull());
await waitFor(() => await waitFor(() =>
expect( 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"]), ).toEqual(["2", "1"]),
); );
}); });
@@ -4,6 +4,7 @@
// beside them, a right-click, a long-press, Shift+F10) instead of a red X at rest; removing needs no // 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 // 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 "…". // 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 { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -346,3 +347,16 @@ describe("PlexPlaylistView · a failed watched toggle", () => {
expect(menuRows()).toContain("plex.markWatched"); 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");
}
});
});
+3 -2
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pin, PinOff } from "lucide-react"; import { Loader2, Pin, PinOff } from "lucide-react";
import { plexLang } from "../i18n";
import { api, type DiscoverCard, type PinKind } from "../lib/api"; import { api, type DiscoverCard, type PinKind } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
@@ -104,8 +105,8 @@ export function useComfortPin(open: boolean): ComfortPin {
if (open && !everOpen) setEverOpen(true); if (open && !everOpen) setEverOpen(true);
const shelf = useQuery({ const shelf = useQuery({
queryKey: qk.comfortPins(), queryKey: qk.comfortPins(plexLang()),
queryFn: () => api.comfortPins(), queryFn: () => api.comfortPins(plexLang()),
enabled: everOpen, enabled: everOpen,
// The shelf is hand-curated and short; re-asking on every menu open would be noise. // The shelf is hand-curated and short; re-asking on every menu open would be noise.
staleTime: 60_000, staleTime: 60_000,
@@ -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<typeof import("react-i18next")>();
const t = (key: string, o?: Record<string, unknown>) =>
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(<PlexPosterCard card={watchedEpisode} onOpen={vi.fn()} shelf />);
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(<PlexPosterCard card={watchedEpisode} onOpen={vi.fn()} wide />);
expect(document.querySelector("img")?.getAttribute("src")).toBe("/api/plex/image/e5");
unmount();
render(<PlexPosterCard card={{ ...watchedEpisode, show_id: null }} onOpen={vi.fn()} />);
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(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
expect(screen.getByText(shown)).toBeTruthy();
});
});
+29 -10
View File
@@ -5,6 +5,7 @@ import { type ReactNode, useRef } from "react";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel"; import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { seasonTitle } from "../lib/plexDetail";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { endsTextSelection, movedPastSlop } from "../lib/textSelection"; import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions"; import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
@@ -288,7 +289,12 @@ export function PlexPosterCard({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const preview = useMediaPreview(); 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; const ep = card.type === "show" ? (next ?? null) : null;
// movies/episodes play; a show opens its drill-down — unless it plays on (`next`) // movies/episodes play; a show opens its drill-down — unless it plays on (`next`)
const isPlayable = card.type !== "show" || !!ep; const isPlayable = card.type !== "show" || !!ep;
@@ -320,7 +326,7 @@ export function PlexPosterCard({
dense dense
rootProps={preview.rootProps({ rootProps={preview.rootProps({
...menuProps?.(card), ...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); // content-visibility keeps off-screen posters cheap to render (smoother long-list scroll);
// a carousel turns it off (index.css `.media-rail`). // a carousel turns it off (index.css `.media-rail`).
@@ -328,8 +334,12 @@ export function PlexPosterCard({
picture={ picture={
<PlexPicture <PlexPicture
aspect={landscape ? "wide" : "poster"} aspect={landscape ? "wide" : "poster"}
thumb={(wide && card.type !== "episode" && card.art) || card.thumb} thumb={
label={card.title} asShow
? `/api/plex/image/${encodeURIComponent(card.show_id ?? "")}`
: (wide && card.type !== "episode" && card.art) || card.thumb
}
label={name}
onOpen={onOpen} onOpen={onOpen}
onRestart={onRestart} onRestart={onRestart}
plays={isPlayable} plays={isPlayable}
@@ -362,11 +372,19 @@ export function PlexPosterCard({
} }
title={ title={
<CardTitle onClick={onOpen} shelf={shelf}> <CardTitle onClick={onOpen} shelf={shelf}>
{card.title} {asShow ? card.show_title : card.title}
</CardTitle> </CardTitle>
} }
meta={ meta={
epCode ? ( asShow ? (
<div className="truncate text-xs leading-6 text-muted">
{t("plex.episodeLine", {
season: card.season_number ?? "?",
episode: card.episode_number ?? "?",
title: card.title,
})}
</div>
) : epCode ? (
<div className="truncate text-xs leading-6 text-muted">{t("plex.nextUp", epCode)}</div> <div className="truncate text-xs leading-6 text-muted">{t("plex.nextUp", epCode)}</div>
) : ( ) : (
(!!card.year || card.rating != null) && ( (!!card.year || card.rating != null) && (
@@ -383,7 +401,7 @@ export function PlexPosterCard({
/> />
<MediaPreview <MediaPreview
preview={preview} preview={preview}
title={card.title} title={name}
facts={ facts={
<span className="flex flex-wrap items-center gap-x-1.5"> <span className="flex flex-wrap items-center gap-x-1.5">
{[card.year, corner].filter(Boolean).join(" · ")} {[card.year, corner].filter(Boolean).join(" · ")}
@@ -443,6 +461,7 @@ export function SeasonCard({
navIndex?: number; navIndex?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const title = seasonTitle(se, t);
const watched = se.status === "watched"; const watched = se.status === "watched";
const m = more(se, menuButtonProps, menuOpen); const m = more(se, menuButtonProps, menuOpen);
return ( return (
@@ -450,14 +469,14 @@ export function SeasonCard({
dense dense
rootProps={{ rootProps={{
...menuProps?.(se), ...menuProps?.(se),
...(navIndex !== undefined && navItemAttrs(navIndex, se.title)), ...(navIndex !== undefined && navItemAttrs(navIndex, title)),
}} }}
picture={ picture={
// A season opens its episode list: no Play on hover, since the click does not play. // A season opens its episode list: no Play on hover, since the click does not play.
<PlexPicture <PlexPicture
aspect="poster" aspect="poster"
thumb={se.thumb} thumb={se.thumb}
label={se.title} label={title}
onOpen={onOpen} onOpen={onOpen}
plays={false} plays={false}
bar={ bar={
@@ -481,7 +500,7 @@ export function SeasonCard({
</CornerBadge> </CornerBadge>
</PlexPicture> </PlexPicture>
} }
title={<CardTitle onClick={onOpen}>{se.title}</CardTitle>} title={<CardTitle onClick={onOpen}>{title}</CardTitle>}
more={m} more={m}
moreLabel={t("card.menu.more")} moreLabel={t("card.menu.more")}
/> />
+17 -7
View File
@@ -34,7 +34,13 @@ import {
Filterable, Filterable,
WatchedToggle, WatchedToggle,
} from "./plexDetailUi"; } 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 { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu"; import { PlexMenu } from "./PlexMenu";
import { usePlexCardActions } from "./usePlexCardActions"; 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 // 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). // mean a second closed popover to keep shut (see ui/ContextMenu).
const menu = useContextMenu<PlexCard>(); const menu = useContextMenu<PlexCard>();
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 // 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. // out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control.
const { artBg, crossfade } = useDetailPrefs(); const { artBg, crossfade } = useDetailPrefs();
@@ -153,7 +161,7 @@ export function PlexPersonView({
const toggle = (card: PlexCard) => const toggle = (card: PlexCard) =>
toggleWatchState(card, t, { toggleWatchState(card, t, {
optimistic: (next) => optimistic: (next) =>
qc.setQueryData<PlexPersonDetail>(qk.plexPerson(name), (old) => qc.setQueryData<PlexPersonDetail>(personKey, (old) =>
old old
? { ? {
...old, ...old,
@@ -402,7 +410,7 @@ export function PlexShowView({
setAddTarget({ setAddTarget({
kind: "group", kind: "group",
ratingKeys: se.episodes.map((e) => e.id), ratingKeys: se.episodes.map((e) => e.id),
title: `${show.title} — ${se.title}`, title: `${show.title} — ${seasonTitle(se, t)}`,
}) })
: undefined; : undefined;
// A season's one action list (E28 C1): the rows its card shows on hover, plus copy. // 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({
<Button variant="link-muted" selectable onClick={onBack} className="text-sm"> <Button variant="link-muted" selectable onClick={onBack} className="text-sm">
{d.show.title} {d.show.title}
</Button> </Button>
<PageHeading className="text-2xl font-semibold leading-tight">{se.title}</PageHeading> <PageHeading className="text-2xl font-semibold leading-tight">
{seasonTitle(se, t)}
</PageHeading>
<p className="text-sm text-muted mt-1"> <p className="text-sm text-muted mt-1">
{t("plex.series.episodeCount", { count: se.episode_count })} {t("plex.series.episodeCount", { count: se.episode_count })}
</p> </p>
@@ -785,7 +795,7 @@ export function PlexSeasonView({
setAddTarget({ setAddTarget({
kind: "group", kind: "group",
ratingKeys: seasonKeys, ratingKeys: seasonKeys,
title: `${d.show.title} — ${se.title}`, title: `${d.show.title} — ${seasonTitle(se, t)}`,
}) })
} }
icon={ListPlus} icon={ListPlus}
@@ -75,7 +75,7 @@ describe("usePlexPlaylistEdits · the reorder", () => {
act(() => hook.result.current.commit([movie("2"), movie("1")])); act(() => hook.result.current.commit([movie("2"), movie("1")]));
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1"])); await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1"]));
await waitFor(() => 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", "2",
"1", "1",
]), ]),
@@ -100,7 +100,11 @@ describe("usePlexPlaylistEdits · the reorder", () => {
// the same `q.data`, and could never correct a stale rollback. // the same `q.data`, and could never correct a stale rollback.
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never); detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never);
act(() => 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. // 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 // (react-query batches its notification, so without this wait the refresh landed only after the
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { plexLang } from "../i18n";
import { api, type PlexCard } from "../lib/api"; import { api, type PlexCard } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
@@ -15,9 +16,13 @@ export function usePlexPlaylistEdits(playlistId: number) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [busy, setBusy] = useState(false); 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({ const q = useQuery({
queryKey: qk.plexPlaylist(playlistId), queryKey: key,
queryFn: () => api.plexPlaylist(playlistId), queryFn: () => api.plexPlaylist(playlistId, lang),
}); });
// Local optimistic copy so drag/remove are instant; re-synced whenever the query returns. // Local optimistic copy so drag/remove are instant; re-synced whenever the query returns.
const [items, setItems] = useState<PlexCard[]>([]); const [items, setItems] = useState<PlexCard[]>([]);
@@ -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 // 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. // sibling `restoreFromCache` reads it live for the same reason.
const putBack = () => { const putBack = () => {
const cur = qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId)); const cur = qc.getQueryData<{ items: PlexCard[] }>(key);
if (cur) setItems(cur.items); if (cur) setItems(cur.items);
invalidate(); invalidate();
}; };
@@ -63,9 +68,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
// next drag from there persisted it, silently undoing the one the server took. The // 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 // 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. // 6). One accepted write, one cache entry that matches it.
qc.setQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId), (old) => qc.setQueryData<{ items: PlexCard[] }>(key, (old) => (old ? { ...old, items: next } : old));
old ? { ...old, items: next } : old,
);
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) }); qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
// …and then REFETCH it, exactly as the sibling does. `next` is this render's membership, and // …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 // 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: () => onClick: () =>
void qc void qc
.fetchQuery({ .fetchQuery({
queryKey: qk.plexPlaylist(playlistId), queryKey: key,
queryFn: () => api.plexPlaylist(playlistId), queryFn: () => api.plexPlaylist(playlistId, lang),
staleTime: 0, staleTime: 0,
}) })
.then((now) => { .then((now) => {
+3
View File
@@ -124,6 +124,9 @@
"markNextWatched": "Mark S{{season}}·E{{episode}} watched", "markNextWatched": "Mark S{{season}}·E{{episode}} watched",
"markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched", "markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched",
"nextUp": "Next: S{{season}}·E{{episode}}", "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", "watchedFailed": "Couldn't update the watched state — please try again",
"seasons": "{{count}} seasons", "seasons": "{{count}} seasons",
"unified": { "unified": {
+3
View File
@@ -124,6 +124,9 @@
"markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl", "markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl",
"markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl", "markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl",
"nextUp": "Következik: S{{season}}·E{{episode}}", "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", "watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra",
"seasons": "{{count}} évad", "seasons": "{{count}} évad",
"unified": { "unified": {
+13
View File
@@ -50,3 +50,16 @@ describe("discoverApi query strings", () => {
expect(lastUrl()).not.toContain("exclude="); 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=");
});
});
+8 -4
View File
@@ -71,8 +71,9 @@ export interface DiscoverPayload {
} }
export const discoverApi = { export const discoverApi = {
discover: (budget: DiscoverBudget, seed: number): Promise<DiscoverPayload> => // `lang` "hu" serves a Plex card's Hungarian title (E30); en is the server default.
req(`/api/discover?budget=${budget}&seed=${seed}`), discover: (budget: DiscoverBudget, seed: number, lang?: string): Promise<DiscoverPayload> =>
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 // The "Mást kérek" reroll: a new hero alone, so pressing it does not re-fetch the rails under the
// user's cursor. // user's cursor.
// `exclude` is the rolling list of `<source>:<id>` keys already shown. Without it a reroll only // `exclude` is the rolling list of `<source>:<id>` keys already shown. Without it a reroll only
@@ -84,12 +85,15 @@ export const discoverApi = {
seed: number, seed: number,
exclude: string[] = [], exclude: string[] = [],
press = 0, press = 0,
lang?: string,
): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> => ): Promise<{ budget: DiscoverBudget; hero: DiscoverHero | null }> =>
req( req(
`/api/discover/pick?budget=${budget}&seed=${seed}&start=${press}` + `/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: ( addComfortPin: (
kind: PinKind, kind: PinKind,
ref: string, ref: string,
+6 -4
View File
@@ -495,12 +495,13 @@ export const plexApi = {
}, },
// One collection's hero + its members in the requested order (the Collection info page). The sort // 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. // vocabulary is the browse grid's, so the same Sort control means the same thing on both.
plexCollection: (rk: string, sort?: SortState): Promise<PlexCollectionDetail> => { plexCollection: (rk: string, sort?: SortState, lang?: string): Promise<PlexCollectionDetail> => {
const u = new URLSearchParams(); const u = new URLSearchParams();
if (sort) { if (sort) {
u.set("sort", sort.key); u.set("sort", sort.key);
u.set("sort_dir", sort.dir); u.set("sort_dir", sort.dir);
} }
if (lang === "hu") u.set("lang", "hu");
const qs = u.toString(); const qs = u.toString();
return req(`/api/plex/collections/${encodeURIComponent(rk)}${qs ? `?${qs}` : ""}`); return req(`/api/plex/collections/${encodeURIComponent(rk)}${qs ? `?${qs}` : ""}`);
}, },
@@ -552,7 +553,8 @@ export const plexApi = {
const qs = p.toString(); const qs = p.toString();
return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`); return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`);
}, },
plexPlaylist: (id: number): Promise<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`), plexPlaylist: (id: number, lang?: string): Promise<PlexPlaylistDetail> =>
req(`/api/plex/playlists/${id}${lang === "hu" ? "?lang=hu" : ""}`),
plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> => plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
req(`/api/plex/playlists`, { req(`/api/plex/playlists`, {
method: "POST", method: "POST",
@@ -636,8 +638,8 @@ export const plexApi = {
`/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`, `/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). // One cast/crew member's filmography: cached headshot + movies and shows (cast OR directors).
plexPerson: (name: string): Promise<PlexPersonDetail> => plexPerson: (name: string, lang?: string): Promise<PlexPersonDetail> =>
req(`/api/plex/person/${encodeURIComponent(name)}`), req(`/api/plex/person/${encodeURIComponent(name)}${lang === "hu" ? "?lang=hu" : ""}`),
plexSession: ( plexSession: (
id: string, id: string,
start = 0, start = 0,
+42
View File
@@ -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=");
});
});
+17
View File
@@ -1,5 +1,6 @@
import { useLayoutEffect } from "react"; import { useLayoutEffect } from "react";
import { usePref, useSavePref } from "./usePref"; import { usePref, useSavePref } from "./usePref";
import type { TFunction } from "i18next";
import type { PlexCard } from "./api"; import type { PlexCard } from "./api";
// The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display // 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 `S${ep.season_number} · E${ep.episode_number}`;
return ep.title; 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");
}
+6 -3
View File
@@ -125,7 +125,7 @@ export const qk = {
// finish it, and it was still the answer on screen with a zeroed progress bar. // finish it, and it was still the answer on screen with a zeroed progress bar.
discover: (...rest: unknown[]) => ["discover", ...rest], discover: (...rest: unknown[]) => ["discover", ...rest],
discoverPick: (...rest: unknown[]) => ["discover", "pick", ...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, plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const, plexWatchLink: () => ["plex-watch-link"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest], 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. // that steers the background extraction, not part of the track's identity.
plexSubtitle: (id: string, ord: number) => ["plex-subtitle", id, ord] as const, plexSubtitle: (id: string, ord: number) => ["plex-subtitle", id, ord] as const,
plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest], 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], plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest],
// facetKey is a filters-derived OBJECT segment (not a scalar), so this key is heterogeneous too. // facetKey is a filters-derived OBJECT segment (not a scalar), so this key is heterogeneous too.
plexFacets: (...rest: unknown[]) => ["plex-facets", ...rest], plexFacets: (...rest: unknown[]) => ["plex-facets", ...rest],
plexPeopleCards: (scope: string, actors: string[]) => plexPeopleCards: (scope: string, actors: string[]) =>
["plex-people-cards", scope, actors] as const, ["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,
}; };