components_ItemGrid_LoadVideoContentTask.bs

import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/baseRequest.bs"
import "pkg:/source/api/image.bs"
import "pkg:/source/api/items.bs"
import "pkg:/source/api/userAuth.bs"
import "pkg:/source/constants/imageSize.bs"
import "pkg:/source/enums/SubtitleSelection.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/liveTv.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/nodeHelpers.bs"
import "pkg:/source/utils/session.bs"
import "pkg:/source/utils/streamSelection.bs"
import "pkg:/source/utils/subtitles.bs"
import "pkg:/source/utils/translate.bs"
import "pkg:/source/utils/trickplay.bs"

sub init()
  m.top.functionName = "loadItems"
  m.log = new log.Logger("LoadVideoContentTask")
end sub

sub loadItems()
  queueManager = m.global.queueManager

  ' Reset per-run fields in case task gets reused
  m.top.isIntro = false
  m.top.errorMsg = ""

  ' Only show preroll once per queue
  if queueManager.callFunc("isPrerollActive")
    ' Prerolls not allowed if we're resuming video
    if queueManager.callFunc("getCurrentItem").startingPoint = 0
      preRoll = GetIntroVideos(m.top.itemId)
      if isValid(preRoll) and preRoll.TotalRecordCount > 0 and isValid(preRoll.items[0])
        ' If an error is thrown in the Intros plugin, instead of passing the error they pass the entire rick roll music video.
        ' Bypass the music video and treat it as an error message
        if lcase(preRoll.items[0].name) <> "rick roll'd"
          queueManager.callFunc("push", queueManager.callFunc("getCurrentItem"))
          m.top.itemId = preRoll.items[0].id
          queueManager.callFunc("setPrerollStatus", false)
          m.top.isIntro = true
        end if
      end if
    end if
  end if

  id = m.top.itemId
  mediaSourceId = invalid
  if m.top.mediaSourceId <> "" then mediaSourceId = m.top.mediaSourceId
  audioStreamIdx = m.top.selectedAudioStreamIndex
  forceTranscoding = m.top.shouldForceTranscoding
  bypassDoviPreservation = m.top.shouldBypassDoviPreservation

  m.top.content = [LoadItems_VideoPlayer(id, mediaSourceId, audioStreamIdx, forceTranscoding, bypassDoviPreservation)]
end sub

function LoadItems_VideoPlayer(id as string, mediaSourceId as dynamic, audioStreamIdx = 1 as integer, forceTranscoding = false as boolean, bypassDoviPreservation = false as boolean) as dynamic

  video = {}
  video.id = id
  video.content = createObject("RoSGNode", "ContentNode")
  video.content.addField("trickplayMetadata", "assocarray", false)

  LoadItems_AddVideoContent(video, mediaSourceId, audioStreamIdx, forceTranscoding, bypassDoviPreservation)

  if not isValid(video.content)
    return invalid
  end if

  return video
end function

sub LoadItems_AddVideoContent(video as object, mediaSourceId as dynamic, audioStreamIdx = 1 as integer, forceTranscoding = false as boolean, bypassDoviPreservation = false as boolean)

  meta = ItemMetaData(video.id)
  if not isValid(meta)
    video.errorMsg = "Error loading metadata"
    video.content = invalid
    return
  end if

  queueManager = m.global.queueManager
  userSession = m.global.user
  userSettings = userSession.settings

  ' Cache mediaSourcesData.mediaSources once — accessed frequently below
  mediaSources = invalid
  if isValid(meta.mediaSourcesData) and isValidAndNotEmpty(meta.mediaSourcesData.mediaSources)
    mediaSources = meta.mediaSourcesData.mediaSources
  end if

  ' Select best video source if no specific source was requested
  if not isValid(mediaSourceId) and isValid(mediaSources) and mediaSources.Count() > 1
    bestSourceIndex = findBestVideoSource(mediaSources)
    mediaSourceId = mediaSources[bestSourceIndex].Id
    m.log.info("Auto-selected video source", "index", bestSourceIndex, "mediaSourceId", mediaSourceId)
  end if

  ' Re-determine audio stream index if no manual selection was made
  ' Task field = 0 means no manual selection (default value)
  ' Task field > 0 means user manually selected from MovieDetails/TVListDetails - preserve it
  if m.top.selectedAudioStreamIndex = 0
    ' Use the selected source's MediaStreams for audio selection when available
    selectedSourceStreams = invalid
    if isValid(mediaSourceId) and isValid(mediaSources)
      for each source in mediaSources
        if source.Id = mediaSourceId and isValid(source.MediaStreams)
          selectedSourceStreams = source.MediaStreams
          exit for
        end if
      end for
    end if
    if isValid(selectedSourceStreams)
      ' Resolve playDefaultAudioTrack setting (JellyRock override or web client)
      playDefault = resolvePlayDefaultAudioTrack(userSettings, userSession.config)
      audioStreamIdx = findBestAudioStreamIndex(selectedSourceStreams, playDefault, resolveAudioLanguagePreference(userSettings, userSession.config))
    else if isValid(mediaSources) and isValid(mediaSources[0].MediaStreams)
      playDefault = resolvePlayDefaultAudioTrack(userSettings, userSession.config)
      audioStreamIdx = findBestAudioStreamIndex(mediaSources[0].MediaStreams, playDefault, resolveAudioLanguagePreference(userSettings, userSession.config))
    else
      ' MediaStreams not available - keep existing behavior of using 0
      ' This should never happen since ItemMetaData() just fetched metadata
      audioStreamIdx = 0
    end if
    m.log.debug("Audio stream auto-selected", "audioStreamIdx", audioStreamIdx)
  else
    m.log.debug("Audio stream manually selected", "audioStreamIdx", audioStreamIdx)
  end if

  ' Early metadata extraction - MediaSources may not exist yet for Live TV
  if isValid(mediaSources) and isValidAndNotEmpty(mediaSources)
    ' Duration source priority for setting video.content.length (needed for seeking):
    '   1. mediaSource.RunTimeTicks  — file's probed duration (authoritative when available)
    '   2. Size × 8 / Bitrate        — actual file duration estimate (good for recordings
    '                                   where the file differs from the scheduled runtime)
    '   3. meta.runTimeTicks         — movie/episode metadata (full runtime; may not match
    '                                   a partial recording)
    ' MPEG-TS files have no duration header so #1 is often missing; #2 is ideal because
    ' it reflects the actual recorded bytes, not the program's scheduled length.
    effectiveLengthSeconds = 0
    durationSource = "none"
    if isValid(mediaSources[0].RunTimeTicks) and mediaSources[0].RunTimeTicks > 0
      effectiveLengthSeconds = int(mediaSources[0].RunTimeTicks / 10000000)
      durationSource = "mediaSource.RunTimeTicks"
    else if isValid(mediaSources[0].Size) and mediaSources[0].Size > 0 and isValid(mediaSources[0].Bitrate) and mediaSources[0].Bitrate > 0
      effectiveLengthSeconds = int((mediaSources[0].Size * 8) / mediaSources[0].Bitrate)
      durationSource = "size/bitrate"
    else if isValid(meta.runTimeTicks) and meta.runTimeTicks > 0
      effectiveLengthSeconds = int(meta.runTimeTicks / 10000000)
      durationSource = "meta.runTimeTicks"
    end if
    if effectiveLengthSeconds > 0
      video.content.length = effectiveLengthSeconds
    end if
    m.log.info("Duration resolved", "seconds", effectiveLengthSeconds, "source", durationSource)

    ' Find first video stream - MediaStreams[0] might be subtitle/audio
    if isValid(mediaSources[0].MediaStreams)
      videoStream = getFirstVideoStream(mediaSources[0].MediaStreams)
      if isValid(videoStream) and isValid(videoStream.Width) and isValid(videoStream.Height)
        video.MaxVideoDecodeResolution = [videoStream.Width, videoStream.Height]
      end if
    end if
  end if

  subtitleIdx = m.top.selectedSubtitleIndex
  videotype = LCase(meta.type)

  ' Title override for recordings, TvChannels, and Programs (TV guide entries)
  titleOverride = ""
  if videotype = "recording" or videotype = "tvchannel" or isValidAndNotEmpty(meta.channelId)
    if isValidAndNotEmpty(meta.episodeTitle)
      titleOverride = meta.episodeTitle
    else if videotype = "tvchannel" and isValid(meta.currentProgram) and isValidAndNotEmpty(meta.currentProgram.name)
      ' For TvChannel, prefer the currently-airing program's name over the channel name
      titleOverride = meta.currentProgram.name
    else
      titleOverride = meta.name
    end if
    if LCase(meta.type) = "program"
      video.id = meta.channelId
    else
      video.id = meta.id
    end if
  end if

  ' Only TvChannel and Program items are actual live streams. Movies and episodes
  ' recorded from live TV have meta.channelId set but are seekable files, not live.
  isLive = false
  if videotype = "tvchannel" or videotype = "program"
    isLive = true
  end if
  m.log.info("Live detection", "videotype", videotype, "isLive", isLive, "hasChannelId", isValidAndNotEmpty(meta.channelId))

  video.chapters = meta.chapters
  video.title = isValidAndNotEmpty(titleOverride) ? titleOverride : meta.title
  video.showID = meta.seriesId

  if shouldTreatAsEpisode(videotype, meta)
    video.content.contenttype = "episode"
    video.seasonNumber = meta.parentIndexNumber
    video.episodeNumber = meta.indexNumber
    video.episodeNumberEnd = meta.indexNumberEnd
  end if

  ' Set logo image using metadata (same logic as ItemDetails)
  ' All OSD logo images are sized to imageSize.LOGO_OSD — the Poster node in OSD.xml enforces
  ' this client-side via loadWidth/loadHeight with loadDisplayMode="noScale", so the API params and UI stay in sync.
  osdLogoSize = imageSize.LOGO_OSD ' { width: 500, height: 300 } — BrightScript requires local var for namespace field access
  ' Check for logoImageTag first (movie/item logo)
  if isValidAndNotEmpty(meta.logoImageTag)
    video.logoImage = ImageURL(meta.id, "Logo", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: meta.logoImageTag })
  else if isValidAndNotEmpty(meta.parentLogoImageTag) and isValidAndNotEmpty(meta.seriesId)
    ' Series logo for episodes
    video.logoImage = ImageURL(meta.seriesId, "Logo", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: meta.parentLogoImageTag })
  else if videotype = "movie" and isValidAndNotEmpty(meta.primaryImageTag)
    ' Movie with no logo — fall back to primary (poster) image
    video.logoImage = ImageURL(meta.id, "Primary", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: meta.primaryImageTag })
  else if videotype = "episode" or videotype = "recording"
    ' Episode/recording with no series logo — fall back to series primary poster, then episode thumbnail
    if isValidAndNotEmpty(meta.seriesPrimaryImageTag) and isValidAndNotEmpty(meta.seriesId)
      video.logoImage = ImageURL(meta.seriesId, "Primary", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: meta.seriesPrimaryImageTag })
    else if isValidAndNotEmpty(meta.primaryImageTag)
      video.logoImage = ImageURL(meta.id, "Primary", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: meta.primaryImageTag })
    end if
  else if videotype = "tvchannel"
    ' For live TV channels, prefer the currently-airing program's artwork over the channel icon.
    ' Priority: program logo → program primary (poster) → channel icon (fallback).
    currentProgram = meta.currentProgram
    if isValid(currentProgram)
      if isValidAndNotEmpty(currentProgram.logoImageTag)
        video.logoImage = ImageURL(currentProgram.id, "Logo", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: currentProgram.logoImageTag })
      else if isValidAndNotEmpty(currentProgram.primaryImageTag)
        video.logoImage = ImageURL(currentProgram.id, "Primary", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: currentProgram.primaryImageTag })
      else if isValidAndNotEmpty(meta.primaryImageTag)
        video.logoImage = ImageURL(meta.id, "Primary", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: meta.primaryImageTag })
      end if
    else if isValidAndNotEmpty(meta.primaryImageTag)
      video.logoImage = ImageURL(meta.id, "Primary", { maxHeight: osdLogoSize.height, maxWidth: osdLogoSize.width, quality: 90, tag: meta.primaryImageTag })
    end if
  end if

  if LCase(m.top.itemType) = "episode"
    if userSettings.playbackPlayNextEpisode = "enabled" or userSettings.playbackPlayNextEpisode = "webclient" and userSession.config.enableNextEpisodeAutoPlay
      addNextEpisodesToQueue(meta.seriesId)
    end if
  end if

  playbackPosition = 0!

  currentItem = queueManager.callFunc("getCurrentItem")

  if isValid(currentItem) and isValid(currentItem.startingPoint)
    playbackPosition = currentItem.startingPoint
  end if

  ' Determine final subtitleIdx BEFORE calling ItemPostPlaybackInfo to avoid duplicate API calls.
  ' defaultSubtitleTrackFromVid() only needs meta and audioStreamIdx, both available at this point.
  ' Pass mediaSourceId so subtitle selection uses the correct source's streams (not always mediaSources[0]).
  if subtitleIdx = SubtitleSelection.NOT_SET
    subtitleIdx = defaultSubtitleTrackFromVid(meta, audioStreamIdx, mediaSourceId)
  end if

  if isLive
    video.content.PlayStart = LIVE_EDGE_SEEK
  else
    video.content.PlayStart = int(playbackPosition / 10000000)
    m.log.debug("Playback position resolved", "startingPoint", playbackPosition, "PlayStart", video.content.PlayStart)
  end if

  ' Build complete trickplay config using utility function for fail-fast validation
  ' meta.trickplayData is the pre-extracted Trickplay AA from the transformer
  if isValidAndNotEmpty(meta.trickplayData)
    deviceWidth = m.global.device.videoWidth
    if not isValid(deviceWidth) or deviceWidth = 0
      deviceWidth = 1920 ' Default to FHD if not available
    end if

    trickplayConfig = trickplay.buildConfigFromMetadata(meta.trickplayData, video.id, deviceWidth, int(playbackPosition / 10000000))

    if isValid(trickplayConfig)
      video.content.trickplayMetadata = trickplayConfig
      m.log.info("Trickplay config created", "selectedWidth", trickplayConfig.width, "deviceWidth", deviceWidth, "initialPos", trickplayConfig.initialPosition)
    else
      m.log.warn("Failed to build valid trickplay config for video", video.id)
    end if
  end if

  if not isValid(mediaSourceId) then mediaSourceId = video.id
  if isLive then mediaSourceId = ""

  ' Call ItemPostPlaybackInfo ONCE with final subtitleIdx
  m.playbackInfo = ItemPostPlaybackInfo(video.id, mediaSourceId, audioStreamIdx, subtitleIdx, playbackPosition, meta, bypassDoviPreservation, forceTranscoding)
  if not isValid(m.playbackInfo)
    m.log.error("ItemPostPlaybackInfo returned invalid response")
    m.top.errorMsg = translate(translationKeys.ErrorThereWasAnErrorRetrievingPlayback)
    video.content = invalid
    return
  end if

  ' Surface structured server errors as user-friendly messages before inspecting MediaSources
  if isValidAndNotEmpty(m.playbackInfo.ErrorCode)
    errorCode = m.playbackInfo.ErrorCode
    m.log.error("PlaybackInfo returned error code", "errorCode", errorCode, "itemId", video.id)
    if errorCode = "NoCompatibleStream"
      m.top.errorMsg = translate(translationKeys.LabelNoCompatibleStreamsAreAvailableFor)
    else
      m.top.errorMsg = translate(translationKeys.ErrorTheServerWasUnableToStart) + " (" + errorCode + ")"
    end if
    video.content = invalid
    return
  end if

  m.log.debug("PlaybackInfo loaded", "mediaSourceCount", m.playbackInfo.MediaSources.Count())

  ' Call addSubtitlesToVideo ONCE after ItemPostPlaybackInfo
  addSubtitlesToVideo(video, meta)

  ' Set video.SelectedSubtitle based on final subtitleIdx
  video.SelectedSubtitle = subtitleIdx

  video.videoId = video.id
  video.mediaSourceId = mediaSourceId
  video.audioIndex = audioStreamIdx
  video.playbackInfo = m.playbackInfo

  video.PlaySessionId = m.playbackInfo.PlaySessionId

  if isLive
    video.content.StreamFormat = "hls"
  end if

  video.container = meta.container

  ' Pass JellyfinBaseItem node for OSD to read typed fields directly
  video.meta = meta

  ' Fetch media segments for non-live, non-intro content
  if not isLive and not m.top.isIntro
    segmentsResponse = GetMediaSegments(video.id)
    if isValid(segmentsResponse)
      transformer = JellyfinDataTransformer()
      transformer.populateMediaSegments(meta, segmentsResponse)
    end if
  end if

  ' All downstream code requires MediaSources[0] — fail fast if it's missing
  if not isValid(m.playbackInfo.MediaSources) or m.playbackInfo.MediaSources.Count() = 0 or not isValid(m.playbackInfo.MediaSources[0])
    video.errorMsg = "Error loading playback info: no valid media source returned"
    video.content = invalid
    return
  end if

  addAudioStreamsToVideo(video, meta, mediaSourceId)

  audioIndexList = []
  for each audioTrackEntry in video.fullAudioData
    audioIndexList.push(audioTrackEntry.index)
  end for
  m.log.debug("fullAudioData built", "count", video.fullAudioData.Count(), "jellyfinIndices", audioIndexList, "selectedAudioStreamIdx", audioStreamIdx)

  if isLive
    video.transcodeParams = {
      "MediaSourceId": m.playbackInfo.MediaSources[0].Id,
      "LiveStreamId": m.playbackInfo.MediaSources[0].LiveStreamId,
      "PlaySessionId": video.PlaySessionId
    }
  end if

  ' 'TODO: allow user selection of subtitle track before playback initiated, for now set to no subtitles
  video.isDirectPlaySupported = m.playbackInfo.MediaSources[0].SupportsDirectPlay

  ' For h264/hevc video, Roku spec states that it supports specfic encoding levels
  ' The device can decode content with a Higher Encoding level but may play it back with certain
  ' artifacts. If the user preference is set, and the only reason the server says we need to
  ' transcode is that the Encoding Level is not supported, then try to direct play but silently
  ' fall back to the transcode if that fails.
  '
  ' Resolution guard: Only attempt the level override when the source resolution is within the
  ' codec's hardware ceiling. Many encoders tag 1080p content with inflated levels (e.g. 5.1)
  ' even though the actual decode workload is within Roku's capability. But for resolutions
  ' above the hardware limit (e.g. 4K h264), no level tolerance will help — skip the override.
  ' Both height and width are checked to catch ultrawide or non-standard aspect ratios.
  if m.playbackInfo.MediaSources[0].MediaStreams.Count() > 0 and not isLive
    ' Find first video stream - MediaStreams[0] might be subtitle/audio
    videoStream = getFirstVideoStream(m.playbackInfo.MediaSources[0].MediaStreams)
    if isValid(videoStream) and isValid(videoStream.codec)
      tryDirectPlay = userSettings.playbackTryDirectH264ProfileLevel and videoStream.codec = "h264"
      tryDirectPlay = tryDirectPlay or (userSettings.playbackTryDirectHevcProfileLevel and videoStream.codec = "hevc")

      ' Check source resolution against hardware ceiling before allowing override
      if tryDirectPlay and not isWithinH264HardwareCeiling(videoStream)
        tryDirectPlay = false
      end if
    else
      tryDirectPlay = false
    end if
    if tryDirectPlay and isValid(m.playbackInfo.MediaSources[0].TranscodingUrl) and forceTranscoding = false
      transcodingReasons = getTranscodeReasons(m.playbackInfo.MediaSources[0].TranscodingUrl)
      if transcodingReasons.Count() = 1 and transcodingReasons[0] = "VideoLevelNotSupported"
        video.isDirectPlaySupported = true
        video.isTranscodeAvailable = true
      end if
    end if
  end if

  if video.isDirectPlaySupported
    video.isTranscoded = false
    setupVideoContentWithAuth(video, mediaSourceId, audioStreamIdx)
    applyLiveDirectPlayFallback(video, meta)
    m.log.info("Playback path: DIRECT PLAY", "finalUrl", video.content.url, "streamFormat", video.content.StreamFormat)
  else
    if not isValid(m.playbackInfo.MediaSources[0].TranscodingUrl)
      ' Server did not provide a transcode URL — surface a message to the user via the task field
      ' so VideoPlayerView shows one dialog (not two).
      m.log.error("Server did not provide a TranscodingUrl", "itemId", video.id)
      m.top.errorMsg = translate(translationKeys.ErrorAnErrorWasEncounteredWhilePlaying)
      video.content = invalid
      return
    end if
    ' Get transcoding reason
    video.transcodeReasons = getTranscodeReasons(m.playbackInfo.MediaSources[0].TranscodingUrl)
    transcodingUrl = buildURL(m.playbackInfo.MediaSources[0].TranscodingUrl)
    if not isValid(transcodingUrl)
      m.log.error("Failed to build transcoding URL — server URL not set", "itemId", video.id)
      m.top.errorMsg = translate(translationKeys.ErrorAnErrorWasEncounteredWhilePlaying)
      video.content = invalid
      return
    end if

    video.content.url = transcodingUrl
    ' Jellyfin reports the transcode protocol via TranscodingSubProtocol ("hls" or "http").
    ' Only set StreamFormat="hls" for HLS — Roku auto-detects progressive MP4 from the URL
    ' and the moov atom, and explicitly setting StreamFormat="mp4" breaks that detection.
    subProtocol = ""
    if isValid(m.playbackInfo.MediaSources[0].TranscodingSubProtocol)
      subProtocol = LCase(m.playbackInfo.MediaSources[0].TranscodingSubProtocol)
    end if
    if subProtocol = "hls"
      video.content.StreamFormat = "hls"
    end if
    video.isTranscoded = true
    m.log.info("Playback path: TRANSCODED", "finalUrl", transcodingUrl, "streamFormat", video.content.StreamFormat, "subProtocol", subProtocol, "transcodeReasons", video.transcodeReasons)

    ' If DoVi preservation caused this transcode, flag that direct play is a viable buffer-overflow fallback.
    ' VideoRangeTypeNotSupported is the reason Jellyfin returns when our DoVi container profile blocks the MKV.
    ' On a buffer:loop: error the player will retry with bypassDoviPreservation=true, letting the server
    ' re-evaluate without the DoVi constraint and (usually) grant direct play instead.
    if userSettings.playbackPreserveDovi and not bypassDoviPreservation
      if arrayHasValue(video.transcodeReasons, "VideoRangeTypeNotSupported")
        video.isDoviDirectPlayFallbackAvailable = true
      end if
    end if
  end if

  setCertificateAuthority(video.content)
  ' Convert Jellyfin audio stream index to Roku Track identifier (Index + 1 for MKV)
  video.audioTrack = getRokuAudioTrackPosition(audioStreamIdx, video.fullAudioData)
end sub

' setupVideoContentWithAuth: Configures video content URL and applies authentication
'
' Determines the appropriate URL based on protocol and stream location, then applies
' Jellyfin authentication headers for internal streams. External streams (non-localhost)
' receive the raw URL without authentication to prevent credential leakage.
'
' Protocol handling:
' - "file": Direct stream from Jellyfin server (gets auth)
' - Non-file with localhost domain: Proxied through Jellyfin (gets auth)
' - Non-file with external domain: Direct external URL (NO auth)
'
' @param {object} video - Video object containing content node to configure
' @param {dynamic} mediaSourceId - Media source ID or empty string for live streams
' @param {integer} audioStreamIdx - Selected audio stream index
sub setupVideoContentWithAuth(video, mediaSourceId, audioStreamIdx)
  fullyExternal = false
  protocol = LCase(m.playbackInfo.MediaSources[0].Protocol)

  if protocol <> "file"
    uri = parseUrl(m.playbackInfo.MediaSources[0].Path)
    if not isValidAndNotEmpty(uri) then return

    if isValid(uri[2]) and isLocalhost(uri[2])
      ' if the domain of the URI is local to the server,
      ' create a new URI by appending the received path to the server URL
      ' later we will substitute the users provided URL for this case
      if not isValid(uri[4])
        m.log.error("Localhost stream has no path component", "sourceUrl", m.playbackInfo.MediaSources[0].Path)
        m.top.errorMsg = translate(translationKeys.ErrorAnErrorWasEncounteredWhilePlaying)
        video.content = invalid
        return
      end if

      localUrl = buildURL(uri[4])
      if not isValid(localUrl)
        m.log.error("Failed to build localhost stream URL — server URL not set", "path", uri[4])
        m.top.errorMsg = translate(translationKeys.ErrorAnErrorWasEncounteredWhilePlaying)
        video.content = invalid
        return
      end if
      video.content.url = localUrl
    else
      ' External stream - use raw URL without modification
      fullyExternal = true
      video.content.url = m.playbackInfo.MediaSources[0].Path
    end if
  else
    ' File protocol - build Jellyfin streaming URL
    params = {
      "Static": "true",
      "Container": video.container,
      "PlaySessionId": video.PlaySessionId,
      "AudioStreamIndex": audioStreamIdx
    }

    if mediaSourceId <> ""
      params.MediaSourceId = mediaSourceId
    end if

    streamUrl = buildURL(Substitute("Videos/{0}/stream", video.id), params)
    if not isValid(streamUrl)
      m.log.error("Failed to build stream URL — server URL not set", "itemId", video.id)
      m.top.errorMsg = translate(translationKeys.ErrorAnErrorWasEncounteredWhilePlaying)
      video.content = invalid
      return
    end if
    video.content.url = streamUrl
  end if

  ' Apply Jellyfin authentication only for internal streams
  if not fullyExternal
    video.content = authRequest(video.content)
  end if
end sub

' addAudioStreamsToVideo: Populate video.fullAudioData with the item's audio tracks.
'
' Sources from the original item metadata (meta.mediaSourcesData) — NOT from the
' PlaybackInfo response. The /PlaybackInfo response can return a transcode-shaped
' MediaSource carrying only the chosen audio stream, which would shrink the OSD picker
' to a single entry (issue #500). The OSD picker must always list every original track
' so the user can switch.
'
' @param {object} video - video AA receiving the fullAudioData field
' @param {dynamic} meta - item metadata (from ItemMetaData) holding mediaSourcesData
' @param {dynamic} mediaSourceId - chosen MediaSource ID (matches one entry under meta.mediaSourcesData.mediaSources)
sub addAudioStreamsToVideo(video as object, meta as dynamic, mediaSourceId as dynamic)
  mediaStreams = invalid

  if isValid(meta) and isValid(meta.mediaSourcesData) and isValid(meta.mediaSourcesData.mediaSources)
    sources = meta.mediaSourcesData.mediaSources
    if isValidAndNotEmpty(mediaSourceId)
      for each source in sources
        if isValid(source) and source.Id = mediaSourceId and isValid(source.MediaStreams)
          mediaStreams = source.MediaStreams
          exit for
        end if
      end for
    end if
    if not isValid(mediaStreams) and isValid(sources[0]) and isValid(sources[0].MediaStreams)
      mediaStreams = sources[0].MediaStreams
    end if
  end if

  ' Defensive fallback if metadata is unavailable (shouldn't happen — ItemMetaData ran above)
  if not isValid(mediaStreams) and isValid(m.playbackInfo) and isValid(m.playbackInfo.MediaSources) and isValid(m.playbackInfo.MediaSources[0])
    mediaStreams = m.playbackInfo.MediaSources[0].MediaStreams
  end if

  audioStreams = []
  if isValid(mediaStreams)
    for i = 0 to mediaStreams.Count() - 1
      if LCase(mediaStreams[i].Type) = "audio"
        audioStreams.push(mediaStreams[i])
      end if
    end for
  end if

  video.fullAudioData = audioStreams
end sub

sub addSubtitlesToVideo(video, meta)
  if not isValid(meta) then return
  if not isValid(meta.id) then return
  if not isValid(m.playbackInfo) then return
  if not isValidAndNotEmpty(m.playbackInfo.MediaSources) then return
  if not isValid(m.playbackInfo.MediaSources[0].MediaStreams) then return

  subtitles = sortSubtitles(m.playbackInfo.MediaSources[0].MediaStreams)
  safesubs = subtitles["all"]
  subtitleTracks = []

  if m.global.user.settings.playbackSubsOnlyText = true
    safesubs = subtitles["text"]
  end if

  for each subtitle in safesubs
    subtitleTracks.push(subtitle.track)
  end for

  video.content.SubtitleTracks = subtitleTracks
  video.fullSubtitleData = safesubs
end sub


function directPlaySupported(meta as object) as boolean
  devinfo = CreateObject("roDeviceInfo")
  mediaSources = invalid
  if isValid(meta.mediaSourcesData) and isValidAndNotEmpty(meta.mediaSourcesData.mediaSources)
    mediaSources = meta.mediaSourcesData.mediaSources
  end if
  if not isValid(mediaSources) then return false
  mediaSource = mediaSources[0]
  if not isValid(mediaSource) then return false

  if isValid(mediaSource.SupportsDirectPlay) and mediaSource.SupportsDirectPlay = false
    return false
  end if

  ' Get the first video stream instead of blindly using MediaStreams[0]
  ' which could be a subtitle or audio stream
  videoStream = getFirstVideoStream(mediaSource.MediaStreams)
  if not isValid(videoStream)
    return false
  end if

  streamInfo = { Codec: videoStream.codec }
  if isValid(videoStream.Profile) and videoStream.Profile.len() > 0
    streamInfo.Profile = LCase(videoStream.Profile)
  end if
  if isValid(mediaSource.container) and mediaSource.container.len() > 0
    'CanDecodeVideo() requires the .container to be format: "mp4", "hls", "mkv", "ism", "dash", "ts" if its to direct stream
    if mediaSource.container = "mov"
      streamInfo.Container = "mp4"
    else
      streamInfo.Container = mediaSource.container
    end if
  end if

  decodeResult = devinfo.CanDecodeVideo(streamInfo)
  return isValid(decodeResult) and decodeResult.result

end function


' Add next episodes to the playback queue
sub addNextEpisodesToQueue(showID)
  queueManager = m.global.queueManager

  ' Don't queue next episodes if we already have a playback queue
  maxQueueCount = 1

  if m.top.isIntro
    maxQueueCount = 2
  end if

  if queueManager.callFunc("getCount") > maxQueueCount then return

  videoID = m.top.itemId

  ' If first item is an intro video, use the next item in the queue
  if m.top.isIntro
    currentVideo = queueManager.callFunc("getItemByIndex", 1)

    if isValid(currentVideo) and isValid(currentVideo.id)
      videoID = currentVideo.id

      ' Override showID value since it's for the intro video
      meta = ItemMetaData(videoID)
      if isValid(meta)
        showID = meta.seriesId
      end if
    end if
  end if

  data = fetchJson(GetApi().BuildGetEpisodesRequest(showID, {
    "StartItemId": videoID,
    "Limit": 50
  }), "nextEpisodes")

  if isValid(data) and data.Items.Count() > 1
    ' Start at index 1 to skip the current episode
    for i = 1 to data.Items.Count() - 1
      queueManager.callFunc("push", nodeHelpers.createQueueItem(data.Items[i]))
    end for
  end if
end sub