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,
exclude: set[str] | None = None,
start: int = 0,
lang: str,
) -> dict | None:
"""`{"tier": ..., "card": ...}`, or None when nothing at all fits.
@@ -70,8 +71,8 @@ def pick(
skip = exclude or set()
def _resume() -> dict | None:
for card in rails.continue_watching(db, user) if resume is None else resume:
playable = rails.playable(db, user.id, card)
for card in rails.continue_watching(db, user, lang=lang) if resume is None else resume:
playable = rails.playable(db, user.id, card, lang=lang)
# Excluded on the card the USER saw (the show), not only on what it resolves to, so a
# rerolled-past show does not come straight back as its own next episode.
if cards.key(card) in skip or (playable is not None and cards.key(playable) in skip):
@@ -82,7 +83,7 @@ def pick(
def _comfort() -> dict | None:
if shelf is None:
own = pins.shelf(db, user, include_plex=rails.plex_available(db))
own = pins.shelf(db, user, include_plex=rails.plex_available(db), lang=lang)
else:
own = shelf
for card in pins.playables(own):
@@ -93,7 +94,7 @@ def pick(
def _surprise() -> dict | None:
# A wider draw than the one card we need: the shuffle is seeded, so without the headroom an
# excluded pick would leave the tier empty rather than move past it.
for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW):
for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW, lang=lang):
if cards.key(card) not in skip:
return card
return None
+6 -6
View File
@@ -170,7 +170,7 @@ class _Batch:
return b
def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict | None:
def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch, lang: str) -> dict | None:
"""One pin as a unified card with its resolved `next`. None when the pinned row has gone (the FK
cascade removes the pin itself, so this is only the window between a delete and a refetch)."""
card: dict | None = None
@@ -196,7 +196,7 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict
if it is None:
return None
st = rollup.item_state_map(db, user.id, [it.id]).get(it.id)
card = cards.from_plex(leaf_card(it, st))
card = cards.from_plex(leaf_card(it, st, lang=lang))
nxt = dict(card) # a movie or a single episode plays itself
elif pin.plex_show_id is not None:
@@ -204,8 +204,8 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict
if sh is None:
return None
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
card = cards.from_plex(show_card(sh, status))
ep = queries.next_episode(db, user.id, sh.id)
card = cards.from_plex(show_card(sh, status, lang=lang))
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
nxt = cards.from_plex(ep) if ep else None
elif pin.playlist_id is not None:
@@ -229,7 +229,7 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict
_PLEX_COLUMNS = ("plex_item_id", "plex_show_id")
def shelf(db: Session, user: User, *, include_plex: bool = True) -> list[dict]:
def shelf(db: Session, user: User, *, include_plex: bool = True, lang: str) -> list[dict]:
"""The whole shelf, in the user's own order.
`include_plex=False` is the Home surface with the deployment's Plex toggle off (the owner's call,
@@ -243,7 +243,7 @@ def shelf(db: Session, user: User, *, include_plex: bool = True) -> list[dict]:
pins = [p for p in pins if all(getattr(p, c) is None for c in _PLEX_COLUMNS)]
batch = _Batch.load(db, user, pins)
for pin in pins:
card = _shelf_card(db, user, pin, batch)
card = _shelf_card(db, user, pin, batch, lang)
if card is not None:
out.append(card)
return out
+16 -8
View File
@@ -40,7 +40,7 @@ from app.models import (
VideoState,
)
from app.plex import rollup
from app.plex.cards import leaf_card, movie_card, show_card
from app.plex.cards import display_title, leaf_card, movie_card, show_card
from app.plex.query import apply_show_state_filter, content_libs
# How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike
@@ -221,7 +221,9 @@ def _unseen_movie_conds(uid: int):
return ~seen
def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tuple[object, dict]]:
def plex_in_progress(
db: Session, uid: int, limit: int = RAIL_LIMIT, *, lang: str
) -> list[tuple[object, dict]]:
"""In-progress movies + partially-watched shows as (progressed_at, card).
Mirrors `routes/plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a
@@ -249,7 +251,7 @@ def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tup
.limit(limit)
)
for it, st in db.execute(mq).all():
out.append((st.progress_updated_at, movie_card(it, st)))
out.append((st.progress_updated_at, movie_card(it, st, lang=lang)))
if show_libs:
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
@@ -263,12 +265,14 @@ def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tup
for sh, resumed_at in db.execute(
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
).all():
out.append((resumed_at, show_card(sh, "in_progress")))
out.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
return out
def plex_surprise(db: Session, uid: int, budget: str, seed: int, limit: int) -> list[dict]:
def plex_surprise(
db: Session, uid: int, budget: str, seed: int, limit: int, *, lang: str
) -> list[dict]:
"""Unseen playable movies inside the budget, in seeded-shuffle order.
Movies only. A show is not a thing you can start playing, and resolving every candidate show to
@@ -300,14 +304,14 @@ def plex_surprise(db: Session, uid: int, budget: str, seed: int, limit: int) ->
q = q.where(PlexItem.duration_s <= hi)
items = db.execute(q).scalars().all()
states = rollup.item_state_map(db, uid, [it.id for it in items])
return [movie_card(it, states.get(it.id)) for it in items]
return [movie_card(it, states.get(it.id), lang=lang) for it in items]
# Watch states that take an episode out of the running for "what would play next".
_DONE = ("watched", "hidden")
def next_episode(db: Session, uid: int, show_id: int) -> dict | None:
def next_episode(db: Session, uid: int, show_id: int, *, lang: str) -> dict | None:
"""What "put that show on" means: the episode in progress, else the first unwatched one, else the
first — over the same `(season, episode)` broadcast ordering `detail.py::_episode_nav` uses.
@@ -357,5 +361,9 @@ def next_episode(db: Session, uid: int, show_id: int) -> dict | None:
ep, st = pick
show = db.get(PlexShow, show_id)
return leaf_card(
ep, st, show_title=show.title if show else None, show_rk=show.rating_key if show else None
ep,
st,
show_title=display_title(show, lang) if show else None,
show_rk=show.rating_key if show else None,
lang=lang,
)
+14 -7
View File
@@ -22,7 +22,7 @@ def plex_available(db: Session) -> bool:
return sysconfig.get_bool(db, "plex_enabled")
def playable(db: Session, uid: int, card: dict) -> dict | None:
def playable(db: Session, uid: int, card: dict, *, lang: str) -> dict | None:
"""The thing that would actually START from this card — itself for anything with a runtime, and
for a Plex SHOW the episode that is next (in progress, else the first unwatched, else the first).
@@ -40,11 +40,11 @@ def playable(db: Session, uid: int, card: dict) -> dict | None:
sh = by_rating_key(db, PlexShow, card["id"])
if sh is None:
return None
ep = queries.next_episode(db, uid, sh.id)
ep = queries.next_episode(db, uid, sh.id, lang=lang)
return cards.from_plex(ep) if ep else None
def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[dict]:
def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT, *, lang: str) -> list[dict]:
"""One resume rail across both sources, most recently progressed first.
A Plex SHOW enters as a show CARD — that is what the Plex rail shows, and it is the artwork the
@@ -57,17 +57,22 @@ def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[
(ts, cards.from_video(r)) for ts, r in queries.yt_in_progress(db, user, limit)
]
if plex_available(db):
rows += [(ts, cards.from_plex(c)) for ts, c in queries.plex_in_progress(db, user.id, limit)]
rows += [
(ts, cards.from_plex(c))
for ts, c in queries.plex_in_progress(db, user.id, limit, lang=lang)
]
rows.sort(key=lambda r: r[0] or NO_TS, reverse=True)
out = [card for _, card in rows[:limit]]
# Resolve AFTER the slice: only the rows that survive it are worth a query.
for card in out:
if card["kind"] == "show":
card["next"] = playable(db, user.id, card)
card["next"] = playable(db, user.id, card, lang=lang)
return out
def surprise(db: Session, user: User, budget: str, seed: int, limit: int = 3) -> list[dict]:
def surprise(
db: Session, user: User, budget: str, seed: int, limit: int = 3, *, lang: str
) -> list[dict]:
"""The hero's draw pool: unseen things that fit the budget, from both sources under ONE seed.
Interleaved rather than concatenated — the same alternation `routes/plex/discover.py` uses for its
@@ -83,7 +88,9 @@ def surprise(db: Session, user: User, budget: str, seed: int, limit: int = 3) ->
(cards.from_video, queries.yt_surprise(db, user, budget, seed, limit)),
(
cards.from_plex,
queries.plex_surprise(db, user.id, budget, seed, limit) if plex_available(db) else [],
queries.plex_surprise(db, user.id, budget, seed, limit, lang=lang)
if plex_available(db)
else [],
),
]
if seed % 2:
+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
# stays a few hundred bytes each rather than carrying full synopses. (These non-grid card paths serve
# the EN summary/rating; the main grid coalesces the HU twin in browse.unified_library.)
# stays a few hundred bytes each rather than carrying full synopses.
def _card_summary(summary: str | None) -> str | None:
return (summary or "")[:300] or None
def movie_card(it: PlexItem, st: PlexState | None) -> dict:
def norm_lang(lang: str | None) -> str:
"""The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN."""
return "hu" if lang == "hu" else "en"
# Every card speaks the reader's language (E30): `lang="hu"` serves the HU overlay twin's title and
# summary, falling back to EN where a row has none — the same rule as browse.unified_library's
# `title_col`/`summary_col`. These rails once served English to a Hungarian reader.
def display_title(row: PlexItem | PlexShow, lang: str) -> str:
return (row.title_hu or row.title) if lang == "hu" else row.title
def display_summary(row: PlexItem | PlexShow, lang: str) -> str | None:
return (row.summary_hu or row.summary) if lang == "hu" else row.summary
def movie_card(it: PlexItem, st: PlexState | None, *, lang: str) -> dict:
return {
"id": it.rating_key,
"type": "movie",
"title": it.title,
"title": display_title(it, lang),
"year": it.year,
"rating": it.rating,
"summary": _card_summary(it.summary),
"summary": _card_summary(display_summary(it, lang)),
"duration_seconds": it.duration_s,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": _art_url(it.rating_key, it.art_key),
@@ -39,14 +54,14 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict:
}
def show_card(sh: PlexShow, status: str = "new") -> dict:
def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
return {
"id": sh.rating_key,
"type": "show",
"title": sh.title,
"title": display_title(sh, lang),
"year": sh.year,
"rating": sh.rating,
"summary": _card_summary(sh.summary),
"summary": _card_summary(display_summary(sh, lang)),
"thumb": f"/api/plex/image/{sh.rating_key}",
"art": _art_url(sh.rating_key, sh.art_key),
"season_count": sh.child_count,
@@ -55,12 +70,12 @@ def show_card(sh: PlexShow, status: str = "new") -> dict:
}
def episode_card(e: PlexItem, st: PlexState | None) -> dict:
def episode_card(e: PlexItem, st: PlexState | None, *, lang: str) -> dict:
return {
"id": e.rating_key,
"type": "episode",
"title": e.title,
"summary": e.summary,
"title": display_title(e, lang),
"summary": display_summary(e, lang),
"season_number": e.season_number,
"episode_number": e.episode_number,
"duration_seconds": e.duration_s,
@@ -77,12 +92,15 @@ def leaf_card(
st: PlexState | None,
show_title: str | None = None,
show_rk: str | None = None,
*,
lang: str,
) -> dict:
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.
`show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season."""
`show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season.
`show_title` is the caller's, already in `lang` (see `display_title`)."""
if it.kind == "episode":
card = episode_card(it, st)
card = episode_card(it, st, lang=lang)
card["show_title"] = show_title
card["show_id"] = show_rk
return card
return movie_card(it, st)
return movie_card(it, st, lang=lang)
+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.budget import Budget, DEFAULT_BUDGET
from app.models import ComfortPin, User
from app.plex.cards import norm_lang
from app.routes._common import owned_or_404, not_found
router = APIRouter(prefix="/api/discover", tags=["discover"])
@@ -45,6 +46,7 @@ class ReorderIn(BaseModel):
def get_discover(
budget: Budget = DEFAULT_BUDGET,
seed: int = 0,
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@@ -52,13 +54,14 @@ def get_discover(
`resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from
are not queried twice for a single page load."""
resume = rails.continue_watching(db, user)
lang = norm_lang(lang)
resume = rails.continue_watching(db, user, lang=lang)
# Plex off → the shelf rail (and so the hero's comfort tier) goes YouTube-only like the rails
# beside it; the pins are kept and `GET /pins` still lists them (`pins.shelf`).
shelf = pins.shelf(db, user, include_plex=rails.plex_available(db))
shelf = pins.shelf(db, user, include_plex=rails.plex_available(db), lang=lang)
return {
"budget": budget,
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf),
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf, lang=lang),
"continue_watching": resume,
"comfort": shelf,
}
@@ -80,6 +83,7 @@ def get_pick(
seed: int = 0,
exclude: str = "",
start: int = 0,
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@@ -99,18 +103,25 @@ def get_pick(
return {
"budget": budget,
"hero": hero_mod.pick(
db, user, budget, seed, exclude=set(list(skip)[:MAX_EXCLUDE]), start=start
db,
user,
budget,
seed,
exclude=set(list(skip)[:MAX_EXCLUDE]),
start=start,
lang=norm_lang(lang),
),
}
@router.get("/pins")
def list_pins(
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""The shelf on its own — for the shelf editor, which needs it without the rest of the page."""
return {"items": pins.shelf(db, user)}
return {"items": pins.shelf(db, user, lang=norm_lang(lang))}
@router.post("/pins")
+1 -5
View File
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
from app import logs
from app.models import PlexItem
from app.plex.cards import norm_lang as _norm_lang # noqa: F401 — the package's historic name
from app.plex.lookups import by_rating_key
from app.routes._common import not_found
@@ -15,8 +16,3 @@ def item_or_404(db: Session, rating_key: str) -> PlexItem:
if it is None:
raise not_found("plex_item")
return it
def _norm_lang(lang: str | None) -> str:
"""The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN."""
return "hu" if lang == "hu" else "en"
+18 -11
View File
@@ -10,6 +10,7 @@ from sqlalchemy import (
func,
literal,
null,
or_,
select,
)
from sqlalchemy.orm import Session, aliased
@@ -25,7 +26,7 @@ from app.models import (
User,
)
from app.plex import rollup
from app.plex.cards import episode_card
from app.plex.cards import display_title, episode_card
from app.plex.query import (
FIELD_COLUMNS,
PLAYBACK_CLASSES,
@@ -281,26 +282,32 @@ def unified_library(
episodes = []
if tsq is not None and want_shows and field in ("all", "episode"):
ep_st = aliased(PlexState)
ep_vec = (
func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, ""))
if field == "episode"
else PlexItem.search_vector
)
# In HU an episode is found by its Hungarian title too (E30: the overlay now fills it) — and
# still by its English one, like `vector_match` does for the grid.
if field == "episode":
ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, ""))
ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, ""))
else:
ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu
hit = ep_vec.op("@@")(tsq)
if lang == "hu":
hit = or_(ep_vec_hu.op("@@")(tsq), hit)
ep_vec = ep_vec_hu
eps = db.execute(
select(PlexItem, ep_st, PlexShow.title.label("show_title"))
select(PlexItem, ep_st, PlexShow)
.outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id))
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
.where(
PlexItem.kind == "episode",
PlexItem.library_id.in_(show_lib_ids),
ep_vec.op("@@")(tsq),
hit,
)
.order_by(func.ts_rank(ep_vec, tsq).desc())
.limit(24)
).all()
for e, s, show_title in eps:
card = episode_card(e, s)
card["show_title"] = show_title
for e, s, sh in eps:
card = episode_card(e, s, lang=lang)
card["show_title"] = display_title(sh, lang) if sh is not None else None
episodes.append(card)
return {
+7 -5
View File
@@ -22,7 +22,7 @@ from app.models import (
)
from app.plex import rollup
from app.plex import sync as plex_sync
from app.plex.cards import movie_card, show_card
from app.plex.cards import display_title, movie_card, norm_lang, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.plex.query import content_libs, runtime_by_key, top_art_keys
@@ -229,6 +229,7 @@ def collection_detail(
rating_key: str,
sort: str = DEFAULT_MEMBER_SORT,
sort_dir: str = "asc",
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@@ -237,6 +238,7 @@ def collection_detail(
control means the same thing on both surfaces; the default is release year ascending, which is
what this page did before it had a control. Read-only here; edits go through the admin mutation
endpoints below."""
lang = norm_lang(lang)
col = by_rating_key(db, PlexCollection, rating_key)
if col is None:
raise not_found("collection")
@@ -285,7 +287,7 @@ def collection_detail(
Member(
kind="movie",
obj=m,
title=(m.title or "").lower(),
title=display_title(m, lang).lower(),
year=m.year,
rating=m.rating,
added=m.added_at,
@@ -298,7 +300,7 @@ def collection_detail(
Member(
kind="show",
obj=sh,
title=(sh.title or "").lower(),
title=display_title(sh, lang).lower(),
year=sh.year,
rating=sh.rating,
added=sh.added_at,
@@ -310,9 +312,9 @@ def collection_detail(
]
members = sort_members(members, sort, sort_dir)
items = [
movie_card(m.obj, mstate.get(m.obj.id))
movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
if m.kind == "movie"
else show_card(m.obj, sstate.get(m.obj.id, "new"))
else show_card(m.obj, sstate.get(m.obj.id, "new"), lang=lang)
for m in members
]
years = [m.year for m in members if m.year is not None]
+11 -8
View File
@@ -107,7 +107,8 @@ def show_detail(
db: Session = Depends(get_db),
) -> dict:
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
hu = _norm_lang(lang) == "hu"
lang = _norm_lang(lang)
hu = lang == "hu"
sh = by_rating_key(db, PlexShow, rating_key)
if sh is None:
raise not_found("show")
@@ -144,7 +145,7 @@ def show_detail(
by_season: dict[int | None, list] = {}
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
for e in eps:
card = episode_card(e, states.get(e.id))
card = episode_card(e, states.get(e.id), lang=lang)
by_season.setdefault(e.season_id, []).append(card)
all_cards.append(card)
show_roll = rollup.rollup(all_cards)
@@ -156,7 +157,7 @@ def show_detail(
related: list[dict] = []
try:
meta, related_raw = _show_enrichment(db, sh.rating_key)
related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id)
related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id, lang=lang)
if sh.people_enriched_at is None and meta:
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta))
plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views
@@ -234,7 +235,9 @@ def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
}
def _relatedshow_cards(db: Session, user_id: int, related: list[dict], exclude: int) -> list[dict]:
def _relatedshow_cards(
db: Session, user_id: int, related: list[dict], exclude: int, lang: str
) -> list[dict]:
"""Map Plex 'related' metadata to show cards for the shows we actually mirror (so they're openable),
excluding the current show. Order preserved; capped."""
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")]
@@ -254,13 +257,13 @@ def _relatedshow_cards(db: Session, user_id: int, related: list[dict], exclude:
if sh is None or rk in seen:
continue
seen.add(rk)
out.append(show_card(sh, statuses.get(sh.id, "new")))
out.append(show_card(sh, statuses.get(sh.id, "new"), lang=lang))
if len(out) >= 20:
break
return out
def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]:
def _collection_strips(db: Session, it: PlexItem, user_id: int, lang: str) -> list[dict]:
"""The collections this movie belongs to, each with its member titles (playable cards) — the
'Avatar Collection' strip on the info page. Pure local lookup (no Plex call)."""
keys = it.collection_keys or []
@@ -305,7 +308,7 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]:
"id": rk,
"title": col.title,
"source": _collection_source(col),
"items": [movie_card(m, states.get(m.id)) for m in members],
"items": [movie_card(m, states.get(m.id), lang=lang) for m in members],
}
)
return out
@@ -512,7 +515,7 @@ def item_detail(
"episode_number": it.episode_number,
"prev_id": prev_id,
"next_id": next_id,
"collections": _collection_strips(db, it, user.id),
"collections": _collection_strips(db, it, user.id, lang),
# TMDB enrichment (E5 S2), served from the row — movies only (episodes aren't enriched).
"tmdb": plex_tmdb.serve_block(db, it) if it.kind == "movie" else None,
}
+22 -17
View File
@@ -22,7 +22,7 @@ from app.auth import current_user
from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User
from app.plex import rollup
from app.plex.cards import leaf_card, movie_card, show_card
from app.plex.cards import display_title, leaf_card, movie_card, show_card
from app.plex.query import (
LibraryFilters,
apply_meta_filters,
@@ -61,14 +61,14 @@ def _movie_meta(q, p: dict, lang: str, f: LibraryFilters):
return q
def _movie_cards(db: Session, uid: int, items: list[PlexItem]) -> list[dict]:
def _movie_cards(db: Session, uid: int, items: list[PlexItem], lang: str) -> list[dict]:
sts = rollup.item_state_map(db, uid, [it.id for it in items])
return [movie_card(it, sts.get(it.id)) for it in items]
return [movie_card(it, sts.get(it.id), lang=lang) for it in items]
def _show_cards(db: Session, uid: int, shows: list[PlexShow]) -> list[dict]:
def _show_cards(db: Session, uid: int, shows: list[PlexShow], lang: str) -> list[dict]:
statuses = rollup.state_map(db, uid, [s.id for s in shows])
return [show_card(s, statuses.get(s.id, "new")) for s in shows]
return [show_card(s, statuses.get(s.id, "new"), lang=lang) for s in shows]
def _recently_added(
@@ -123,8 +123,8 @@ def _recently_added(
# share one), so the merge doesn't depend on object identity staying alive.
top_movies = [o for _, kind, o in top if kind == "movie"]
top_shows = [o for _, kind, o in top if kind == "show"]
mcards = {m.rating_key: c for m, c in zip(top_movies, _movie_cards(db, uid, top_movies))}
scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows))}
mcards = {m.rating_key: c for m, c in zip(top_movies, _movie_cards(db, uid, top_movies, lang))}
scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang))}
return [mcards[o.rating_key] if kind == "movie" else scards[o.rating_key] for _, kind, o in top]
@@ -156,10 +156,10 @@ def _recently_watched(
.order_by(PlexState.watched_at.desc().nullslast())
.limit(RAIL_LIMIT)
).all():
rows.append((st.watched_at, leaf_card(it, st)))
rows.append((st.watched_at, leaf_card(it, st, lang=lang)))
if show_ids:
eq = (
select(PlexItem, PlexState, PlexShow.title, PlexShow.rating_key)
select(PlexItem, PlexState, PlexShow)
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
.join(PlexShow, PlexShow.id == PlexItem.show_id)
.where(
@@ -170,8 +170,11 @@ def _recently_watched(
.order_by(PlexState.watched_at.desc().nullslast())
.limit(RAIL_LIMIT)
)
for ep, st, sh_title, sh_rk in db.execute(eq).all():
rows.append((st.watched_at, leaf_card(ep, st, show_title=sh_title, show_rk=sh_rk)))
for ep, st, sh in db.execute(eq).all():
card = leaf_card(
ep, st, show_title=display_title(sh, lang), show_rk=sh.rating_key, lang=lang
)
rows.append((st.watched_at, card))
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
return [card for _, card in rows[:RAIL_LIMIT]]
@@ -214,7 +217,7 @@ def _continue_watching(
.limit(RAIL_LIMIT)
).all()
for it, st in mrows:
rows.append((st.progress_updated_at, movie_card(it, st)))
rows.append((st.progress_updated_at, movie_card(it, st, lang=lang)))
if show_ids:
# Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus
@@ -234,7 +237,7 @@ def _continue_watching(
.limit(RAIL_LIMIT)
).all()
for sh, resumed_at in srows:
rows.append((resumed_at, show_card(sh, "in_progress")))
rows.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
return [card for _, card in rows[:RAIL_LIMIT]]
@@ -346,9 +349,11 @@ def _because_you_watched(
ordered_shows = [shows[rk] for rk in rks if rk in shows]
# rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
mcards = {
m.rating_key: c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies))
m.rating_key: c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies, lang))
}
scards = {
s.rating_key: c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang))
}
scards = {s.rating_key: c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows))}
items: list[dict] = []
for rk in rks:
if rk in mcards:
@@ -456,8 +461,8 @@ def surprise(
.scalars()
.all()
)
mcards = _movie_cards(db, user.id, movies)
scards = _show_cards(db, user.id, shows)
mcards = _movie_cards(db, user.id, movies, lang)
scards = _show_cards(db, user.id, shows, lang)
# Interleave movie/show cards, then trim to `limit`.
items: list[dict] = []
for i in range(max(len(mcards), len(scards))):
+9 -6
View File
@@ -18,7 +18,7 @@ from app.models import (
)
from app.plex import rollup
from app.plex import tmdb as plex_tmdb
from app.plex.cards import movie_card, show_card
from app.plex.cards import movie_card, norm_lang, show_card
from app.plex.query import content_libs, split_csv
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -31,6 +31,7 @@ def _resolve_known_for(
uid: int,
movie_lib_ids: list[int],
show_lib_ids: list[int],
lang: str,
) -> list[dict]:
"""The person's TMDB "Known for" credits (top by popularity) that are IN our library, as cards, in
popularity order. Matched by the canonical guid `tmdb:<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.
if k.get("media_type") == "tv":
if g in shows:
out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new")))
out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new"), lang=lang))
elif g in movies:
out.append(movie_card(movies[g], m_states.get(movies[g].id)))
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
return out
@@ -159,6 +160,7 @@ def _enrich_person_bg(person_id: int) -> None:
def person_detail(
name: str,
background_tasks: BackgroundTasks,
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@@ -173,6 +175,7 @@ def person_detail(
The fetch runs OFF the request path (a background task); the first open serves whatever's in the row
(bio possibly still null) and the bio lands from the row by the next open.
"""
lang = norm_lang(lang)
person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
# Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path.
# Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded)
@@ -206,7 +209,7 @@ def person_detail(
.all()
)
states = rollup.item_state_map(db, user.id, [it.id for it in items])
movies = [movie_card(it, states.get(it.id)) for it in items]
movies = [movie_card(it, states.get(it.id), lang=lang) for it in items]
shows: list[dict] = []
if show_lib_ids:
@@ -227,10 +230,10 @@ def person_detail(
.all()
)
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
shows = [show_card(sh, statuses.get(sh.id, "new")) for sh in show_rows]
shows = [show_card(sh, statuses.get(sh.id, "new"), lang=lang) for sh in show_rows]
known_for = _resolve_known_for(
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
)
return {
"name": name,
+8 -3
View File
@@ -19,7 +19,7 @@ from app.models import (
PlexState,
User,
)
from app.plex.cards import leaf_card
from app.plex.cards import display_title, leaf_card, norm_lang
from app.plex.lookups import by_rating_key
from app.routes._common import owned_or_404, not_found
from app.refusal import refused
@@ -137,8 +137,12 @@ def create_playlist(
@router.get("/playlists/{pid}")
def playlist_detail(
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
pid: int,
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
lang = norm_lang(lang)
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
rows = db.execute(_playlist_item_query(pid)).all()
items = [it for (_pi, it) in rows]
@@ -163,8 +167,9 @@ def playlist_detail(
leaf_card(
it,
states.get(it.id),
shows[it.show_id].title if it.show_id in shows else None,
display_title(shows[it.show_id], lang) if it.show_id in shows else None,
shows[it.show_id].rating_key if it.show_id in shows else None,
lang=lang,
)
for it in items
]