components_ItemGrid_LoadVideoContentTask.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/api/UserLibrary.bs"
import "pkg:/source/enums/SubtitleSelection.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/session.bs"
import "pkg:/source/utils/streamSelection.bs"
import "pkg:/source/utils/Subtitles.bs"

sub init()
  m.top.functionName = "loadItems"
end sub

sub loadItems()
  queueManager = m.global.queueManager

  ' Reset intro tracker in case task gets reused
  m.top.isIntro = false

  ' 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
  audio_stream_idx = m.top.selectedAudioStreamIndex
  forceTranscoding = false

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

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

  video = {}
  video.id = id
  video.content = createObject("RoSGNode", "ContentNode")

  LoadItems_AddVideoContent(video, mediaSourceId, audio_stream_idx, forceTranscoding)

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

  return video
end function

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

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

  video.json = meta.json

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

  ' 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
    if isValid(meta.json) and isValid(meta.json.MediaStreams)
      ' Resolve playDefaultAudioTrack setting (JellyRock override or web client)
      playDefault = resolvePlayDefaultAudioTrack(userSettings, userSession.config)

      audio_stream_idx = findBestAudioStreamIndex(meta.json.MediaStreams, playDefault, userSession.config.audioLanguagePreference)
    else
      ' MediaStreams not available - keep existing behavior of using 0
      ' This should never happen since ItemMetaData() just fetched metadata
      audio_stream_idx = 0
    end if
  end if

  ' Early metadata extraction - MediaSources may not exist yet for Live TV
  if isValidAndNotEmpty(meta.json.MediaSources)
    if isValid(meta.json.MediaSources[0].RunTimeTicks)
      if meta.json.MediaSources[0].RunTimeTicks = 0
        video.length = 0
      else
        video.length = meta.json.MediaSources[0].RunTimeTicks / 10000000
      end if
    end if

    ' Find first video stream - MediaStreams[0] might be subtitle/audio
    if isValid(meta.json.MediaSources[0].MediaStreams)
      videoStream = getFirstVideoStream(meta.json.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

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

  ' Check for any Live TV streams or Recordings coming from other places other than the TV Guide
  if videotype = "recording" or (isValid(meta.json) and isValid(meta.json.ChannelId))
    if isValid(meta.json.EpisodeTitle)
      meta.title = meta.json.EpisodeTitle
    else if isValid(meta.json.Name)
      meta.title = meta.json.Name
    end if
    meta.live = true
    if LCase(meta.json.type) = "program"
      video.id = meta.json.ChannelId
    else
      video.id = meta.json.id
    end if
  end if

  if videotype = "tvchannel" and isValid(meta.json) and isValid(meta.json.CurrentProgram)
    if isValid(meta.json.CurrentProgram.Name)
      meta.title = `${meta.title}: ${meta.json.CurrentProgram.Name}`
    end if
    if isValid(meta.json.CurrentProgram.ParentIndexNumber)
      video.seasonNumber = meta.json.CurrentProgram.ParentIndexNumber
    end if
    if isValid(meta.json.CurrentProgram.IndexNumber)
      video.episodeNumber = meta.json.CurrentProgram.IndexNumber
    end if
    if isValid(meta.json.CurrentProgram.IndexNumberEnd)
      video.episodeNumberEnd = meta.json.CurrentProgram.IndexNumberEnd
    end if
  end if

  video.chapters = meta.json.Chapters
  video.title = meta.title
  video.showID = meta.showID

  logoLookupID = video.id

  if videotype = "episode" or videotype = "series"
    video.content.contenttype = "episode"
    video.seasonNumber = meta.json.ParentIndexNumber
    video.episodeNumber = meta.json.IndexNumber
    video.episodeNumberEnd = meta.json.IndexNumberEnd

    if isValid(meta.showID)
      logoLookupID = meta.showID
    end if
  end if

  logoImageExists = api.items.HeadImageURLByName(logoLookupID, "logo")
  if logoImageExists
    video.json.logoImage = api.items.GetImageURL(logoLookupID, "logo", 0, { "maxHeight": 300, "maxWidth": 500, "quality": "90" })
  end if

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

  playbackPosition = 0!

  currentItem = queueManager.callFunc("getCurrentItem")

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

  ' Determine final subtitle_idx BEFORE calling ItemPostPlaybackInfo to avoid duplicate API calls.
  ' defaultSubtitleTrackFromVid() only needs meta and audio_stream_idx, both available at this point.
  if subtitle_idx = SubtitleSelection.notset
    subtitle_idx = defaultSubtitleTrackFromVid(meta, audio_stream_idx)
  end if

  ' PlayStart requires the time to be in seconds
  video.content.PlayStart = int(playbackPosition / 10000000)

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

  ' Call ItemPostPlaybackInfo ONCE with final subtitle_idx
  m.playbackInfo = ItemPostPlaybackInfo(video.id, mediaSourceId, audio_stream_idx, subtitle_idx, playbackPosition, meta)
  if not isValid(m.playbackInfo)
    video.errorMsg = "Error loading playback info"
    video.content = invalid
    return
  end if

  ' Playback info can be logged here for debugging playback issues
  print "LoadItems_AddVideoContent() m.playbackInfo =", m.playbackInfo
  if m.playbackInfo.MediaSources.Count() > 0
    for each mediaSource in m.playbackInfo.MediaSources
      print "mediaSource =", mediaSource
    end for
  end if

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

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

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

  video.PlaySessionId = m.playbackInfo.PlaySessionId

  if meta.live
    video.content.live = true
    video.content.StreamFormat = "hls"
  end if

  video.container = getContainerType(meta)

  if not isValid(m.playbackInfo.MediaSources[0])
    m.playbackInfo = meta.json
  end if

  addAudioStreamsToVideo(video)
  if meta.live
    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.directPlaySupported = 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.
  if m.playbackInfo.MediaSources[0].MediaStreams.Count() > 0 and meta.live = false
    ' 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")
    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.directPlaySupported = true
        video.transcodeAvailable = true
      end if
    end if
  end if

  if video.directPlaySupported
    video.isTranscoded = false
    setupVideoContentWithAuth(video, mediaSourceId, audio_stream_idx)
  else
    if not isValid(m.playbackInfo.MediaSources[0].TranscodingUrl)
      ' If server does not provide a transcode URL, display a message to the user
      m.global.sceneManager.callFunc("userMessage", tr("Error Getting Playback Information"), tr("An error was encountered while playing this item.  Server did not provide required transcoding data."))
      video.errorMsg = "Error getting playback information"
      video.content = invalid
      return
    end if
    ' Get transcoding reason
    video.transcodeReasons = getTranscodeReasons(m.playbackInfo.MediaSources[0].TranscodingUrl)
    video.content.url = buildURL(m.playbackInfo.MediaSources[0].TranscodingUrl)
    video.isTranscoded = true
  end if

  setCertificateAuthority(video.content)
  ' Convert Jellyfin audio stream index to Roku's 1-indexed audio track position
  video.audioTrack = getRokuAudioTrackPosition(audio_stream_idx, 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} audio_stream_idx - Selected audio stream index
sub setupVideoContentWithAuth(video, mediaSourceId, audio_stream_idx)
  fully_external = 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 isValid(uri[4])
        video.content.url = buildURL(uri[4])
      end if
    else
      ' External stream - use raw URL without modification
      fully_external = 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": audio_stream_idx
    }

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

    video.content.url = buildURL(Substitute("Videos/{0}/stream", video.id), params)
  end if

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

' addAudioStreamsToVideo: Add audio stream data to video
'
' @param {dynamic} video component to add fullAudioData to
sub addAudioStreamsToVideo(video)
  audioStreams = []
  mediaStreams = m.playbackInfo.MediaSources[0].MediaStreams

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

  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

'
' Extract array of Transcode Reasons from the content URL
' @returns Array of Strings
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

function directPlaySupported(meta as object) as boolean
  devinfo = CreateObject("roDeviceInfo")
  if isValid(meta.json.MediaSources[0]) and meta.json.MediaSources[0].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(meta.json.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(meta.json.MediaSources[0].container) and meta.json.MediaSources[0].container.len() > 0
    'CanDecodeVideo() requires the .container to be format: “mp4”, “hls”, “mkv”, “ism”, “dash”, “ts” if its to direct stream
    if meta.json.MediaSources[0].container = "mov"
      streamInfo.Container = "mp4"
    else
      streamInfo.Container = meta.json.MediaSources[0].container
    end if
  end if

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

end function

function getContainerType(meta as object) as string
  ' Determine the file type of the video file source
  if not isValid(meta.json.mediaSources) then return ""

  container = meta.json.mediaSources[0].container
  if not isValid(container)
    container = ""
  else if container = "m4v" or container = "mov"
    container = "mp4"
  end if

  return container
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.showID
      end if
    end if
  end if

  url = Substitute("Shows/{0}/Episodes", showID)
  urlParams = {
    "UserId": m.global.user.id,
    "StartItemId": videoID,
    "Limit": 50
  }
  resp = APIRequest(url, urlParams)
  data = getJson(resp)

  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", data.Items[i])
    end for
  end if
end sub