source_utils_people.bs

import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"

' The two surfaces built from an item's `People`: the Cast & Crew cards (merging a
' person's repeated credits into one card and labeling it in the user's language) and
' the details screen's credits row (see creditRowKinds()).
'
' The server sends one `People` entry per CREDIT, not per person: a director who also
' wrote the screenplay appears twice with the same `Id`. That is true on every supported
' line (verified live on 10.7.7, 10.11.11, 12.0.0 and demo 12.1.0), so nothing here is
' version-gated. `BaseItemPerson.Type` is the broad `PersonKind` enum; `Role` is the
' TMDB job string.
'
' Labels are resolved CLIENT-side and always will be: the server's per-request
' localization (12.0+, `Accept-Language`) covers only its own resource strings, and
' neither `PersonKind` values nor TMDB job names are among them. See jellyfin#16488.
'
' Mirrors jellyfin-web's `getPeopleRoleOrTypeLabel` (cardBuilder.js) and the merge in
' its itemDetails controller, with four deliberate differences recorded in the PR:
'
'   1. Guest stars stay in the one Cast & Crew row rather than getting a row of their
'      own, because JellyRock has no layout for a second row.
'   2. `Artist` / `AlbumArtist` are KEPT (web drops them because it renders artists
'      separately; dropping them here would empty the row on a music video).
'   3. `Unknown` renders NO label rather than the literal word: see unlabeledPersonKinds().
'   4. Repeated credits that would label identically collapse: see castCardSpecs().

' 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.
function mergeablePersonKinds() as object
  if isValid(m.mergeablePersonKindsCache) then return m.mergeablePersonKindsCache
  m.mergeablePersonKindsCache = { "director": true, "writer": true, "producer": true }
  return m.mergeablePersonKindsCache
end function

' 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.
function personKindTranslationKeys() as object
  if isValid(m.personKindTranslationKeysCache) then return m.personKindTranslationKeysCache

  m.personKindTranslationKeysCache = {
    "actor": translationKeys.LabelPersonKindActor,
    "albumartist": translationKeys.LabelPersonKindAlbumArtist,
    "arranger": translationKeys.LabelPersonKindArranger,
    "artist": translationKeys.LabelPersonKindArtist,
    "author": translationKeys.LabelPersonKindAuthor,
    "colorist": translationKeys.LabelPersonKindColorist,
    "composer": translationKeys.LabelPersonKindComposer,
    "conductor": translationKeys.LabelPersonKindConductor,
    "coverartist": translationKeys.LabelPersonKindCoverArtist,
    "creator": translationKeys.LabelPersonKindCreator,
    "director": translationKeys.LabelPersonKindDirector,
    "editor": translationKeys.LabelPersonKindEditor,
    "engineer": translationKeys.LabelPersonKindEngineer,
    "gueststar": translationKeys.LabelPersonKindGuestStar,
    "illustrator": translationKeys.LabelPersonKindIllustrator,
    "inker": translationKeys.LabelPersonKindInker,
    "letterer": translationKeys.LabelPersonKindLetterer,
    "lyricist": translationKeys.LabelPersonKindLyricist,
    "mixer": translationKeys.LabelPersonKindMixer,
    "narrator": translationKeys.LabelPersonKindNarrator,
    "penciller": translationKeys.LabelPersonKindPenciller,
    "producer": translationKeys.LabelPersonKindProducer,
    "remixer": translationKeys.LabelPersonKindRemixer,
    "translator": translationKeys.LabelPersonKindTranslator,
    "writer": translationKeys.LabelPersonKindWriter
  }
  return m.personKindTranslationKeysCache
end function

' `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.
function unlabeledPersonKinds() as object
  if isValid(m.unlabeledPersonKindsCache) then return m.unlabeledPersonKindsCache
  m.unlabeledPersonKindsCache = { "unknown": true }
  return m.unlabeledPersonKindsCache
end function

' 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".
function personJobTranslationKeys() as object
  if isValid(m.personJobTranslationKeysCache) then return m.personJobTranslationKeysCache

  m.personJobTranslationKeysCache = {
    "novel": translationKeys.LabelPersonJobNovel,
    "screenplay": translationKeys.LabelPersonJobScreenplay
  }
  return m.personJobTranslationKeysCache
end function

' 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.
'
' @param personKind - `BaseItemPerson.Type` (a `PersonKind` value)
' @param role  - `BaseItemPerson.Role` (a TMDB job, or an actor's character)
' @param cache - optional AA reused across one batch of people
' @return the display label, or "" when there is nothing to say
function personCreditLabel(personKind as string, role as string, cache = invalid as dynamic) as string
  lowerKind = LCase(personKind)

  if role <> "" and (lowerKind = "actor" or lowerKind = "gueststar")
    ' One resolve for the batch; the character is substituted thread-locally.
    return translateCached(translationKeys.LabelPersonRoleAs, cache).Replace("{0}", role)
  end if

  if role = "" or LCase(role) = lowerKind
    if personKind = "" then return ""
    ' Labelled kinds are checked FIRST because they are almost all of them, and each of
    ' these table accessors is a function call this loop pays per credit. The gate keeps
    ' the two tables disjoint, so the order is free to favour the common case.
    kindKeys = personKindTranslationKeys()
    if isValid(kindKeys[lowerKind]) then return translateCached(kindKeys[lowerKind], cache)
    ' A kind we say nothing for on purpose reads the same as no kind at all.
    if isValid(unlabeledPersonKinds()[lowerKind]) then return ""
    ' A kind no committed fingerprint knows about, that is a server newer than our newest
    ' fingerprint, which the coverage gate cannot see. Show it rather than nothing.
    return personKind
  end if

  jobKeys = personJobTranslationKeys()
  if isValid(jobKeys[LCase(role)]) then return translateCached(jobKeys[LCase(role)], cache)
  ' A real job we carry no string for beats the generic type it was filed under, which
  ' is also what web does with a job it cannot translate.
  return role
end function

' 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.
'
' @param people - raw `BaseItemPerson` array from a parent item's `People` field
' @return array of { id, name, primaryImageTag, subtitle }
function castCardSpecs(people as dynamic) as object
  specs = []
  if not isValid(people) then return specs

  ' One resolve per distinct translation key for the whole array: see personCreditLabel.
  labelCache = {}
  mergeable = mergeablePersonKinds()
  indexById = {}

  for each person in people
    if isValid(person)
      ' `?? ""` rather than a helper call: this runs 31+ times on a 31-person cast and a
      ' BrightScript function call is not free. The fields are strings when present.
      personKind = person.Type ?? ""
      role = person.Role ?? ""
      id = person.Id ?? ""
      label = personCreditLabel(personKind, role, labelCache)

      existing = invalid
      isMergeable = id <> "" and isValid(mergeable[LCase(personKind)])
      if isMergeable then existing = indexById[id]

      if isValid(existing)
        if label <> ""
          previous = specs[existing].subtitle
          if previous = ""
            specs[existing].subtitle = label
          else if Instr(1, " / " + previous + " / ", " / " + label + " / ") = 0
            ' Both sides wrapped in the separator so the match is on a WHOLE part:
            ' a bare Instr would let "Director" suppress "Co-Director".
            specs[existing].subtitle = previous + " / " + label
          end if
        end if
      else
        if isMergeable then indexById[id] = specs.count()
        specs.push({
          id: id,
          name: person.Name ?? "",
          primaryImageTag: person.PrimaryImageTag ?? "",
          subtitle: label
        })
      end if
    end if
  end for

  return specs
end function

' 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.
function creditRowKinds() as object
  if isValid(m.creditRowKindsCache) then return m.creditRowKindsCache
  m.creditRowKindsCache = [
    { kind: "creator", messageKey: translationKeys.MessageCreatedBy1 },
    { kind: "director", messageKey: translationKeys.MessageDirectedBy1 }
  ]
  return m.creditRowKindsCache
end function

' 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.
'
' @param people - raw `BaseItemPerson` array from an item's `People` field
' @return array of { id, text }, one per kind that has credits, in row order
function creditRowSpecs(people as dynamic) as object
  specs = []
  if not isValid(people) then return specs

  kinds = creditRowKinds()
  buckets = {}
  for each entry in kinds
    buckets[entry.kind] = { names: [], seen: {} }
  end for

  for each person in people
    if isValid(person)
      bucket = buckets[LCase(textOrEmpty(person.Type))]
      if isValid(bucket)
        name = textOrEmpty(person.Name).trim()
        if name <> ""
          identity = textOrEmpty(person.Id)
          if identity = "" then identity = "name:" + LCase(name)
          if not isValid(bucket.seen[identity])
            bucket.seen[identity] = true
            bucket.names.push(name)
          end if
        end if
      end if
    end if
  end for

  for each entry in kinds
    names = buckets[entry.kind].names
    if names.count() > 0
      specs.push({ id: entry.kind, text: translate(entry.messageKey, [names.join(", ")]) })
    end if
  end for

  return specs
end function