The biography sat at reading width in the left half of a full-width panel with nothing on the right.
Now the person (headshot, facts, the bio clamped to eight lines with More / Less when it is cut) is on the
left and "Known for" on the right; with fewer than four titles the rail takes only its posters' width.
One column where the panel is narrow (200% zoom, the largest text size).
While the server fetches a bio (`enriching`), the page shows a paragraph skeleton (a new "text" shape of
LoadingState) and asks again every 1.5 s for up to 20 s. The view is keyed by the person's name, so
another person starts fresh.
Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
- lib/vocab + the `vocab` locale area: TMDB crew jobs and departments, auto-tags (D1 A: the data
stays English, the catalog is named at its one source, useTags), built-in download profiles,
YouTube topics; held key-for-key to the backend's own lists by backendIds.contract.
- Countries by ISO code: TMDB parse stores {code, name}; rows stored before are coded from TMDB's
own country list in one request (run_enrich); Intl names them, historic codes (YU, SU, XI…)
from the catalog.
- Player tracks named from the language tag, forced/SDH flags and codec tail, not Plex's
server-language displayTitle; no invented "Audio N" on the server.
- Download lifecycle notices (shared / expiring / expired / evicted) named from data; the share
notice carries the title.
- Scheduler: the job result rides along as data and is named per key (dashboard + run notice).
- Invite status named; a render crash's notice no longer shows the engine's English message.
- Fix: YouTube links …/wiki/Humour, the tagger looked for "Humor" — Comedy never came from a topic.
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.
Plex player: a finger's tap on the picture only shows or hides the controls,
the hide timer starts with playback, a double-tap on a side third skips 10 s,
the seek bar is a 2.75rem touch-none hit area with its head shown on touch.
The row is play/pause, Back by position, Next only with a next item, mute,
time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download)
and fullscreen; Stop, Restart and the ±10 s buttons are gone.
A started card shows the video card's Continue + Restart pair (one shared
MediaResumeButtons, beside the picture's button), and its menu lists the
restart; the player opens that item at 0 (fromStart). YouTube modal: the
description opens from an ⓘ, the queue arrows sit inside the screen.
The person page now leads with a TMDB bio, lazily fetched the first time it's
opened (off the S2-linked `tmdb_person_id`) and then served from the row:
- Migration 0072: ADD-only nullable person columns on plex_people — biography,
birthday, deathday, place_of_birth, known_for_department, also_known_as,
known_for ([{tmdb_id, media_type, title}]), tmdb_enriched_at.
- tmdb.py: TmdbClient.fetch_person (/person/{id}+combined_credits), parse_person
(bio + a popularity-ranked, deduped known_for), apply_person, enrich_person
(lazy one-shot, key+id guarded, commits, 404 stamps the watermark).
- people.py person_detail: loads the full person row, lazily enriches (best-effort
— never fails the page), serves the bio fields + resolves known_for to library
rows by guid (in-library only, popularity order).
- FE PlexPersonView: bio/birth-facts/aka hero + a "Known for" Carousel rail above
the existing Movies/Shows filmography; PlexPersonDetail type + i18n (en+hu).
Tests: test_plex_person_bio.py (6: parse / lazy-enrich guards / serve) + extended
PlexPersonView.dom.test.tsx + the unknown-name contract in test_plex_person.py.
Browser-E2E'd on Jim Carrey (live TMDB enrich → bio 1937 chars, born 1962-01-17
Newmarket, Known-for resolved). Gate: vitest 337, backend lane 402.
Wave 5a building blocks under the Seerr-inspired Plex enrichment (E5).
Carousel (frontend/src/components/Carousel.tsx): the one horizontal-rail
component, replacing the hand-rolled `flex overflow-x-auto` strips and the
home of the future Plex-Discover rows. Edge-fade mask, hover/focus scroll
buttons that appear only on the clipped side (useHScroll — the X-axis twin
of useScrollFade, same watch-the-children discipline), native scrollbar
hidden behind .no-scrollbar, reduced-motion-aware paging. First consumers:
CastStrip + RelatedStrip now scroll inside it (dedup + free affordances).
New i18n: common.seeAll / scrollLeft / scrollRight (en+hu).
Card-art serializer (backend/app/plex/cards.py): movie_card + show_card now
emit `art` (the wide backdrop, /api/plex/image/{rk}?variant=art) so rails and
the crossfading hero get a landscape image with no per-item fetch — gated on
art_key so an art-less item stays null and falls back to the poster. FE type
PlexCard.art?.
Tests: Carousel.dom.test (children/header/see-all, clipped-edge buttons +
paging), test_plex_cards (art present/absent by art_key). Added a
ResizeObserver stub to PlexPersonView.dom.test (it renders CastStrip, now a
Carousel consumer).
Gate: tsc x3 / eslint / prettier / knip / vitest 324 green; ruff green;
backend test lane (docker-compose.test.yml) green on the plex card tests.
- PlexPersonView.dom.test.tsx: variadic vi.fn mock for plexSetState so the
spread call typechecks (TS2556 — zero-arg mock couldn't take ...args).
- plexSearch.ts: drop the unused `export` on PLEX_SEARCH_FIELDS (module-local
only; knip flagged it as an unused export).
tsc / knip / lint all green; ruff clean.
- Efficiency: cap each person section at _PERSON_SECTION_MAX (200) so a prolific person can't
return an unbounded list the client mounts a card per row for.
- Reuse: extract the optimistic watch-toggle (flip → persist → resync) into a shared
toggleWatchState helper; the grid and the person page each supply their own cache updater.
- Correctness (scroll): onOpenPerson now stashes infoScrollRef ONLY when leaving an info page
(not from a show/grid context), and a jumped-to info page resets it — so a stale offset can't
wrongly restore a fresh info page.
- Test-coverage: add PlexPersonView.dom.test.tsx (export CastStrip + PlexPersonView) covering
the cast-row rewiring (open-person vs filter fallback) and the person page's sections + filter
+ empty state.
The name-as-identity finding is inherent to Plex cast carrying no per-person id (PlexPerson is
name-keyed too); documented in the endpoint docstring, not structurally fixable in S4.