import "pkg:/source/api/ApiClient.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/enums/SubtitleSelection.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/deviceCapabilities.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/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
audio_stream_idx = m.top.selectedAudioStreamIndex
forceTranscoding = m.top.forceTranscoding
bypassDoviPreservation = m.top.bypassDoviPreservation
m.top.content = [LoadItems_VideoPlayer(id, mediaSourceId, audio_stream_idx, forceTranscoding, bypassDoviPreservation)]
end sub
function LoadItems_VideoPlayer(id as string, mediaSourceId as dynamic, audio_stream_idx = 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, audio_stream_idx, forceTranscoding, bypassDoviPreservation)
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, 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
' 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(mediaSources) and isValid(mediaSources[0].MediaStreams)
' Resolve playDefaultAudioTrack setting (JellyRock override or web client)
playDefault = resolvePlayDefaultAudioTrack(userSettings, userSession.config)
audio_stream_idx = findBestAudioStreamIndex(mediaSources[0].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 isValid(mediaSources) and isValidAndNotEmpty(mediaSources)
if isValid(mediaSources[0].RunTimeTicks)
if mediaSources[0].RunTimeTicks = 0
video.length = 0
else
video.length = mediaSources[0].RunTimeTicks / 10000000
end if
end if
' 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
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.
' TvChannel is always live — include it here so mediaSourceId is cleared and live-stream
' flags (content.live, StreamFormat, transcodeParams.LiveStreamId) are set correctly below.
isLive = false
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
isLive = true
if LCase(meta.type) = "program"
video.id = meta.channelId
else
video.id = meta.id
end if
end if
video.chapters = meta.chapters
video.title = isValidAndNotEmpty(titleOverride) ? titleOverride : meta.title
video.showID = meta.seriesId
if videotype = "episode" or videotype = "series"
video.content.contenttype = "episode"
video.seasonNumber = meta.parentIndexNumber
video.episodeNumber = meta.indexNumber
video.episodeNumberEnd = meta.indexNumberEnd
else if videotype = "recording"
' Recordings belong to a series — look up the series logo, same as episodes
end if
' Set logo image using metadata (same logic as ItemDetails)
' Check for logoImageTag first (movie/item logo)
if isValidAndNotEmpty(meta.logoImageTag)
video.logoImage = ImageURL(meta.id, "Logo", { maxHeight: 212, maxWidth: 500, 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: 212, maxWidth: 500, 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: 534, maxWidth: 500, 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: 534, maxWidth: 500, quality: 90, tag: meta.seriesPrimaryImageTag })
else if isValidAndNotEmpty(meta.primaryImageTag)
video.logoImage = ImageURL(meta.id, "Primary", { maxHeight: 212, maxWidth: 500, 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: 212, maxWidth: 500, quality: 90, tag: currentProgram.logoImageTag })
else if isValidAndNotEmpty(currentProgram.primaryImageTag)
video.logoImage = ImageURL(currentProgram.id, "Primary", { maxHeight: 534, maxWidth: 500, quality: 90, tag: currentProgram.primaryImageTag })
else if isValidAndNotEmpty(meta.primaryImageTag)
video.logoImage = ImageURL(meta.id, "Primary", { maxHeight: 212, maxWidth: 212, quality: 90, tag: meta.primaryImageTag })
end if
else if isValidAndNotEmpty(meta.primaryImageTag)
video.logoImage = ImageURL(meta.id, "Primary", { maxHeight: 212, maxWidth: 212, 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 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)
' 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 subtitle_idx
m.playbackInfo = ItemPostPlaybackInfo(video.id, mediaSourceId, audio_stream_idx, subtitle_idx, playbackPosition, meta, bypassDoviPreservation, forceTranscoding)
if not isValid(m.playbackInfo)
m.log.error("ItemPostPlaybackInfo returned invalid response")
m.top.errorMsg = tr("There was an error retrieving playback information from the server.")
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 = tr("No compatible streams are available for this item.")
else
m.top.errorMsg = tr("The server was unable to start playback.") + " (" + 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 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 isLive
video.content.live = true
video.content.StreamFormat = "hls"
end if
video.container = meta.container
' Pass JellyfinBaseItem node for OSD to read typed fields directly
video.meta = meta
' 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)
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.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 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")
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)
applyLiveDirectPlayFallback(video, meta)
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 = tr("An error was encountered while playing this item. The server did not provide the required transcoding data.")
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
' 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.doviDirectPlayFallbackAvailable = true
end if
end if
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
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
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", nodeHelpers.createQueueItem(data.Items[i]))
end for
end if
end sub