source_utils_streamSelection.bs

import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/languages.bs"
import "pkg:/source/utils/misc.bs"

' streamSelection.bs
' Comprehensive audio and video stream selection utilities
' Combines user preferences with hardware capabilities for optimal playback

' JellyfinLanguage: ISO 639-2 three-letter language codes used by Jellyfin
' Only includes languages supported by Roku OS locales
enum JellyfinLanguage
  ENGLISH = "eng"
  SPANISH = "spa"
  PORTUGUESE = "por"
  FRENCH = "fra"
  GERMAN = "deu"
  ITALIAN = "ita"
end enum

' The web client's "Original language" audio preference (Jellyfin 12.0+). The server stores it
' in AudioLanguagePreference in place of a language code; it means "the item's OriginalLanguage".
const ORIGINAL_LANGUAGE_PREFERENCE = "originallanguage"

' wantsOriginalAudio: True when the viewer's audio preference is "Original language": the web
' client's choice, not overridden by a JellyRock Roku or custom language.
'
' @param {object} userSettings - JellyfinUserSettings node (JellyRock settings)
' @param {object} userConfig - JellyfinUserConfiguration node (web client settings)
' @returns {boolean}
function wantsOriginalAudio(userSettings as object, userConfig as object) as boolean
  if isValid(userSettings) and isValid(userSettings.playbackAudioLanguagePreference)
    override = userSettings.playbackAudioLanguagePreference
    if override = "roku" or override = "custom" then return false
  end if
  if not isValid(userConfig) or not isValid(userConfig.audioLanguagePreference) then return false
  return LCase(userConfig.audioLanguagePreference) = ORIGINAL_LANGUAGE_PREFERENCE
end function

' needsSeriesOriginalLanguage: True when an episode's track selection needs its series'
' OriginalLanguage: the viewer wants the original audio and the episode has none of its own.
' The server falls back to the series itself (Episode.GetInheritedOriginalLanguage), but sends
' only the item's own value, and not every metadata provider sets one on episodes. ItemMetaData()
' fetches it; render-thread callers, which cannot, leave the track to LoadVideoContentTask.
'
' @param {object} item - JellyfinBaseItem
' @param {object} userSettings - JellyfinUserSettings node (JellyRock settings)
' @param {object} userConfig - JellyfinUserConfiguration node (web client settings)
' @returns {boolean}
function needsSeriesOriginalLanguage(item as object, userSettings as object, userConfig as object) as boolean
  if not isValid(item) or (item.type ?? "") <> "Episode" then return false
  if isValidAndNotEmpty(item.originalLanguage) or not isValidAndNotEmpty(item.seriesId) then return false
  return wantsOriginalAudio(userSettings, userConfig)
end function

' matchingLanguageCodes: The stream language codes that count as a match for a preference.
'
' Exact codes win: when any stream carries the preferred code itself (case-insensitively), only
' that code matches, so a "pob" preference keeps its Brazilian track over a "por" one. Only when
' none does are codes for the same language in another form accepted (languagesMatch()). That is
' how a "fre" preference finds a "fra" track, and an OriginalLanguage of "ko" a "kor" one.
'
' Compute it once over ALL the candidate streams, not per filtered subset, so a subset without
' the exact code cannot fall back to a same-language code the full list would have ruled out.
'
' @param {object} codes - Array of stream language codes (entries may be invalid or empty)
' @param {string} preference - Preferred language code, or "" for none
' @returns {object} - AssocArray set of lowercased matching codes; empty when preference is ""
function matchingLanguageCodes(codes as object, preference as string) as object
  matches = {}
  if preference = "" then return matches

  exact = LCase(preference)
  for each code in codes
    if isValid(code) and LCase(code) = exact
      matches[exact] = true
      return matches
    end if
  end for

  for each code in codes
    if languagesMatch(code, preference) then matches[LCase(code)] = true
  end for
  return matches
end function

' streamLanguageCodes: The Language of each stream of one type, for matchingLanguageCodes().
'
' @param {object} streams - Array of MediaStream AAs
' @param {string} streamType - "audio" or "subtitle" (case-insensitive)
' @returns {object} - Array of language codes (entries may be invalid)
function streamLanguageCodes(streams as object, streamType as string) as object
  codes = []
  for each stream in streams
    if isValid(stream.Type) and LCase(stream.Type) = streamType then codes.push(stream.Language)
  end for
  return codes
end function

' isLanguageMatch: Whether a stream language is in a matchingLanguageCodes() set.
'
' @param {object} matches - Set from matchingLanguageCodes()
' @param {dynamic} code - Stream language code
' @returns {boolean}
function isLanguageMatch(matches as object, code as dynamic) as boolean
  return isValidAndNotEmpty(code) and matches.doesExist(LCase(code))
end function

' selectAudioStreamIndex: The audio stream to auto-select for the viewer: their "play default
' track" and language preferences resolved, then findBestAudioStreamIndex().
'
' @param {object} streams - Array of MediaStream AAs (all types, or audio only)
' @param {object} userSettings - JellyfinUserSettings node (JellyRock settings)
' @param {object} userConfig - JellyfinUserConfiguration node (web client settings)
' @param {string} [originalLanguage=""] - The item's OriginalLanguage (JellyfinBaseItem.originalLanguage)
' @returns {integer} - Jellyfin index of the audio stream, or 0 if none
function selectAudioStreamIndex(streams as dynamic, userSettings as object, userConfig as object, originalLanguage = "" as string) as integer
  playDefault = resolvePlayDefaultAudioTrack(userSettings, userConfig)
  language = resolveAudioLanguagePreference(userSettings, userConfig, originalLanguage)
  return findBestAudioStreamIndex(streams, playDefault, language, invalid, wantsOriginalAudio(userSettings, userConfig))
end function

' resolvePlayDefaultAudioTrack: Resolves the playDefaultAudioTrack setting value
'
' Checks JellyRock override setting first, then falls back to web client setting.
' Ensures a valid boolean is always returned.
'
' @param {object} userSettings - JellyfinUserSettings node (JellyRock settings)
' @param {object} userConfig - JellyfinUserConfiguration node (web client settings)
' @returns {boolean} - Resolved playDefaultAudioTrack value (guaranteed boolean)
function resolvePlayDefaultAudioTrack(userSettings as object, userConfig as object) as boolean
  ' Default to true if we can't determine the value
  defaultValue = true

  ' Try to get web client setting
  if isValid(userConfig) and isValid(userConfig.playDefaultAudioTrack)
    ' Ensure it's actually a boolean before using it
    valueType = Type(userConfig.playDefaultAudioTrack)
    if valueType = "roBoolean" or valueType = "Boolean"
      defaultValue = userConfig.playDefaultAudioTrack
    end if
  end if

  ' Check for JellyRock override setting
  if isValid(userSettings) and isValid(userSettings.playbackPlayDefaultAudioTrack) and userSettings.playbackPlayDefaultAudioTrack <> ""
    if userSettings.playbackPlayDefaultAudioTrack = "enabled"
      return true
    else if userSettings.playbackPlayDefaultAudioTrack = "disabled"
      return false
      ' else "webclient" or other - use web client setting
    end if
  end if

  return defaultValue
end function

' resolveAudioLanguagePreference: Resolves the audio language preference setting value
'
' Checks JellyRock override setting first, then falls back to web client setting.
' Returns a 3-letter ISO 639-2 language code (for example "eng", "jpn") or empty string.
'
' The web client's "Original language" choice (12.0+) resolves to the item's OriginalLanguage,
' which is ISO 639-1 ("ko"), or to "" when the item has none. Pair the result with
' wantsOriginalAudio(): "" then means "no language rule", not "use the Roku locale".
'
' @param {object} userSettings - JellyfinUserSettings node (JellyRock settings)
' @param {object} userConfig - JellyfinUserConfiguration node (web client settings)
' @param {string} [originalLanguage=""] - The item's OriginalLanguage (JellyfinBaseItem.originalLanguage)
' @returns {string} - Resolved audio language preference (language code or empty string)
function resolveAudioLanguagePreference(userSettings as object, userConfig as object, originalLanguage = "" as string) as string
  ' Default to web client value
  defaultValue = ""
  if isValid(userConfig) and isValid(userConfig.audioLanguagePreference)
    defaultValue = userConfig.audioLanguagePreference
  end if
  if LCase(defaultValue) = ORIGINAL_LANGUAGE_PREFERENCE then defaultValue = originalLanguage

  ' Check for JellyRock override setting
  if isValid(userSettings) and isValid(userSettings.playbackAudioLanguagePreference) and userSettings.playbackAudioLanguagePreference <> ""
    if userSettings.playbackAudioLanguagePreference = "roku"
      ' Use Roku device OS language
      rokuLocale = m.global.device.locale
      if isValid(rokuLocale) and rokuLocale <> ""
        return mapRokuLocaleToJellyfinLanguage(rokuLocale)
      end if
      return ""
    else if userSettings.playbackAudioLanguagePreference = "custom"
      ' Use custom language code - validate it's a 3-letter code
      if isValid(userSettings.playbackAudioLanguageCustom)
        customCode = userSettings.playbackAudioLanguageCustom
        if Len(customCode) = 3
          return LCase(customCode)
        end if
      end if
      return ""
      ' else "webclient" or other - fall through to web client value
    end if
  end if

  return defaultValue
end function

' resolveSubtitleLanguagePreference: Resolves the subtitle language preference setting value
'
' Checks JellyRock override setting first, then falls back to web client setting.
' The "audio" option chains to resolveAudioLanguagePreference() so subtitle language
' follows the user's audio language override, including "Original language", which follows
' the item's OriginalLanguage, or applies no language rule when the item has none.
'
' @param {object} userSettings - JellyfinUserSettings node (JellyRock settings)
' @param {object} userConfig - JellyfinUserConfiguration node (web client settings)
' @param {string} [originalLanguage=""] - The item's OriginalLanguage (JellyfinBaseItem.originalLanguage)
' @returns {string} - Resolved subtitle language preference (language code or empty string)
function resolveSubtitleLanguagePreference(userSettings as object, userConfig as object, originalLanguage = "" as string) as string
  ' Default to web client subtitle language preference
  defaultValue = ""
  if isValid(userConfig) and isValid(userConfig.subtitleLanguagePreference)
    defaultValue = userConfig.subtitleLanguagePreference
  end if

  ' Check for JellyRock override setting
  if isValid(userSettings) and isValid(userSettings.playbackSubtitleLanguagePreference) and userSettings.playbackSubtitleLanguagePreference <> ""
    if userSettings.playbackSubtitleLanguagePreference = "audio"
      ' Follow the resolved audio language preference
      return resolveAudioLanguagePreference(userSettings, userConfig, originalLanguage)
    else if userSettings.playbackSubtitleLanguagePreference = "roku"
      ' Use Roku device OS language
      rokuLocale = m.global.device.locale
      if isValid(rokuLocale) and rokuLocale <> ""
        return mapRokuLocaleToJellyfinLanguage(rokuLocale)
      end if
      return ""
    else if userSettings.playbackSubtitleLanguagePreference = "custom"
      ' Use custom language code - validate it's a 3-letter code
      if isValid(userSettings.playbackSubtitleLanguageCustom)
        customCode = userSettings.playbackSubtitleLanguageCustom
        if Len(customCode) = 3
          return LCase(customCode)
        end if
      end if
      return ""
      ' else "webclient" or other - fall through to web client value
    end if
  end if

  return defaultValue
end function

' mapRokuLocaleToJellyfinLanguage: Converts Roku locale codes to Jellyfin ISO 639-2 language codes
'
' Maps Roku device locale (for example "en_US", "fr_CA") to Jellyfin's 3-letter language codes.
' Extracts base language from locale and maps to standard ISO 639-2 codes.
'
' Supported Roku locales:
' - en_US, en_GB, en_CA, en_AU: eng (English)
' - es_ES, es_MX: spa (Spanish)
' - pt_BR: por (Portuguese)
' - fr_CA: fra (French)
' - de_DE: deu (German)
' - it_IT: ita (Italian)
'
' @param {dynamic} rokuLocale - Roku locale string (for example "en_US", "fr_CA")
' @returns {string} - Jellyfin ISO 639-2 language code (for example "eng", "fra"), or empty string if not recognized
function mapRokuLocaleToJellyfinLanguage(rokuLocale as dynamic) as string
  if not isValid(rokuLocale) or rokuLocale = "" then return ""

  ' Extract base language (everything before the underscore)
  ' "en_US" becomes "en", "fr_CA" becomes "fr"
  baseLanguage = ""
  underscorePos = rokuLocale.inStr("_")
  if underscorePos > -1
    baseLanguage = LCase(Left(rokuLocale, underscorePos))
  else
    ' No underscore found - use entire string (shouldn't happen with valid Roku locales)
    baseLanguage = LCase(rokuLocale)
  end if

  ' Map base language to Jellyfin ISO 639-2 code
  if baseLanguage = "en"
    return JellyfinLanguage.ENGLISH
  else if baseLanguage = "es"
    return JellyfinLanguage.SPANISH
  else if baseLanguage = "pt"
    return JellyfinLanguage.PORTUGUESE
  else if baseLanguage = "fr"
    return JellyfinLanguage.FRENCH
  else if baseLanguage = "de"
    return JellyfinLanguage.GERMAN
  else if baseLanguage = "it"
    return JellyfinLanguage.ITALIAN
  end if

  ' Unknown language - return empty string
  return ""
end function

' isSelectedAudioStreamDirectPlayable: Whether the audio stream at jellyfinAudioIndex
' inside audioStreams is decodable by the current device.
'
' Used by mid-playback audio switching to decide whether Roku's native track-switch is
' viable, or whether a server reload is required (for example switching back to an 8ch track
' on a stereo device: Roku can't decode it natively, so a transcode reload is needed).
'
' @param {integer} jellyfinAudioIndex - Jellyfin index of the chosen audio stream
' @param {dynamic} audioStreams - Array of audio MediaStream objects (for example fullAudioData)
' @returns {boolean} - true if the chosen stream can be direct-played on this device
function isSelectedAudioStreamDirectPlayable(jellyfinAudioIndex as integer, audioStreams as dynamic) as boolean
  if not isValid(audioStreams) then return false
  selected = invalid
  for each stream in audioStreams
    if isValid(stream.index) and stream.index = jellyfinAudioIndex
      selected = stream
      exit for
    end if
  end for
  if not isValid(selected) then return false
  return isStreamDirectPlayable(selected, getDeviceAudioCapabilities())
end function

' isCommentaryAudioStream: Determine whether a stream is a commentary/secondary track.
'
' Detection: case-insensitive substring match for "commentary" in the stream Title.
' Used by findBestAudioStreamIndex() to deprioritize commentary tracks during auto-selection
' so a director's-commentary stereo track doesn't outrank the main multichannel track on a
' stereo device (issue #500).
'
' @param {object} stream - MediaStream object from Jellyfin metadata
' @returns {boolean} - true if the stream looks like a commentary track
function isCommentaryAudioStream(stream as object) as boolean
  if not isValid(stream) or not isValid(stream.Title) then return false
  return inStr(1, LCase(stream.Title), "commentary") > 0
end function

' findBestAudioStreamIndex: Primary function for selecting the best audio stream
'
' Selection priority when playDefault = true (Jellyfin: "Play default audio track regardless of language"):
'   1. Filter by IsDefault = true streams (ignore language preference)
'   2. If multiple IsDefault streams, use language preference as tiebreaker
'   3. If still multiple or no language match, apply hardware optimization
'   4. If no IsDefault streams exist, fall back to language preference, then hardware optimization
'
' Selection priority when playDefault = false:
'   1. Filter by language preference (completely ignore IsDefault flag)
'   2. If multiple language matches, apply hardware optimization
'   3. If no language matches, apply hardware optimization to all streams
'
' "Original language" (preferOriginal, playDefault = false): as the server does, a stream the
' file flags IsOriginal wins: among the language matches when the item's original language is
' known, among all streams when it is not. The server ignores IsOriginal when playDefault = true.
'
' Language matching: exact codes first, then the same language in another ISO form
' (matchingLanguageCodes()).
'
' Roku OS Language Fallback:
'   When preferredLanguage is blank/empty, automatically uses the Roku device's
'   OS language (from m.global.device.locale) as fallback for better
'   out-of-box experience (see issue #179). Not with preferOriginal: the viewer asked for the
'   original, and the locale is usually the dub, so no language rule applies instead.
'
' Hardware optimization:
'   - Prefer streams matching device's max channel capability
'   - Among matches, prefer direct-playable codecs
'   - Fall back intelligently based on channel counts
'
' @param {dynamic} streams - Array of media streams from Jellyfin metadata
' @param {dynamic} playDefault - Boolean, if true use IsDefault flag and only use language as tiebreaker
' @param {dynamic} preferredLanguage - Language code in any ISO 639 form (for example "eng", "ko")
' @param {dynamic} deviceCapabilities - Optional: Device audio capabilities (for testing). If invalid, will detect automatically.
' @param {boolean} [preferOriginal=false] - The viewer wants the original audio (wantsOriginalAudio())
' @returns {integer} - Jellyfin index of best audio stream, or 0 if not found
function findBestAudioStreamIndex(streams as dynamic, playDefault as dynamic, preferredLanguage as dynamic, deviceCapabilities = invalid as dynamic, preferOriginal = false as boolean) as integer
  if not isValid(streams) or streams.Count() = 0 then return 0

  ' Get device audio capabilities (or use provided ones for testing)
  if not isValid(deviceCapabilities)
    deviceCapabilities = getDeviceAudioCapabilities()
  end if

  ' Apply Roku OS language fallback when web client language preference is blank
  ' This provides better out-of-box experience for new users (issue #179)
  effectiveLanguage = preferredLanguage
  if not isValid(effectiveLanguage) then effectiveLanguage = ""
  if effectiveLanguage = "" and not preferOriginal
    ' Get Roku OS locale from global device node
    rokuLocale = m.global.device.locale
    if isValid(rokuLocale) and rokuLocale <> ""
      effectiveLanguage = mapRokuLocaleToJellyfinLanguage(rokuLocale)
    end if
  end if

  ' Collect all audio streams with valid index fields
  ' Streams without index cannot be selected, so filter them out early
  audioStreams = []
  for i = 0 to streams.Count() - 1
    if LCase(streams[i].Type) = "audio" and isValid(streams[i].index)
      audioStreams.push(streams[i])
    end if
  end for

  if audioStreams.Count() = 0 then return 0
  if audioStreams.Count() = 1
    ' Only one audio track - return its index
    if isValid(audioStreams[0].index)
      return audioStreams[0].index
    end if
    return 0
  end if

  ' Deprioritize commentary tracks (issue #500): when at least one non-commentary track
  ' exists, hide commentary entries from all subsequent selection logic so a stereo
  ' commentary track can't outrank the multichannel main track on a stereo device.
  ' Files with only commentary tracks fall through with the original list.
  mainStreams = []
  for i = 0 to audioStreams.Count() - 1
    if not isCommentaryAudioStream(audioStreams[i])
      mainStreams.push(audioStreams[i])
    end if
  end for
  if mainStreams.Count() > 0
    audioStreams = mainStreams
    if audioStreams.Count() = 1 and isValid(audioStreams[0].index)
      return audioStreams[0].index
    end if
  end if

  ' Multiple audio tracks - apply selection logic
  languageMatches = matchingLanguageCodes(streamLanguageCodes(audioStreams, "audio"), effectiveLanguage)

  ' BRANCH 1: "Play default track" setting is enabled
  ' Use IsDefault flag as primary filter, language as tiebreaker only
  if isValid(playDefault) and playDefault = true
    defaultStreams = []
    for i = 0 to audioStreams.Count() - 1
      if audioStreams[i].IsDefault = true
        defaultStreams.push(audioStreams[i])
      end if
    end for

    ' If we found IsDefault streams, process them
    if defaultStreams.Count() > 0
      ' Only one IsDefault stream - use hardware optimization on it
      if defaultStreams.Count() = 1
        bestStream = selectBestStreamByHardware(defaultStreams, deviceCapabilities)
        if isValid(bestStream) and isValid(bestStream.index)
          return bestStream.index
        end if
      else
        ' Multiple IsDefault streams - try language preference as tiebreaker
        if effectiveLanguage <> ""
          languageMatchedDefaults = []
          for i = 0 to defaultStreams.Count() - 1
            if isLanguageMatch(languageMatches, defaultStreams[i].Language)
              languageMatchedDefaults.push(defaultStreams[i])
            end if
          end for

          ' If language matched some IsDefault streams, use hardware optimization on them
          if languageMatchedDefaults.Count() > 0
            bestStream = selectBestStreamByHardware(languageMatchedDefaults, deviceCapabilities)
            if isValid(bestStream) and isValid(bestStream.index)
              return bestStream.index
            end if
          end if
        end if

        ' Language didn't match or not set - use hardware optimization on all IsDefault streams
        bestStream = selectBestStreamByHardware(defaultStreams, deviceCapabilities)
        if isValid(bestStream) and isValid(bestStream.index)
          return bestStream.index
        end if
      end if
    end if

    ' No IsDefault streams found - fall through to language preference logic
  end if

  ' BRANCH 2: Either playDefault = false OR playDefault = true but no IsDefault streams found
  ' Use language preference as primary filter (completely ignore IsDefault flag)
  preferOriginalFlag = preferOriginal and not (isValid(playDefault) and playDefault = true)
  if effectiveLanguage <> ""
    languageMatchedStreams = []
    for i = 0 to audioStreams.Count() - 1
      if isLanguageMatch(languageMatches, audioStreams[i].Language)
        languageMatchedStreams.push(audioStreams[i])
      end if
    end for

    ' If we found language matches, apply hardware optimization
    if languageMatchedStreams.Count() > 0
      if preferOriginalFlag
        originalStreams = originalAudioStreams(languageMatchedStreams)
        if originalStreams.Count() > 0 then languageMatchedStreams = originalStreams
      end if
      bestStream = selectBestStreamByHardware(languageMatchedStreams, deviceCapabilities)
      if isValid(bestStream) and isValid(bestStream.index)
        return bestStream.index
      end if
    end if
  else if preferOriginalFlag
    ' Original language unknown: the server still takes a stream flagged IsOriginal
    originalStreams = originalAudioStreams(audioStreams)
    if originalStreams.Count() > 0
      bestStream = selectBestStreamByHardware(originalStreams, deviceCapabilities)
      if isValid(bestStream) and isValid(bestStream.index)
        return bestStream.index
      end if
    end if
  end if

  ' FALLBACK: No matches found - apply hardware optimization to all streams
  bestStream = selectBestStreamByHardware(audioStreams, deviceCapabilities)
  if isValid(bestStream) and isValid(bestStream.index)
    return bestStream.index
  end if

  ' FINAL FALLBACK: Return first audio stream
  if isValid(audioStreams[0].index)
    return audioStreams[0].index
  end if

  return 0
end function

' originalAudioStreams: The streams the file flags as the original audio (MediaStream.IsOriginal).
'
' @param {object} audioStreams - Array of audio MediaStream AAs
' @returns {object} - Array of the flagged streams, possibly empty
function originalAudioStreams(audioStreams as object) as object
  originals = []
  for each stream in audioStreams
    if stream.IsOriginal = true then originals.push(stream)
  end for
  return originals
end function

' selectBestStreamByHardware: Selects the best audio stream based on device capabilities
'
' Priority:
' 1. Prefer streams matching device's max channel capability (for quality)
' 2. Among matching streams, prefer direct-playable codecs
' 3. Fall back intelligently based on channel counts
'
' @param {object} audioStreams - Array of audio stream objects
' @param {object} deviceCapabilities - Device capability info from getDeviceAudioCapabilities()
' @returns {dynamic} - Best matching audio stream object, or invalid
function selectBestStreamByHardware(audioStreams as object, deviceCapabilities as object) as dynamic
  if audioStreams.Count() = 0 then return invalid
  if audioStreams.Count() = 1 then return audioStreams[0]

  maxChannels = deviceCapabilities.maxChannels
  supports8Channel = deviceCapabilities.supports8Channel

  ' Find streams matching our max channel capability
  channelMatchingStreams = []
  for each stream in audioStreams
    if isValid(stream.channels) and stream.channels = maxChannels
      channelMatchingStreams.push(stream)
    end if
  end for

  ' Case 1: We have streams matching our max channel capability
  if channelMatchingStreams.Count() > 0
    ' If only one, verify it's actually playable
    if channelMatchingStreams.Count() = 1
      stream = channelMatchingStreams[0]

      ' Special handling for 8-channel: requires passthrough, Roku can't natively decode
      if maxChannels = 8
        if supports8Channel and isStreamDirectPlayable(stream, deviceCapabilities)
          return stream
        else
          ' 8-channel not supported via passthrough - fall back to 6-channel
          sixChannelStream = findDirectPlayableStreamByChannelCount(audioStreams, 6, deviceCapabilities)
          if isValid(sixChannelStream) then return sixChannelStream
          ' No direct-playable 6-channel - look for stereo
          stereoStream = findDirectPlayableStreamByChannelCount(audioStreams, 2, deviceCapabilities)
          if isValid(stereoStream) then return stereoStream
          ' No good options - return the 8-channel anyway (will transcode)
          return stream
        end if
      else
        ' 6-channel or stereo - verify it's direct playable
        if isStreamDirectPlayable(stream, deviceCapabilities)
          return stream
        end if
        ' Not direct playable but it's our only match - return it anyway
        return stream
      end if
    end if

    ' Multiple streams match our max channels - pick first direct-playable one
    for each stream in channelMatchingStreams
      if isStreamDirectPlayable(stream, deviceCapabilities)
        return stream
      end if
    end for

    ' None are direct-playable - return first one (will transcode)
    return channelMatchingStreams[0]
  end if

  ' Case 2: No exact channel match - apply fallback logic
  if maxChannels = 2
    ' Device only supports stereo - look for < 8 channel streams to make transcoding easier
    for each stream in audioStreams
      if isValid(stream.channels) and stream.channels < 8
        return stream
      end if
    end for
    ' All streams are 8-channel - return first one
    return audioStreams[0]
  else if maxChannels = 6
    ' Device supports 5.1 - look for 8-channel to preserve surround (will transcode to 5.1)
    for each stream in audioStreams
      if isValid(stream.channels) and stream.channels = 8
        return stream
      end if
    end for
    ' No 8-channel found - return first stream
    return audioStreams[0]
  else if maxChannels = 8
    ' Device supports 7.1 passthrough - look for 6-channel alternative if no 8-channel works
    sixChannelStream = findDirectPlayableStreamByChannelCount(audioStreams, 6, deviceCapabilities)
    if isValid(sixChannelStream) then return sixChannelStream
    ' No 6-channel found - return first stream
    return audioStreams[0]
  end if

  ' Final fallback
  return audioStreams[0]
end function

' getDeviceAudioCapabilities: Detects device audio codec and channel support
'
' Strategy:
' - Use combined check (no PassThru) for 6-channel and below (safe, checks both)
' - Always verify 8-channel with PassThru: 1 (combined check can lie)
' - Roku max native decode is 6 channels (varies by model)
' - 8-channel requires HDMI passthrough to receiver/soundbar
'
' @returns {object} - AssocArray with:
'   maxChannels (integer: 2, 6, or 8) - Maximum supported audio channels
'   supports8Channel (boolean) - True if HDMI passthrough supports 8-channel audio
function getDeviceAudioCapabilities() as object
  di = CreateObject("roDeviceInfo")

  audioCodecs = ["aac", "ac3", "dts", "eac3"]
  audioChannels = [6, 2]
  maxChannels = 2 ' Default to stereo
  supports8Channel = false

  ' Check combined capability (Roku + HDMI) for 6-channel and below
  ' Omitting PassThru parameter checks both device and HDMI receiver
  ' This is safe for 6-channel and below
  for each codec in audioCodecs
    for each channelCount in audioChannels
      if di.CanDecodeAudio({ Codec: codec, ChCnt: channelCount }).Result
        maxChannels = channelCount
        exit for
      end if
    end for
    if maxChannels > 2 then exit for
  end for

  ' Check for 8-channel HDMI passthrough
  ' CRITICAL: Always use PassThru: 1 for 8-channel verification
  ' The combined check (no PassThru) can lie about 8-channel support
  ' Roku devices cannot natively decode 8-channel audio (max is 6)
  ' PassThru: 1 checks only the HDMI device capability (receiver/soundbar)
  for each codec in audioCodecs
    if di.CanDecodeAudio({ Codec: codec, ChCnt: 8, PassThru: 1 }).Result
      supports8Channel = true
      maxChannels = 8
      exit for
    end if
  end for

  return {
    maxChannels: maxChannels,
    supports8Channel: supports8Channel
  }
end function

' isStreamDirectPlayable: Checks if an audio stream can be directly played by the device
'
' Answers the question the device profile answers (getActualCodecSupport, plus the
' rules around it in getCodecProfiles), so a mid-play switch goes native exactly where
' the server would have direct-played the track, and auto-pick favors those tracks.
' Asking Roku its own way here is what let a switch go native onto a path the server
' never chooses: DTS 7.1, which failed to start, and TrueHD, which buffers forever.
'
' @param {object} stream - Audio stream object with codec and channels fields
' @param {object} deviceCapabilities - Device capability info
' @returns {boolean} - True if stream can be direct played
function isStreamDirectPlayable(stream as object, deviceCapabilities as object) as boolean
  if not isValid(stream) then return false
  if not isValid(stream.codec) then return false
  if not isValid(stream.channels) then return false

  ' Rules that hold whatever the hardware answers, so mock mode applies them too.
  ' TrueHD is never direct-played (see canDecodeAudioInContainer), nor are DTS-HD and DTS:X
  ' (see getActualCodecSupport), and DTS only up to DTS_MAX_DIRECT_PLAY_CHANNELS: Roku says
  ' yes to them all, and they fail natively. AAC in a profile the device profile refuses
  ' (getUnsupportedAacProfiles) is refused here too.
  codec = LCase(stream.codec)
  if codec = "truehd" or codec = "dtshd" or codec = "dtsx" then return false
  if codec = "dts" and stream.channels > DTS_MAX_DIRECT_PLAY_CHANNELS then return false
  if codec = "aac" and isValid(stream.profile)
    for each unsupported in getUnsupportedAacProfiles()
      if LCase(stream.profile) = LCase(unsupported) then return false
    end for
  end if

  ' "Decode Multichannel Audio" off: the profile caps these codecs at 2ch so the server
  ' handles multichannel, and a native switch must not bypass that choice.
  if stream.channels > 2 and arrayHasValue(getStereoOutputCodecs(), codec)
    if not m.global.user.settings.playbackDecodeMultichannelAudio then return false
  end if

  ' Testing mode: If isMock flag is present, trust the capabilities without real hardware checks
  ' This allows deterministic unit testing on any hardware configuration
  if isValid(deviceCapabilities.isMock) and deviceCapabilities.isMock = true
    ' For mock mode, assume stream is playable if channels are within device max
    if stream.channels = 8
      return deviceCapabilities.supports8Channel
    else if stream.channels = 6
      return deviceCapabilities.maxChannels >= 6
    else if stream.channels = 2
      return deviceCapabilities.maxChannels >= 2
    end if
    return true
  end if

  return canDeviceDecodeCodec(codec, stream.channels)
end function

' findDirectPlayableStreamByChannelCount: Finds a direct-playable stream with specific channel count
'
' @param {object} audioStreams - Array of audio streams
' @param {integer} targetChannels - Desired channel count (2, 6, or 8)
' @param {object} deviceCapabilities - Device capability info
' @returns {dynamic} - Matching stream or invalid
function findDirectPlayableStreamByChannelCount(audioStreams as object, targetChannels as integer, deviceCapabilities as object) as dynamic
  if not isValid(audioStreams) then return invalid

  targetChannelStreams = []

  ' Find all streams with target channel count
  for each stream in audioStreams
    if isValid(stream.channels) and stream.channels = targetChannels
      targetChannelStreams.push(stream)
    end if
  end for

  ' Check each for direct playability
  for each stream in targetChannelStreams
    if isStreamDirectPlayable(stream, deviceCapabilities)
      return stream
    end if
  end for

  return invalid
end function

' findDefaultSubtitleStreamIndex: Determine which subtitle stream will be auto-selected during playback
' Mirrors the selection logic in defaultSubtitleTrackFromVid() and sortSubtitles() (subtitles.bs)
' for use on the render thread without requiring playback-specific dependencies (buildURL, etc.)
'
' Selection logic (by subtitleMode):
'   "none":       No subtitles
'   "default":    First forced or default subtitle matching preferred language
'   "always":     First non-forced subtitle matching preferred language; fallback to forced/default
'   "onlyforced": First forced subtitle matching preferred language
'   "smart":      First non-forced subtitle when audio language differs from preference; fallback to forced/default
'
' Priority ordering: forced > default > normal; preferred language first within each category
' Text subtitles are tried first; non-text only used when playbackSubsOnlyText is false
'
' @param {object} mediaStreams - Array of MediaStream objects from Jellyfin API (all types: video, audio, subtitle)
' @param {integer} selectedAudioStreamIndex - Server-side index of the selected audio stream (for Smart mode)
' @param {string} [originalLanguage=""] - The item's OriginalLanguage (JellyfinBaseItem.originalLanguage)
' @returns {integer} - Server-side stream index of the selected subtitle, or -1 if none
function findDefaultSubtitleStreamIndex(mediaStreams as object, selectedAudioStreamIndex as integer, originalLanguage = "" as string) as integer
  if not isValid(mediaStreams) or mediaStreams.Count() = 0 then return -1

  ' ONE rendezvous to get user node
  localUser = m.global.user
  subtitleMode = isValid(localUser.config.subtitleMode) ? LCase(localUser.config.subtitleMode) : ""

  if subtitleMode = "none" then return -1

  prefLang = resolveSubtitleLanguagePreference(localUser.settings, localUser.config, originalLanguage)
  languageMatches = matchingLanguageCodes(streamLanguageCodes(mediaStreams, "subtitle"), prefLang)

  ' Sort subtitles into priority buckets: forced > default > normal
  ' Within each bucket, preferred language entries come first
  ' Mirrors sortSubtitles() in subtitles.bs without URL building
  forced = []
  default = []
  normal = []
  textForced = []
  textDefault = []
  textNormal = []

  for each stream in mediaStreams
    if not isValid(stream.Type) or LCase(stream.Type) <> "subtitle" then continue for
    if not isValid(stream.index) then continue for

    isText = stream.IsTextSubtitleStream ?? false
    isForced = stream.IsForced ?? false
    isDefault = stream.IsDefault ?? false
    lang = stream.Language ?? ""

    entry = { lang: lang, isForced: isForced, isDefault: isDefault, isText: isText, index: stream.index }

    if isForced
      if isLanguageMatch(languageMatches, lang)
        forced.unshift(entry)
        if isText then textForced.unshift(entry)
      else
        forced.push(entry)
        if isText then textForced.push(entry)
      end if
    else if isDefault
      if isLanguageMatch(languageMatches, lang)
        default.unshift(entry)
        if isText then textDefault.unshift(entry)
      else
        default.push(entry)
        if isText then textDefault.push(entry)
      end if
    else
      if isLanguageMatch(languageMatches, lang)
        normal.unshift(entry)
        if isText then textNormal.unshift(entry)
      else
        normal.push(entry)
        if isText then textNormal.push(entry)
      end if
    end if
  end for

  ' Merge buckets: forced > default > normal (same order as sortSubtitles)
  default.append(normal)
  forced.append(default)
  allSorted = forced

  textDefault.append(textNormal)
  textForced.append(textDefault)
  textSorted = textForced

  ' Find selected audio language (for Smart mode)
  selectedAudioLanguage = ""
  for each stream in mediaStreams
    if isValid(stream.index) and stream.index = selectedAudioStreamIndex and isValid(stream.Type) and LCase(stream.Type) = "audio"
      selectedAudioLanguage = stream.Language ?? ""
      exit for
    end if
  end for

  ' Try text subtitles first (same as defaultSubtitleTrackFromVid)
  result = selectSubtitleByMode(textSorted, subtitleMode, prefLang, selectedAudioLanguage, true, languageMatches)
  if result >= 0 then return result

  ' If not text-only mode, try all subtitles
  if not localUser.settings.playbackSubsOnlyText
    result = selectSubtitleByMode(allSorted, subtitleMode, prefLang, selectedAudioLanguage, false, languageMatches)
    if result >= 0 then return result
  end if

  return -1
end function

' selectSubtitleByMode: Apply subtitle mode selection logic to sorted subtitle entries
' Mirrors defaultSubtitleTrack() in subtitles.bs
'
' @param {object} sortedEntries - Array of sorted subtitle entries from findDefaultSubtitleStreamIndex
' @param {string} subtitleMode - Lowercase subtitle mode ("default", "always", "onlyforced", "smart")
' @param {string} prefLang - Preferred subtitle language (ISO 639-2 code, for example "eng")
' @param {string} selectedAudioLanguage - Language of the selected audio stream
' @param {boolean} requireText - If true, only consider text subtitle entries
' @param {object} languageMatches - matchingLanguageCodes() for prefLang over ALL the item's subtitles
' @returns {integer} - Server-side stream index of the selected subtitle, or -1 if none
function selectSubtitleByMode(sortedEntries as object, subtitleMode as string, prefLang as string, selectedAudioLanguage as string, requireText as boolean, languageMatches as object) as integer
  if not isValid(sortedEntries) or sortedEntries.Count() = 0 then return -1

  ' Smart mode is only active when audio language differs from subtitle preference
  allowSmartMode = false
  if selectedAudioLanguage <> ""
    allowSmartMode = not languagesMatch(selectedAudioLanguage, prefLang)
  end if

  ' Primary pass: match by mode with language filter
  for each entry in sortedEntries
    if requireText and not entry.isText then continue for

    ' Language match check
    languageMatch = true
    if prefLang <> ""
      languageMatch = isLanguageMatch(languageMatches, entry.lang)
    end if

    if languageMatch
      if subtitleMode = "default" and (entry.isForced or entry.isDefault)
        return entry.index
      else if subtitleMode = "always" and not entry.isForced
        return entry.index
      else if subtitleMode = "onlyforced" and entry.isForced
        return entry.index
      else if subtitleMode = "smart" and allowSmartMode and not entry.isForced
        return entry.index
      end if
    end if
  end for

  ' "always" fallback: if no full (non-forced) subtitles found, fall back to forced/default
  if subtitleMode = "always"
    for each entry in sortedEntries
      if requireText and not entry.isText then continue for
      if entry.isForced or entry.isDefault
        return entry.index
      end if
    end for
  end if

  ' "smart" fallback: fall back to forced/default (ignoring language preference)
  if subtitleMode = "smart" and allowSmartMode
    for each entry in sortedEntries
      if requireText and not entry.isText then continue for
      if entry.isForced or entry.isDefault
        return entry.index
      end if
    end for
  end if

  return -1
end function

' getTranscodeReasons: Extracts the transcode reason codes from a Jellyfin transcoding URL.
' The server embeds reasons as a comma-separated TranscodeReasons query parameter.
' Used to detect VideoLevelNotSupported so a direct-play fallback can be attempted.
'
' @param {string} url - The transcoding URL containing TranscodeReasons
' @returns {object} Array of reason strings, or empty array if param not present
function getTranscodeReasons(url as string) as object
  regex = CreateObject("roRegex", "[?&]TranscodeReasons=([^&]*)", "")
  match = regex.Match(url)

  if match.count() > 1
    return match[1].Split(",")
  end if

  return []
end function

' getDeviceVideoCapabilities: Detects device video resolution and HDR support
'
' Strategy:
' - Use cached m.global.device values for resolution (render-thread safe, no object creation)
' - Use canPlay4k() for 4K eligibility (HDCP 2.2 + HEVC decode check)
' - Use roDeviceInfo.GetDisplayProperties() for HDR format detection
'
' @param {dynamic} [mockCapabilities=invalid] - Optional mock for unit testing (must have isMock=true)
' @returns {object} - AssocArray with:
'   maxHeight (integer) - Maximum display height in pixels
'   maxWidth (integer) - Maximum display width in pixels
'   canDecode4k (boolean) - True if device can play 4K content
'   supportsHdr10 (boolean) - True if display supports HDR10
'   supportsDolbyVision (boolean) - True if display supports Dolby Vision
'   supportsHlg (boolean) - True if display supports HLG
function getDeviceVideoCapabilities(mockCapabilities = invalid as dynamic) as object
  ' Testing mode: return mock capabilities directly
  if isValid(mockCapabilities) and isValid(mockCapabilities.isMock) and mockCapabilities.isMock = true
    return mockCapabilities
  end if

  ' Production mode: detect from hardware
  maxHeight = 1080
  maxWidth = 1920
  if isValid(m.global) and isValid(m.global.device)
    if isValid(m.global.device.videoHeight) then maxHeight = m.global.device.videoHeight
    if isValid(m.global.device.videoWidth) then maxWidth = m.global.device.videoWidth
  end if

  canDecode4k = canPlay4k()

  supportsHdr10 = false
  supportsDolbyVision = false
  supportsHlg = false

  if canDecode4k
    di = CreateObject("roDeviceInfo")
    dp = di.GetDisplayProperties()
    supportsHdr10 = dp.Hdr10 = true
    supportsDolbyVision = dp.DolbyVision = true
    supportsHlg = dp.HLG = true
  end if

  return {
    maxHeight: maxHeight,
    maxWidth: maxWidth,
    canDecode4k: canDecode4k,
    supportsHdr10: supportsHdr10,
    supportsDolbyVision: supportsDolbyVision,
    supportsHlg: supportsHlg
  }
end function

' mediaSourceForId: The MediaSource a playback request is for.
'
' mediaSources[0] is only the server's default. A version picked in the Video dropdown sits
' elsewhere in the list, and from Jellyfin 12.0 the server moves the in-progress version to
' the front, so anything about the file being PLAYED (its runtime, bitrate, container or
' streams) has to be read from the source the request names (mediaSourceIndexForId).
'
' @param {dynamic} mediaSources - MediaSourceInfo array
' @param {dynamic} mediaSourceId - the id the request names ("" or invalid = the default)
' @return {dynamic} the matching source; mediaSources[0] when none matches; invalid when there
'                   are no sources
function mediaSourceForId(mediaSources as dynamic, mediaSourceId as dynamic) as dynamic
  if type(mediaSources) <> "roArray" or mediaSources.count() = 0 then return invalid
  index = mediaSourceIndexForId(mediaSources, mediaSourceId)
  if index < 0 then index = 0
  return mediaSources[index]
end function

' mediaSourceIndexForId: Index of the MediaSource with this id.
'
' Case-insensitive, matching the server: it keys its own per-version lookup with
' StringComparer.OrdinalIgnoreCase and compares source ids with StringComparison
' .OrdinalIgnoreCase (MediaSourceManager), so it does not assume one spelling of a GUID
' and neither should any caller that matches one.
'
' @param {dynamic} mediaSources - MediaSourceInfo array
' @param {dynamic} mediaSourceId
' @return {integer} the index, or -1 when absent
function mediaSourceIndexForId(mediaSources as dynamic, mediaSourceId as dynamic) as integer
  if type(mediaSources) <> "roArray" or not isValidAndNotEmpty(mediaSourceId) then return -1
  wanted = LCase(mediaSourceId)
  for i = 0 to mediaSources.count() - 1
    source = mediaSources[i]
    if isValid(source) and isValidAndNotEmpty(source.Id) and LCase(source.Id) = wanted then return i
  end for
  return -1
end function

' findBestVideoSource: Selects the optimal MediaSource for the device's capabilities
'
' Scoring algorithm:
' 1. Resolution fit (primary): Prefer highest resolution at or below device max.
'    Sources exceeding device max receive a penalty (will likely transcode).
' 2. HDR match (secondary): If device supports HDR, prefer HDR sources.
'    If device does not support HDR, prefer SDR (avoids tone-mapping transcode).
' 3. Direct play (tertiary): Prefer sources the server says can direct play.
' 4. Bitrate (tiebreaker): Higher bitrate wins among otherwise-equal sources.
'
' @param {object} mediaSources - Array of MediaSource objects from Jellyfin API
' @param {dynamic} [deviceCapabilities=invalid] - Optional device capabilities (for testing)
' @returns {integer} - Array index of the best MediaSource (0-based)
function findBestVideoSource(mediaSources as dynamic, deviceCapabilities = invalid as dynamic) as integer
  if not isValid(mediaSources) or mediaSources.Count() = 0 then return 0
  if mediaSources.Count() = 1 then return 0

  caps = getDeviceVideoCapabilities(deviceCapabilities)

  bestIndex = 0
  bestScore = -99999

  for i = 0 to mediaSources.Count() - 1
    source = mediaSources[i]
    if not isValid(source) then continue for

    ' Find the first video stream in this source
    videoStream = invalid
    if isValid(source.MediaStreams)
      videoStream = getFirstVideoStream(source.MediaStreams)
    end if

    score = 0
    sourceHeight = 0
    sourceVideoRange = "SDR"

    if isValid(videoStream)
      if isValid(videoStream.Height) then sourceHeight = videoStream.Height
      if isValid(videoStream.VideoRange) then sourceVideoRange = videoStream.VideoRange
    end if

    ' --- Resolution fit (primary: up to 10000 points) ---
    ' Sources at or below device max get positive score proportional to resolution
    ' Sources above device max get a penalty
    if sourceHeight > 0
      if sourceHeight <= caps.maxHeight
        ' Higher resolution = higher score (within device capability)
        score += sourceHeight
      else
        ' Exceeds device max: penalize proportional to overshoot
        ' Still prefer a 4K source over garbage, but rank below fitting sources
        score += caps.maxHeight - (sourceHeight - caps.maxHeight)
      end if
    end if

    ' --- HDR match (secondary: up to 500 points) ---
    isHdrSource = LCase(sourceVideoRange) <> "sdr"
    deviceSupportsHdr = caps.supportsHdr10 or caps.supportsDolbyVision or caps.supportsHlg

    if isHdrSource
      if deviceSupportsHdr
        ' Device supports HDR and source is HDR: bonus
        score += 500
      else
        ' Device does not support HDR: penalty (tone-mapping transcode)
        score -= 500
      end if
    end if

    ' --- Direct play (tertiary: 200 points) ---
    if isValid(source.SupportsDirectPlay) and source.SupportsDirectPlay = true
      score += 200
    end if

    ' --- Bitrate tiebreaker (up to ~50 points, normalized) ---
    if isValid(source.Bitrate) and source.Bitrate > 0
      ' Normalize bitrate to a small range so it only breaks ties
      ' Typical bitrates: 5Mbps (5000000) to 80Mbps (80000000)
      ' Divide by 1000000 to get 5-80 range
      score += int(source.Bitrate / 1000000)
    end if

    if score > bestScore
      bestScore = score
      bestIndex = i
    end if
  end for

  return bestIndex
end function

' findBestVideoSourceAmongIds: Best source for this device among a NAMED SUBSET of an item's versions.
'
' The tie-break half of versionResume.chooseResumeSource(): that module decides which
' versions are at the same place and so compete on quality, but it is pure by contract and
' has no device knowledge, so it hands the tie back as ids. Scoring runs over only those
' versions, because the best of a subset is not the best overall: a 4K version nobody has
' started must not win a tie between two the viewer is actually partway through.
'
' @param {dynamic} mediaSources - the item's full MediaSource array
' @param {dynamic} sourceIds - ids to choose between (from chooseResumeSource().tiedIds)
' @param {dynamic} [deviceCapabilities=invalid] - Optional device capabilities (for testing)
' @returns {string} - the winning source id, or "" when none of the ids name a source
function findBestVideoSourceAmongIds(mediaSources as dynamic, sourceIds as dynamic, deviceCapabilities = invalid as dynamic) as string
  if not isValid(mediaSources) or type(sourceIds) <> "roArray" or sourceIds.Count() = 0 then return ""

  candidates = []
  for each sourceId in sourceIds
    index = mediaSourceIndexForId(mediaSources, sourceId)
    if index >= 0 then candidates.push(mediaSources[index])
  end for

  if candidates.Count() = 0 then return ""

  best = candidates[findBestVideoSource(candidates, deviceCapabilities)]
  if not isValid(best) or not isValidAndNotEmpty(best.Id) then return ""
  return best.Id
end function

' applyLiveDirectPlayFallback: Flags that a transcode retry is available for live streams
' attempting direct play. Live direct play may fail on Roku if the server overestimates
' compatibility. VideoPlayerView observes isTranscodeAvailable to retry with transcoding on error.
'
' @param {object} video - Video object to update
' @param {object} meta - Video metadata containing the live stream flag
sub applyLiveDirectPlayFallback(video as object, meta as object)
  if meta.live
    video.isTranscodeAvailable = true
  end if
end sub