source_utils_languageFilters.bs

import "pkg:/source/utils/languages.bs"
import "pkg:/source/utils/mediaDisplayTitle.bs"
import "pkg:/source/utils/misc.bs"

' Audio- and subtitle-language browse filters, a Jellyfin 12.0 server capability.
'
' `audioLanguages` / `subtitleLanguages` are the only `GET /Items` parameters added
' between the 10.11.8 and 12.0 spec fingerprints (jellyfin#9787). Measured on the
' local 10.11.11 / 12.0.0 / 12.1.0 servers, 2026-09-22:
'
'   * On 12.x they filter. `audioLanguages=zzz` returns 0 of 17 films; `=eng`
'     returns all 17.
'   * On 10.11 they are silently ignored: `audioLanguages=zzz` returns all 17,
'     in either casing. That is why the filters are version-gated rather than
'     offered everywhere and allowed to no-op: a 10.11 user picking "Japanese
'     audio" would get their whole library back, which reads as a broken filter.
'   * Within one parameter the values are OR'd (`hin,pol` returns exactly the
'     union, 2 films); across the two parameters they are AND'd (Spanish audio 3,
'     Japanese subtitles 9, both together 2). The panel can only have one filter
'     category active at a time, so only the OR half is reachable today:
'     see `itemgridoptions-single-active-filter` in tech-debt.md.
'   * Comma is the delimiter on the wire, and a pipe returns 0 there. It is not what
'     the categories declare: an option's value is itself several comma-joined codes,
'     so the panel separates options with PANEL_DELIMITER and toWire() flattens the
'     two apart when the request is built. See PANEL_DELIMITER for why.
'   * Values are case-sensitive 3-letter ISO 639-2 codes. `ENG` and `en` both
'     return 0. So the option list must send the server's own `Value` verbatim,
'     which is why nothing here upper- or lower-cases a code.
'
' The alias trap, and why this module exists at all. The server does not
' canonicalize ISO 639-2/T against /B: it matches the literal tag on the file. On
' the same library, `subtitleLanguages=fra` returns 1 film and `fre` returns 10,
' and `/Items/Filters2` duly advertises both as separate options: "French (fra)"
' and "French (fre)". Offering those two entries as the server sends them would be
' a filter where picking "French" gives one of two partial answers and which one
' is arbitrary. Grouping them and sending `fra,fre` returns all 11, which is what
' the user meant.
'
' The grouping needs no new table: `languageBaseCode()` already folds the alias
' pairs (it is the same fold the audio/subtitle track pickers use), and
' `resolveLanguageName()` already localizes a code through the app's own language
' keys. Checked 2026-09-22 against the 52 codes the local fixture reports: every
' one resolves, and the 8 alias pairs present (fra/fre, deu/ger, ces/cze, nld/dut,
' zho/chi, eus/baq, ell/gre, ron/rum) merge cleanly.
'
' Using the app's names rather than the server's `Name` is also what makes the list
' readable: the server's strings are English-only library-science labels
' ("Spanish; Castilian (spa)", "Greek, Modern (1453-) (ell)"), and the app already
' holds localized names for the same codes.
namespace languageFilters

  ' The first server line that honours the parameters at all. Below this they are
  ' accepted and ignored, so the gate is on the SERVER VERSION and not on whether
  ' the request succeeds.
  const MIN_SERVER = "12.0.0"

  ' The key each category is stored and sent under. Shared by the presenter that declares
  ' the category, the Filters2 merge that fills it, and toWire(), so the three cannot
  ' drift apart on spelling.
  const AUDIO_KEY = "AudioLanguages"
  const SUBTITLE_KEY = "SubtitleLanguages"

  ' Separates the codes WITHIN one language's value, and is what the server requires
  ' between values. Comma is not a preference: a pipe-separated list returns 0 results.
  const CODE_DELIMITER = ","

  ' Separates the selected options from EACH OTHER, in the panel and in the registry.
  '
  ' It cannot also be CODE_DELIMITER, and that is the whole reason this constant exists.
  ' One language's value is already several codes joined by a comma ("fra,fre"), so a
  ' comma between options makes the saved query ambiguous: the restore pass splits
  ' "fra,fre" into "fra" and "fre", matches neither against the option, and leaves the
  ' box unticked while the filter is still applied, and one confirming keypress on that
  ' panel then clears the filter outright. Measured on a Roku Stick 2026-09-22: the
  ' round-trip test fails on exactly the 8 alias-merged languages and passes on the rest,
  ' which is what makes the bug look like flakiness rather than breakage.
  '
  ' "|" is what Genres and OfficialRatings already use for the same reason (their values
  ' are free text that can contain a comma), so this follows the panel's existing rule
  ' rather than inventing one: an option's value never contains its category's delimiter.
  ' toWire() flattens the two apart on the way to the request.
  const PANEL_DELIMITER = "|"

  ' supported: Whether this server honours the language filter parameters.
  '
  ' @param {string} serverVersion - m.global.server.version
  ' @return {boolean} false for an empty/unknown version (the conservative answer:
  '                   an unknown server is assumed not to filter, so the categories
  '                   are hidden rather than offered and silently ignored)
  function supported(serverVersion as string) as boolean
    if not isValidAndNotEmpty(serverVersion) then return false
    return versionChecker(serverVersion, languageFilters.MIN_SERVER)
  end function

  ' optionsFrom: Turn one `/Items/Filters2` language list into filter options,
  ' merging the ISO 639-2/T and /B spellings of a language into a single entry.
  '
  ' @param {dynamic} namePairs - `AudioLanguages` or `SubtitleLanguages` from the
  '                              Filters2 response: an array of { Name, Value }.
  '                              Anything else (invalid, empty, not an array) is
  '                              answered with an empty array, which the caller
  '                              reads as "do not offer this category".
  ' @return {object} array of { title, value }, sorted by localized title, where
  '                  `value` is every code that resolved to the same language,
  '                  comma-joined
  function optionsFrom(namePairs as dynamic) as object
    if type(namePairs) <> "roArray" then return []

    ' base code -> { title, codes }. Insertion builds the groups; the sort below
    ' fixes the display order, because AA key order is not defined.
    groups = {}
    order = []

    for each pair in namePairs
      if not isValid(pair) then continue for

      code = pair.Value
      if not isValidAndNotEmpty(code) then continue for

      ' The app's own localized name, not the server's English label. Resolved from the
      ' CODE the server sent, never from languageBaseCode()'s folded base: the two name
      ' tables are keyed differently, and languageEnglishFallbacks holds the 3-letter
      ' forms ("aar" -> "Afar") while the base of "aar" is "aa", which no table carries.
      ' Resolving the base would render Afar as a bare "aa". (Tried, measured, reverted
      ' 2026-09-22: the naive de-duplication of this line is a downgrade.)
      '
      ' Empty means a code the app renders as nothing on purpose ("und", "zxx"). There
      ' is no sensible row to offer for those, so they are dropped rather than shown with
      ' a blank title. A code with no name at ALL keeps its raw form as the label, because
      ' resolveLanguageName() falls back to the code: a filter that works under an ugly
      ' label beats one that silently is not offered.
      '
      ' Known limit, left unaddressed here on purpose: when a group's spellings resolve to
      ' DIFFERENT names, the first one the server listed wins, so the label depends on its
      ' ordering. That needs both spellings of one language present in a single library
      ' and only one of them carrying a name. No such pair exists in the 52 codes the
      ' local fixture reported (counted 2026-09-22), where every alias pair resolves
      ' identically.
      title = resolveLanguageName(code)
      if title = "" then continue for

      base = languageBaseCode(code)
      if groups.doesExist(base)
        ' The alias case: a second spelling of a language already in the list.
        ' Both codes have to be sent, because the server matches the literal tag.
        if not inArray(groups[base].codes, code) then groups[base].codes.push(code)
      else
        groups[base] = { title: title, codes: [code] }
        order.push(base)
      end if
    end for

    options = CreateObject("roArray", order.count(), true)
    for each base in order
      options.push({
        title: groups[base].title,
        value: groups[base].codes.join(languageFilters.CODE_DELIMITER)
      })
    end for

    ' Case-insensitive so the order matches how the titles read on screen.
    options.sortBy("title", "i")
    return options
  end function

  ' toWire: the request form of a saved `filterOptions` AA.
  '
  ' The panel and the registry separate selected options with PANEL_DELIMITER; the server
  ' wants one flat comma list. Flattening that one level is the entire conversion
  ' ("fra,fre|deu,ger" becomes "fra,fre,deu,ger"), and it is what lets the saved form stay
  ' unambiguous while the sent form stays what the server accepts.
  '
  ' Only the two language keys are touched. Every other category already stores exactly
  ' what it sends, so its value is copied through untouched.
  '
  ' Called from gridQuery.build(), which is the single place a saved `filterOptions`
  ' becomes a request (LoadItemsTask2 appends whatever keys it is handed). Keeping the
  ' conversion there is what keeps the panel, the registry and the wire in agreement.
  '
  ' @param {dynamic} filterOptions - the saved AA, as BaseGridView holds it
  ' @return {object} a NEW AA; the caller's is never mutated
  function toWire(filterOptions as dynamic) as object
    if type(filterOptions) <> "roAssociativeArray" then return {}

    wire = {}
    for each key in filterOptions
      if languageFilters.isLanguageKey(key)
        wire[key] = textOrEmpty(filterOptions[key]).split(languageFilters.PANEL_DELIMITER).join(languageFilters.CODE_DELIMITER)
      else
        wire[key] = filterOptions[key]
      end if
    end for
    return wire
  end function

  ' isLanguageKey: whether a `filterOptions` key is one of the two language categories.
  '
  ' Case-insensitive because the key round-trips through the registry as JSON and through
  ' a SceneGraph assocarray field, neither of which promises to preserve the spelling a
  ' presenter declared.
  function isLanguageKey(key as dynamic) as boolean
    name = LCase(textOrEmpty(key))
    return name = LCase(languageFilters.AUDIO_KEY) or name = LCase(languageFilters.SUBTITLE_KEY)
  end function

end namespace