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