source_utils_streamSelection.bs

import "pkg:/source/utils/config.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

' 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

' mapRokuLocaleToJellyfinLanguage: Converts Roku locale codes to Jellyfin ISO 639-2 language codes
'
' Maps Roku device locale (e.g., "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 (e.g., "en_US", "fr_CA")
' @returns {string} - Jellyfin ISO 639-2 language code (e.g., "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 (first 2 characters before underscore)
  ' "en_US" → "en", "fr_CA" → "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

' 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 → 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
'
' 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)
'
' 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 - Three-letter language code (e.g., "eng", "jpn")
' @param {dynamic} deviceCapabilities - Optional: Device audio capabilities (for testing). If invalid, will detect automatically.
' @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) 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(preferredLanguage) or preferredLanguage = ""
    ' 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

  ' Multiple audio tracks - apply selection logic

  ' 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 isValid(effectiveLanguage) and effectiveLanguage <> ""
          languageMatchedDefaults = []
          for i = 0 to defaultStreams.Count() - 1
            if isValid(defaultStreams[i].Language) and LCase(defaultStreams[i].Language) = LCase(effectiveLanguage)
              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)
  if isValid(effectiveLanguage) and effectiveLanguage <> ""
    languageMatchedStreams = []
    for i = 0 to audioStreams.Count() - 1
      if isValid(audioStreams[i].Language) and LCase(audioStreams[i].Language) = LCase(effectiveLanguage)
        languageMatchedStreams.push(audioStreams[i])
      end if
    end for

    ' If we found language matches, apply hardware optimization
    if languageMatchedStreams.Count() > 0
      bestStream = selectBestStreamByHardware(languageMatchedStreams, 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

' 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
'
' For 8-channel: MUST use PassThru: 1 (only HDMI passthrough, Roku can't decode 8-channel)
' For 6-channel and below: Use combined check (no PassThru) - checks both Roku and HDMI
'
' @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

  ' 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

  ' Production mode: Check actual hardware capabilities
  di = CreateObject("roDeviceInfo")

  ' For 8-channel audio, MUST verify with PassThru: 1
  ' Roku cannot natively decode 8-channel (max is 6)
  ' Combined check can lie about 8-channel support
  if stream.channels = 8
    if not deviceCapabilities.supports8Channel then return false
    result = di.CanDecodeAudio({
      Codec: LCase(stream.codec),
      ChCnt: stream.channels,
      PassThru: 1
    })
    return isValid(result) and result.Result = true
  end if

  ' For 6-channel and below, use combined check (Roku + HDMI)
  ' This is safe and efficient - checks if either can decode it
  result = di.CanDecodeAudio({
    Codec: LCase(stream.codec),
    ChCnt: stream.channels
  })

  return isValid(result) and result.Result = true
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