source_utils_mediaDisplayTitle.bs

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

' getVideoResolutionLabel: Determine resolution label from video dimensions
' Resolution thresholds check width OR height (either can qualify)
' 4K never gets p/i suffix. All other resolutions get "p" or "i" based on interlacing.
'
' @param {integer} width - Video width in pixels
' @param {integer} height - Video height in pixels
' @param {boolean} isInterlaced - Whether the video is interlaced
' @returns {string} - Resolution label (for example "4K", "1080p", "720i") or "" if below all thresholds
function getVideoResolutionLabel(width as integer, height as integer, isInterlaced as boolean) as string
  ' 4K: Width >= 3800 OR Height >= 2000 (no p/i suffix)
  if width >= 3800 or height >= 2000
    return "4K"
  end if

  ' Determine scan type suffix for non-4K resolutions
  suffix = "p"
  if isInterlaced then suffix = "i"

  ' 1440: Width >= 2500 OR Height >= 1400
  if width >= 2500 or height >= 1400
    return "1440" + suffix
  end if

  ' 1080: Width >= 1800 OR Height >= 1000
  if width >= 1800 or height >= 1000
    return "1080" + suffix
  end if

  ' 720: Width >= 1200 OR Height >= 700
  if width >= 1200 or height >= 700
    return "720" + suffix
  end if

  ' 480: Width >= 700 OR Height >= 400
  if width >= 700 or height >= 400
    return "480" + suffix
  end if

  ' Below all thresholds
  return ""
end function

' resolveLanguageName: Convert a media stream language code to a localized display name.
'
' MediaStream.Language passes through whatever the container metadata holds:
' ffmpeg/Jellyfin do not normalize, so real-world inputs include ISO 639-2/T
' ("fra"), 639-2/B ("fre"), 639-1 ("fr"), and BCP-47 tags ("fr-FR", "pt_BR").
'
' Three-tier resolution chain:
'   1. Lowercase + strip after first "-" or "_" (drop region/script suffix)
'   2. "und" / "zxx" give "": caller omits the language label
'   3. Alias map: normalize 3-letter / deprecated codes to a base form
'   4. Tier 1: translationKey lookup, then translate(): localized to user's UI
'   5. Tier 2: English-name fallback: covers every other ISO 639-2 code
'   6. Tier 3: raw code passthrough: last-resort, preserves info
'
' "und" (Undefined) and "zxx" (No linguistic content: instrumental music,
' ambient sound, foley) are both omitted: rendering "No linguistic content
' EAC3 5.1" as a track label is noisier than plain "EAC3 5.1". The other
' ISO 639-2 special codes ("mul" Multiple, "mis" Uncoded) ARE displayed
' because they convey real information when they appear.
'
' @param {string} langCode - Media stream language code from any common format
' @param {object} [cache=invalid] - translateCached() batch cache
' @returns {string} - Localized name, English fallback, "" for und/zxx, or raw code
function resolveLanguageName(langCode as string, cache = invalid as dynamic) as string
  if langCode = "" then return ""

  base = LCase(langCode)

  hyphenPos = inStr(1, base, "-")
  if hyphenPos > 0 then base = Left(base, hyphenPos - 1)
  underscorePos = inStr(1, base, "_")
  if underscorePos > 0 then base = Left(base, underscorePos - 1)

  if base = "und" or base = "zxx" then return ""

  aliases = mediaLanguageAliases()
  if aliases.doesExist(base) then base = aliases[base]

  keyMap = languageTranslationKeys()
  if keyMap.doesExist(base) then return translateCached(keyMap[base], cache)

  fallbacks = languageEnglishFallbacks()
  if fallbacks.doesExist(base) then return fallbacks[base]

  return langCode
end function

' formatAudioChannels: Convert channel count integer to display format
' Ignores ChannelLayout field since it can return inconsistent strings (for example "stereo")
'
' @param {integer} channels - Number of audio channels
' @returns {string} - Channel layout string (for example "2.0", "5.1", "7.1") or "" if invalid/zero
function formatAudioChannels(channels as integer) as string
  if channels <= 0 then return ""

  if channels <= 2
    return channels.toStr().trim() + ".0"
  end if

  ' For > 2 channels: (channels - 1).1 (for example 6 -> "5.1", 8 -> "7.1")
  return (channels - 1).toStr().trim() + ".1"
end function

' formatVideoDisplayTitle: Build a display title for a video stream
' Format: "RESOLUTION CODEC" or "RESOLUTION CODEC RANGETYPE" (when HDR)
'
' @param {object} stream - Video MediaStream object from Jellyfin API
' @returns {string} - Formatted display title (for example "4K HEVC HDR10") or "N/A" if insufficient data
function formatVideoDisplayTitle(stream as object) as string
  if not isValid(stream) then return "N/A"

  parts = []

  ' 1. Resolution label
  width = 0
  height = 0
  isInterlaced = false
  if isValid(stream.Width) then width = stream.Width
  if isValid(stream.Height) then height = stream.Height
  if isValid(stream.IsInterlaced) then isInterlaced = stream.IsInterlaced

  resolutionLabel = getVideoResolutionLabel(width, height, isInterlaced)
  if resolutionLabel <> ""
    parts.push(resolutionLabel)
  end if

  ' 2. Codec (uppercase)
  if isValid(stream.Codec) and stream.Codec <> ""
    parts.push(UCase(stream.Codec))
  end if

  ' 3. HDR info - only when VideoRange = "HDR" (case-insensitive)
  if isValid(stream.VideoRange) and LCase(stream.VideoRange) = "hdr"
    parts.push(formatVideoRangeLabel(stream))
  end if

  ' If no parts could be assembled, return "N/A"
  if parts.count() = 0 then return "N/A"

  return parts.join(" ")
end function

' formatVideoRangeLabel: The dynamic-range label for a video stream.
'
' Extracted from formatVideoDisplayTitle so the playback-info report can name the
' same range the track pickers do, rather than growing a second copy of the Dolby
' Vision profile/compatibility formatting.
'
' Callers differ in one way, and this function leaves that choice to them on
' purpose: formatVideoDisplayTitle only asks for a label when VideoRange is
' "HDR", because "1080p H264 SDR" is noise in a picker row. The report asks
' unconditionally, because "SDR" is exactly what it needs to render on the target
' side of a tone-mapping transcode.
'
' VideoRangeType is a 10.8+ field, so on the floor server this falls back to the
' coarse VideoRange ("SDR" / "HDR") that has existed throughout.
'
' @param {object} stream - Video MediaStream object from Jellyfin API
' @returns {string} - for example "DV 8.1", "HDR10+", "HLG", "SDR", or "" with nothing to go on
function formatVideoRangeLabel(stream as object) as string
  if not isValid(stream) then return ""

  isHdr = isValid(stream.VideoRange) and LCase(stream.VideoRange) = "hdr"

  if not isValid(stream.VideoRangeType) or stream.VideoRangeType = "" or LCase(stream.VideoRangeType) = "unknown"
    ' Pre-10.8, or a stream the server could not classify.
    if isHdr then return "HDR"
    if isValidAndNotEmpty(stream.VideoRange) then return UCase(stream.VideoRange)
    return ""
  end if

  rangeTypeLower = LCase(stream.VideoRangeType)

  if rangeTypeLower = "sdr" then return "SDR"
  if rangeTypeLower = "hdr10" then return "HDR10"
  if rangeTypeLower = "hdr10plus" then return "HDR10+"
  if rangeTypeLower = "hlg" then return "HLG"

  if rangeTypeLower.left(4) = "dovi"
    ' All Dolby Vision variants: DOVI, DOVIWithSDR, DOVIWithHDR10, DOVIWithHLG, etc.
    hdrLabel = "DV"

    if isValid(stream.DvProfile)
      doviProfile = stream.DvProfile.toStr().trim()
      if doviProfile <> "" and doviProfile <> "0"
        hdrLabel = hdrLabel + " " + doviProfile

        if isValid(stream.DvBlSignalCompatibilityId)
          doviCompatId = stream.DvBlSignalCompatibilityId.toStr().trim()
          if doviCompatId <> "" and doviCompatId <> "0"
            hdrLabel = hdrLabel + "." + doviCompatId
          end if
        end if
      end if
    end if

    return hdrLabel
  end if

  ' Unrecognized range type - show raw value as-is rather than dropping it.
  return stream.VideoRangeType
end function

' formatAudioDisplayTitle: Build a display title for an audio stream
' Format: "Language CODEC CHANNELS" or "Language CODEC CHANNELS (Default)"
'
' @param {object} stream - Audio MediaStream object from Jellyfin API
' @returns {string} - Formatted display title (for example "English AAC 2.0 (Default)") or "N/A" if insufficient data
function formatAudioDisplayTitle(stream as object) as string
  if not isValid(stream) then return "N/A"

  parts = []

  ' 1. Language (full name from ISO 639-2 code; omitted for "und"/Undefined)
  if isValid(stream.Language) and stream.Language <> ""
    langName = resolveLanguageName(stream.Language)
    if langName <> "" then parts.push(langName)
  end if

  ' 2. Codec (uppercase)
  if isValid(stream.Codec) and stream.Codec <> ""
    parts.push(UCase(stream.Codec))
  end if

  ' 3. Channel layout (prefer ChannelLayout from server, fall back to computing from Channels)
  channelLabel = ""
  if isValid(stream.ChannelLayout) and stream.ChannelLayout <> ""
    channelLabel = stream.ChannelLayout
  else if isValid(stream.Channels)
    channelLabel = formatAudioChannels(stream.Channels)
  end if
  if channelLabel <> ""
    parts.push(channelLabel)
  end if

  ' If no parts could be assembled, return "N/A"
  if parts.count() = 0 then return "N/A"

  result = parts.join(" ")

  ' 4. Default suffix. Translated: this string is shown to the user, and a
  ' hard-coded English word is a bug in every locale but one: it left a French
  ' or German user reading their own language everywhere except this one word.
  if isValid(stream.IsDefault) and stream.IsDefault = true
    result = result + " (" + translate(translationKeys.LabelDefault) + ")"
  end if

  return result
end function

' subtitleLabelParts: What a subtitle stream says about itself, as the pieces its
' labels are built from: the full language name ("" for none, "und" or "zxx"), the codec
' uppercased ("" for none), and the flags in display order. Hearing-impaired leads
' because it changes what the track IS rather than when it plays; Forced takes
' precedence over Default if both are set.
'
' @param {object} stream - Subtitle MediaStream object from Jellyfin API
' @param {object} [cache=invalid] - translateCached() batch cache, for a caller labeling many tracks
' @returns {object} - { language as string, codec as string, flags as roArray of string }
function subtitleLabelParts(stream as object, cache = invalid as dynamic) as object
  parts = { language: "", codec: "", flags: [] }
  if not isValid(stream) then return parts

  if isValid(stream.Language) and stream.Language <> ""
    parts.language = resolveLanguageName(stream.Language, cache)
  end if
  if isValid(stream.Codec) and stream.Codec <> ""
    parts.codec = UCase(stream.Codec)
  end if

  if isValid(stream.IsHearingImpaired) and stream.IsHearingImpaired = true
    parts.flags.push(translateCached(translationKeys.LabelHearingImpaired, cache))
  end if
  if isValid(stream.IsForced) and stream.IsForced = true
    parts.flags.push(translateCached(translationKeys.LabelForcedSubtitle, cache))
  else if isValid(stream.IsDefault) and stream.IsDefault = true
    parts.flags.push(translateCached(translationKeys.LabelDefault, cache))
  end if

  return parts
end function

' joinSubtitleLabelParts: "Language CODEC (flag, flag)", leaving out whatever is
' empty. "" when there is no language and no codec, whatever the flags: a flag
' alone does not say which track it is.
function joinSubtitleLabelParts(language as string, codec as string, flags as object) as string
  words = []
  if language <> "" then words.push(language)
  if codec <> "" then words.push(codec)
  if words.count() = 0 then return ""

  result = words.join(" ")
  if flags.count() > 0 then result = result + " (" + flags.join(", ") + ")"
  return result
end function

' formatSubtitleDisplayTitle: Build a display title for a subtitle stream
' Format: "Language CODEC", optionally followed by a parenthesized flag list:
' "English SUBRIP (Hearing impaired)", "English SUBRIP (Hearing impaired, Forced)".
' The language is omitted for "und"/Undefined. The flags matter: the panel badges
' hearing-impaired, so without it the dropdown and the panel described the same
' track differently.
'
' @param {object} stream - Subtitle MediaStream object from Jellyfin API
' @param {object} [cache=invalid] - translateCached() batch cache, for a caller labeling many tracks
' @returns {string} - Formatted display title (for example "English SUBRIP (Forced)") or "N/A" if insufficient data
function formatSubtitleDisplayTitle(stream as object, cache = invalid as dynamic) as string
  parts = subtitleLabelParts(stream, cache)
  result = joinSubtitleLabelParts(parts.language, parts.codec, parts.flags)
  if result = "" then return "N/A"
  return result
end function

' formatSubtitleTrackLabel: The label a subtitle list shows for one track. The
' details-screen dropdown and the playback picker both use it, so the same track
' reads the same on both. It was two paths once, and the picker's (the server's
' DisplayTitle verbatim) led with "Undefined" for a track with no language.
'
' A track's own Title leads when it has one, because it is often the only thing
' telling two tracks apart: "Spanish (Latin America)" and "Spanish (Spain)" are both
' "Spanish SUBRIP" by language and codec, as are a "Signs" track and a full one.
' This is the server's own DisplayTitle rule, minus its "Undefined" placeholder:
' the Title, then each part it does not already say. A Title that names the language
' stands in for it, so the codec follows with a space ("English (SDH) SUBRIP");
' otherwise " · " separates the two ("SDH · English SUBRIP (Hearing impaired)").
'
' A file kept outside the video gets a marker. "External" is the word Jellyfin and
' the playback report use for it, and it covers a download and a file placed by hand
' alike. When there is no Title, language or codec to build from, the server's
' DisplayTitle says more than "N/A".
'
' @param {object} stream - Subtitle MediaStream object from Jellyfin API
' @param {boolean} [markExternal=true] - false for a slot that shows the track alone, without the entries the marker distinguishes it from
' @param {object} [cache=invalid] - translateCached() batch cache, for a caller labeling many tracks
' @returns {string} - for example "English SUBRIP", "SAMI  ·  External", "Spanish (Latin America) SUBRIP"; "N/A" if there is nothing to describe it by
function formatSubtitleTrackLabel(stream as object, markExternal = true as boolean, cache = invalid as dynamic) as string
  if not isValid(stream) then return "N/A"

  parts = subtitleLabelParts(stream, cache)
  title = ""
  if isValidAndNotEmpty(stream.Title) then title = toString(stream.Title).Trim()

  if title = ""
    label = joinSubtitleLabelParts(parts.language, parts.codec, parts.flags)
  else
    lowerTitle = LCase(title)
    titleNamesLanguage = parts.language <> "" and inStr(1, lowerTitle, LCase(parts.language)) > 0
    language = parts.language
    if titleNamesLanguage then language = ""
    codec = parts.codec
    if codec <> "" and inStr(1, lowerTitle, LCase(codec)) > 0 then codec = ""
    flags = []
    for each flag in parts.flags
      if inStr(1, lowerTitle, LCase(flag)) = 0 then flags.push(flag)
    end for

    rest = joinSubtitleLabelParts(language, codec, flags)
    if rest <> "" and titleNamesLanguage
      label = title + " " + rest
    else if rest <> ""
      label = title + " · " + rest
    else if flags.count() > 0
      label = title + " (" + flags.join(", ") + ")"
    else
      label = title
    end if
  end if

  if label = "" and isValidAndNotEmpty(stream.DisplayTitle) then label = toString(stream.DisplayTitle)
  if label = "" then return "N/A"
  if markExternal and stream.IsExternal = true then label = label + "  ·  " + translateCached(translationKeys.LabelExternalSubtitle, cache)

  return label
end function