Methods

(static) castCardSpecs(people) → {object}

Merge and label a whole People array in ONE pass: what the Cast & Crew row wants.

Pure: takes raw AAs, returns plain AAs, creates no nodes and reads no node fields.

Fused on purpose rather than split into a merge step and a label step. Do not re-split it: the two-pass version allocated a credit AA, a credits array, a second spec AA and a labels array to join, PER PERSON, and measured 46% slower on the row it serves (measured 2026-09-20 on a Stick 4K against Jellyfin 12.0.0, a 31-person cast: castMs median 47.5 ms -> 69.5 ms, n=8 per arm interleaved, Mann-Whitney p=0.0073). One AA per CARD, with the subtitle appended as each credit is read, is what this costs instead: the loop runs once per person on a Task thread, where Roku's interpreter charges for every allocation.

The merge rule, mirroring jellyfin-web's itemDetails controller: entries that share an Id collapse onto the card of their first appearance, but only when the credit is a Director / Writer / Producer one. Actors and guest stars stay separate: a performer credited twice is usually two characters. An entry with no Id never merges, because the id is the only thing that identifies one person across two credits and merging on name would fuse two different people who share one.

Credits that would label IDENTICALLY collapse rather than repeat: a director also credited as such by Role read "Director / Director". Web does repeat them (its RoleList join does not dedupe), so this is a deliberate improvement on the reference. The test is anchored on " / " so a credit that merely CONTAINS an existing one still shows: an existing "Director" must not swallow a later "Co-Director". It runs only on the second and later credits of a MERGED person, so the no-merge path (the one the timings above were taken on) allocates nothing new.

Parameters:
NameTypeDescription
peopledynamic

raw BaseItemPerson array from a parent item's People field

Returns:

array of { id, name, primaryImageTag, subtitle }

Type: 
object

(static) creditRowKinds() → {object}

The credits the details screen puts on its credits row, in the order they appear on it: "Created by ...", then "Directed by ...". Lowercase keys, like the tables above.

The ORDER is the overflow policy. A row too long for the space left of the logo is cut by fitInfoRow(), which keeps the earlier item and drops the later one, so on a series with a long creator list "Directed by" is what goes. That is the intended trade: on the one type that carries both, the creators are the headline credit.

Adding a kind here is the whole change: creditRowSpecs() picks it up in the same single pass, and ItemDetails needs no edit.

Cached on the caller's m after first build. Caches the translation KEYS, never the resolved strings, so a mid-session UI-language change is picked up on the next lookup.

Returns:
Type: 
object

(static) creditRowSpecs(people) → {object}

The details screen's credit lines, ready to put in a Label, in creditRowKinds() order.

ONE pass over People covering every kind, because People is a node ARRAY field: reading item.people copies the whole array, so a pass per kind costs a copy per kind. The caller reads it once and hands it here.

Deduplicated by Id for the same reason the cards merge: the server sends one entry per credit, so a director credited twice (say Director and Co-Director) would otherwise read "Directed by Mike Judge, Mike Judge". Entries with no Id fall back to name matching, which is the best available identity when the server gives none.

The dedupe is PER KIND, not shared across kinds. Someone credited as both Creator and Director belongs on both lines; one seen map across the buckets would silently drop the second line. Pinned by a spec.

A kind with no named credits yields NO spec, so the row never shows a bare "Created by" with nothing after it. A name that is blank or only whitespace does not count as a credit: an entry the server sent with no usable name would otherwise pad the line.

Unlike castCardSpecs() this resolves its translations WITHOUT a batch cache: it runs on the render thread (ItemDetails.populateInfoGroup), where translate()'s read of m.global.translations costs ~2 us rather than the ~93 us a Task thread pays, and there is at most one call per kind. Call it from a Task thread and that stops being true.

Parameters:
NameTypeDescription
peopledynamic

raw BaseItemPerson array from an item's People field

Returns:

array of { id, text }, one per kind that has credits, in row order

Type: 
object

(static) mergeablePersonKinds() → {object}

The person kinds whose repeated credits collapse onto one card. Actors and guest stars stay separate: a performer credited twice is usually two distinct characters, and web makes the same split.

Returns:
Type: 
object

(static) personCreditLabel(personKind, role, cacheopt) → {string}

Label for ONE credit, using jellyfin-web's rule: actor / guest star with a character: "as {character}" no role, or a role that only restates the type: the translated type, or "" when unlabeledPersonKinds() covers that kind otherwise: the role, translated when we carry a string

cache is an AA the caller threads through a batch, keyed on the TRANSLATION KEY, not on the credit: an actor's label differs per character, so keying it on the credit would miss on every actor and leave a 30-person cast making 30 translate() calls. Resolving "as {0}" once and substituting the character locally bounds the whole batch to at most one call per distinct key, in practice a few.

That matters because this runs on a Task thread, where translate() reads m.global.translations (one rendezvous at 93 us against 2 us from the render thread, measured 2026-08-23 on a Stick 4K) and reads m.global.translationsFallback for a SECOND only when the key misses, which for en_US it never does (docs/architecture/async.md). Pass invalid to skip caching.

Parameters:
NameTypeAttributesDefaultDescription
personKindstring

BaseItemPerson.Type (a PersonKind value)

rolestring

BaseItemPerson.Role (a TMDB job, or an actor's character)

cachedynamic<optional>
invalid

optional AA reused across one batch of people

Returns:

the display label, or "" when there is nothing to say

Type: 
string

(static) personJobTranslationKeys() → {object}

Maps a TMDB job to its translation key, for the jobs the server maps onto a broader PersonKind. Screenplay and Novel both arrive as Type: "Writer" (jellyfin#16066, #17248) and are the two web carries a string for. Any other job falls through to the raw Role, which is also what web does: an untranslated real job beats a generic "Writer".

Returns:
Type: 
object

(static) personKindTranslationKeys() → {object}

Maps a PersonKind to its translation key. Covers every enum value except those unlabeledPersonKinds() renders blank on purpose, because each one reaches the card subtitle when no Role is set. Before this map they rendered as raw enum text ("GuestStar").

Completeness is GATED, not remembered: npm run lint:language-coverage fails when a PersonKind value in any committed spec fingerprint appears in neither this map nor unlabeledPersonKinds(). A missing row is not a compile error (there is no expression to fail on), which is exactly how nine values shipped as raw English before 2026-09-20.

Cached on the caller's m after first build. Caches the KEYS, never the resolved strings, so a mid-session UI-language change is picked up on the next lookup.

Returns:
Type: 
object

(static) unlabeledPersonKinds() → {object}

PersonKind values that render NO label, on purpose.

Unknown is the enum's serialization DEFAULT (BaseItemPerson.Type declares default: "Unknown"), so it means "the server did not say": the same thing as no type at all, which personCreditLabel() already renders as "". Putting the literal word under a person's name is noise. jellyfin-web translates it instead; this is deliberate.

Declared as a SET rather than left out of personKindTranslationKeys(), because the coverage gate requires the two together to cover the enum. An omission is indistinguishable from the oversight that gate exists to catch; a row here is a recorded decision.

Cached on the caller's m after first build.

Returns:
Type: 
object