import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPipeline.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/apiResponse.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/constants/timeouts.bs"
import "pkg:/source/enums/LiveStreamStage.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/episodeQueue.bs"
import "pkg:/source/utils/liveStreamHandoff.bs"
import "pkg:/source/utils/liveTv.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/nodeHelpers.bs"
import "pkg:/source/utils/probedStreams.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"
import "pkg:/source/utils/upstreamManifest.bs"
import "pkg:/source/utils/versionPick.bs"
import "pkg:/source/utils/versionResume.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 = ""
' This run has opened no live stream yet (liveStreamHandoff.bs).
m.top.liveStream = { stage: LiveStreamStage.NONE, id: "" }
' Only show preroll once per queue
if queueManager.callFunc("isPrerollActive")
' Prerolls not allowed if we're resuming video.
'
' startingPoint alone no longer answers that on a per-version server: the item's own
' version can sit at 0 while an alternate is partway through, and which version resumes is
' settled further down, after the metadata fetch, too late to un-queue an intro. So the
' caller passes hasVersionInProgress, read from the server's source order for free.
' jellyfin-web draws the same line (getIntros() bails on options.startPositionTicks), and
' keys it on the position actually being played, which is what this restores.
' Both reads are explicitly guarded rather than compared against 0: an absent field reads
' `invalid`, and "is it resuming?" must not be decided by how invalid happens to compare.
currentItem = queueManager.callFunc("getCurrentItem")
startsFromAPosition = isValid(currentItem) and isValid(currentItem.startingPoint) and currentItem.startingPoint > 0
otherVersionInProgress = isValid(currentItem) and currentItem.hasVersionInProgress = true
if not startsFromAPosition and not otherVersionInProgress
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"
' The item plays after its intro, so it goes directly after the slot the intro plays
' in: appending it would play the rest of the queue first. Moving on to it reaches
' the same item, not a new one, so it keeps the start it was launched with
' (QueueManager.advanceTo consumes followsIntro).
followingItem = queueManager.callFunc("getCurrentItem")
if isValid(followingItem) then followingItem.followsIntro = true
queueManager.callFunc("insertAfterCurrent", followingItem)
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
' resolveResumeVersion: Which of an item's versions resumes, and from where.
'
' The imperative shell around versionResume.chooseResumeSource(): that function owns every
' rule and says what it still needs; this fetches it and asks again. Same division as
' ItemDetails, so both entry points reach an identical answer. The split between them is
' what made the app name one version on the details screen and play another.
'
' Reads ride apiPipeline rather than a fetch loop: the pipeline keeps as many requests on the
' pool as it has slots, so N versions cost roughly one round trip instead of N. Each reply was
' ~150 bytes against the item's ~17 KB (measured 2026-09-19), and nothing is fetched at all unless something is
' already in progress, so an item nobody has started costs no requests on the playback path.
' When the source order cannot say which version was played last, one more request asks the
' server (fetchMostRecentVersionId).
'
' @param {string} itemId - the item being played
' @param {object} meta - ItemMetaData() for it (its own UserData position comes from here)
' @param {object} mediaSources - its MediaSources, in server order
' @return {object} { sourceId, ticks }: sourceId "" when no version is in progress
function resolveResumeVersion(itemId as string, meta as object, mediaSources as object) as object
ctx = {
itemId: itemId,
itemPositionTicks: meta.playbackPositionTicks,
mediaSources: mediaSources,
positions: {},
isPerVersion: true,
deviceBestId: ""
}
' At most four passes: read the positions, ask which version the server would resume, settle
' a near-level tie, and take the answer. Each pass records an answer (an unreadable one
' included), so nothing is asked twice, and the bound means a request that cannot be built
' cannot spin.
for attempt = 1 to 4
answer = versionResume.chooseResumeSource(ctx)
if answer.needsSourceIds.count() > 0
fetchVersionPositions(ctx.positions, answer.needsSourceIds)
else if answer.needsMostRecent
ctx.mostRecentId = fetchMostRecentVersionId(itemId, meta, mediaSources)
else if answer.tiedIds.count() > 1
' Equally far along, so quality decides, and that needs the device, which
' versionResume knows nothing about by design.
ctx.deviceBestId = findBestVideoSourceAmongIds(mediaSources, answer.tiedIds)
if not isValidAndNotEmpty(ctx.deviceBestId) then exit for
else
return { sourceId: answer.sourceId, ticks: answer.ticks }
end if
end for
' A tie no device pick could settle: not reachable while tiedIds come from the item's own
' sources, but it must not be re-asked: no per-version answer, so the caller's own rule applies.
return { sourceId: "", ticks: 0& }
end function
' fetchMostRecentVersionId: Ask the server which of an item's versions it would resume.
'
' The resume query returns the version that owns the resume point, which is the answer the
' source order gives only from a primary (see versionResume.chooseResumeSource). Scoped to the
' item's parent (its season, or the folder holding a movie), so the reply is that parent's
' in-progress items rather than the whole of Continue Watching, and lean, because only ids are
' read.
'
' @param {string} itemId - the item being played
' @param {object} meta - ItemMetaData() for it
' @param {object} mediaSources - its MediaSources
' @return {string} the version's source id, or "" when the server cannot say, itself an
' answer, so the caller falls back to the order instead of asking again
function fetchMostRecentVersionId(itemId as string, meta as object, mediaSources as object) as string
if not isValidAndNotEmpty(meta.parentId) then return ""
req = GetApi().BuildGetResumeItemsRequest({ parentId: meta.parentId, EnableImages: false, EnableUserData: false })
if not isValid(req) then return ""
return versionResume.mostRecentIdFrom(fetchRes(req, "version-most-recent-" + itemId), mediaSources)
end function
' fetchVersionPositions: Read each named version's own position into `positions`.
'
' A version that does not answer is recorded as POSITION_UNREADABLE rather than left absent,
' so the caller's next pass cannot ask for it again and loop, and so an unread position is
' never mistaken for "this version has no progress", which would silently restart the content.
'
' @param {object} positions - sourceId -> ticks, filled in place
' @param {object} sourceIds - the versions to read
sub fetchVersionPositions(positions as object, sourceIds as object)
api = GetApi()
entries = []
for each sourceId in sourceIds
req = api.BuildGetItemUserDataRequest(sourceId)
if isValid(req)
entries.push({ requestId: "version-position-" + sourceId, req: req, sourceId: sourceId })
else
positions[sourceId] = versionResume.POSITION_UNREADABLE
end if
end for
if entries.count() = 0 then return
pipe = apiPipelineBegin(entries)
result = apiPipelineNext(pipe)
while isValid(result)
positions[result.entry.sourceId] = versionResume.playbackPositionFrom(result.res)
result = apiPipelineNext(pipe)
end while
' Anything the pipeline never delivered (budget expiry) is still absent. Pin it so the
' decision is made on what is known instead of waiting on a reply that is not coming.
for each sourceId in sourceIds
if not isValid(positions[sourceId]) then positions[sourceId] = versionResume.POSITION_UNREADABLE
end for
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) then return invalid
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
playbackPosition = 0!
currentItem = queueManager.callFunc("getCurrentItem")
if isValid(currentItem) and isValid(currentItem.startingPoint)
playbackPosition = currentItem.startingPoint
end if
' A place the caller named (a chapter, Play from the start, a position saved before a
' reload), as opposed to a saved resume position (nodeHelpers.setExactStart).
startsAtExactPosition = isValid(currentItem) and isValid(currentItem.startingPointIsExact) and currentItem.startingPointIsExact
' Select a video source if no specific source was requested. On a per-version server this is
' the one place that choice is made for every entry point that did not make it on screen
' (quick play, casts, a queued item), because it is the only one that can read each version's
' own position. The details screen still decides for itself and arrives with an explicit id,
' which is honored untouched. An item that starts fresh (QueueManager.advanceTo /
' startCurrentFresh) is the exception: it resumes nothing, so position has no say in its version.
if not isValid(mediaSourceId) and isValid(mediaSources) and mediaSources.Count() > 1
if isValid(currentItem) and currentItem.startsFresh = true
' No position decides this item's version: the viewer's explicit pick does when the item
' has it, else the device (versionPick).
preference = queueManager.callFunc("getVersionPreference")
mediaSourceId = mediaSources[versionPick.sourceIndexFor(preference, mediaSources)].Id
m.log.info("Fresh start version", "mediaSourceId", mediaSourceId, "hasPick", isValid(preference))
else if versionResume.isPerVersionServer(m.global.server.version)
choice = resolveResumeVersion(video.id, meta, mediaSources)
if isValidAndNotEmpty(choice.sourceId)
mediaSourceId = choice.sourceId
' The chosen version resumes at ITS OWN position, never the one the queue carried:
' two versions of one item can be a minute apart in runtime, so reusing another
' version's position would drop the viewer somewhere else in the content. Unless the
' caller named the place: a chapter plays from the chapter, on whichever version.
if not startsAtExactPosition then playbackPosition = choice.ticks
m.log.info("Resume version chosen", "mediaSourceId", mediaSourceId, "ticks", playbackPosition.toStr(), "isExact", startsAtExactPosition)
else
mediaSourceId = mediaSources[findBestVideoSource(mediaSources)].Id
m.log.info("Auto-selected video source (nothing in progress)", "mediaSourceId", mediaSourceId)
end if
else
' One position per item, so any version resumes at it: take the best for the device.
mediaSourceId = mediaSources[findBestVideoSource(mediaSources)].Id
m.log.info("Auto-selected video source (single-position server)", "mediaSourceId", mediaSourceId)
end if
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
' Read the version that is actually PLAYING, not the server's default (mediaSourceForId).
playedSource = mediaSourceForId(mediaSources, mediaSourceId)
if m.top.selectedAudioStreamIndex = 0
if isValid(playedSource) and isValid(playedSource.MediaStreams)
audioStreamIdx = selectAudioStreamIndex(playedSource.MediaStreams, userSettings, userSession.config, meta.originalLanguage ?? "")
else
' MediaStreams not available - keep existing behavior of using 0
' This should never happen since ItemMetaData() fetched metadata above
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(playedSource)
' 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(playedSource.RunTimeTicks) and playedSource.RunTimeTicks > 0
effectiveLengthSeconds = int(playedSource.RunTimeTicks / 10000000)
durationSource = "mediaSource.RunTimeTicks"
else if isValid(playedSource.Size) and playedSource.Size > 0 and isValid(playedSource.Bitrate) and playedSource.Bitrate > 0
effectiveLengthSeconds = int((playedSource.Size * 8) / playedSource.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)
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 OSD's videoLogo Poster enforces
' this client-side via loadWidth/loadHeight (set in OSD.bs init()) 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, then program primary (poster), then 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
' 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. For a live item this request opens the
' live stream, and only its answer names it, so it waits as long as the server itself can take
' (timeouts.LIVE_OPEN_MS), and the stage it is at is recorded for the view (liveStreamHandoff.bs).
openLimitMs = 0
if isLive
openLimitMs = timeouts.LIVE_OPEN_MS
m.top.liveStream = { stage: LiveStreamStage.OPENING, id: "" }
end if
res = ItemPostPlaybackInfoResponse(video.id, mediaSourceId, audioStreamIdx, subtitleIdx, playbackPosition, meta, bypassDoviPreservation, forceTranscoding, openLimitMs)
m.playbackInfo = invalid
if isValid(res) and res.ok then m.playbackInfo = res.json
if isLive
liveStreamId = ""
if isValid(m.playbackInfo) and isValidAndNotEmpty(m.playbackInfo.MediaSources) and isValid(m.playbackInfo.MediaSources[0])
if isValidAndNotEmpty(m.playbackInfo.MediaSources[0].LiveStreamId) then liveStreamId = m.playbackInfo.MediaSources[0].LiveStreamId
end if
' OPEN is written BEFORE isAbandoned is read: the view decides between the two on the render
' thread, and this order is what leaves exactly one of us to close the stream.
if liveStreamId <> ""
m.top.liveStream = { stage: LiveStreamStage.OPEN, id: liveStreamId }
else
m.top.liveStream = { stage: LiveStreamStage.NONE, id: "" }
end if
if m.top.isAbandoned
' The viewer left while the stream was opening: nobody will play it.
if liveStreamId <> ""
m.log.info("Closing the live stream opened for a view that has gone", "liveStreamId", liveStreamId, "itemId", video.id)
SubmitSideEffect(GetApi().BuildCloseLiveStreamRequest(liveStreamId))
end if
video.content = invalid
return
end if
end if
if not isValid(m.playbackInfo)
m.log.error("PlaybackInfo failed", "cause", apiResponse.jsonFailure(res), "isLive", isLive)
if isLive and apiResponse.noAnswer(res)
m.top.errorMsg = translate(translationKeys.ErrorThisChannelTookTooLongToStart)
else
m.top.errorMsg = translate(translationKeys.ErrorThereWasAnErrorRetrievingPlayback)
end if
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())
' Whether the device plays the source's own manifest URL is worked out once, from this first
' answer, and applied further down. Nothing it reads depends on which tracks were picked, and the
' probed picks below need it: a new answer is wasted when the device never plays the server's.
manifestSource = invalid
if isValidAndNotEmpty(m.playbackInfo.MediaSources) then manifestSource = m.playbackInfo.MediaSources[0]
manifestTranscodingUrl = ""
if isValid(manifestSource) then manifestTranscodingUrl = textOrEmpty(manifestSource.TranscodingUrl)
userPolicy = userSession.policy
upstreamBlocker = getUpstreamManifestBlocker(manifestSource, {
forceTranscoding: forceTranscoding,
serverUrl: textOrEmpty(getUrl()),
forceRemoteSourceTranscoding: isValid(userPolicy) and isBooleanTrue(userPolicy.forceRemoteSourceTranscoding),
transcodeReasons: getTranscodeReasons(manifestTranscodingUrl)
})
manifestFormat = ""
deviceLoadsUpstream = false
if upstreamBlocker = UpstreamManifestBlocker.NONE
manifestFormat = upstreamManifestFormat(manifestSource)
deviceLoadsUpstream = upstreamManifestAnswers(manifestSource.Path, manifestFormat)
end if
' A source the server had never probed (a .strm item before its first play) listed no streams,
' so the audio and subtitle picks above had nothing to choose from. This PlaybackInfo probed it
' and the server saved what it found, so read the item again and pick from that.
if not isLive and sourceListsNoStreams(playedSource)
reloadProbedStreams(meta, mediaSourceId)
probedSource = mediaSourceForId(meta.mediaSourcesData.mediaSources, mediaSourceId)
if not sourceListsNoStreams(probedSource)
if m.top.selectedAudioStreamIndex = 0
audioStreamIdx = selectAudioStreamIndex(probedSource.MediaStreams, userSettings, userSession.config, meta.originalLanguage ?? "")
end if
sentSubtitleIdx = subtitleIdx
if m.top.selectedSubtitleIndex = SubtitleSelection.NOT_SET
subtitleIdx = defaultSubtitleTrackFromVid(meta, audioStreamIdx, mediaSourceId)
end if
serverAllowedFirst = isValid(manifestSource) and isBooleanTrue(manifestSource.SupportsDirectPlay)
answerPlays = serverAnswerPlays(upstreamBlocker, deviceLoadsUpstream, serverAllowedFirst)
m.log.info("Tracks picked from the streams the first PlaybackInfo probed", "audioStreamIdx", audioStreamIdx, "subtitleIdx", subtitleIdx, "answerPlays", answerPlays, "itemId", video.id)
' A transcode was built for the server's own picks, so ask for one built for ours, unless
' the device plays the upstream URL or the player's retry asks the server with our picks.
if answerPlays and probedPicksNeedNewAnswer(manifestSource, audioStreamIdx, subtitleIdx, sentSubtitleIdx)
res = ItemPostPlaybackInfoResponse(video.id, mediaSourceId, audioStreamIdx, subtitleIdx, playbackPosition, meta, bypassDoviPreservation, forceTranscoding)
if isValid(res) and res.ok and isValid(res.json) and isValidAndNotEmpty(res.json.MediaSources) and not isValidAndNotEmpty(res.json.ErrorCode)
m.playbackInfo = res.json
else
' Play the first answer, and describe it as the server built it.
m.log.warn("PlaybackInfo for the probed picks failed; keeping the server's", "cause", apiResponse.jsonFailure(res), "itemId", video.id)
audioStreamIdx = serverAudioIndex(manifestSource)
subtitleIdx = sentSubtitleIdx
end if
end if
end if
end if
' 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
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
' A source whose URL is an upstream HLS or DASH manifest (a Live TV channel, a .strm item) plays
' from that URL whenever the server had no reason of its own against it (getUpstreamManifestBlocker
' says which), even though Jellyfin 12.0+ declines direct play for every manifest. Whatever the
' server answered, the device must be able to load the URL itself first: one it cannot reach leaves
' the Roku buffering rather than failing, so the fallback to the server would not run in time.
' The blocker and that check ran on the first answer, above; whether the server allowed direct
' play is read from the answer that plays.
if upstreamBlocker = UpstreamManifestBlocker.NONE
serverAllowed = isBooleanTrue(video.isDirectPlaySupported)
if deviceLoadsUpstream
video.isDirectPlaySupported = true
' If the device cannot play the stream after all, the player retries through the server.
video.isTranscodeAvailable = true
m.log.info("Playing the upstream manifest URL", "format", manifestFormat, "serverAllowed", serverAllowed, "isLive", isLive, "itemId", video.id)
else if serverAllowed
' The server's "yes" carries no stream of its own (no TranscodingUrl), so the player asks it
' for one before playing anything: the retry a failed direct play takes.
video.needsServerStream = true
video.isTranscodeAvailable = true
m.log.info("This device cannot load the upstream manifest URL the server allowed; asking the server for its stream", "format", manifestFormat, "isLive", isLive, "itemId", video.id)
else
m.log.info("This device cannot load the upstream manifest URL; using the server's stream", "format", manifestFormat, "isLive", isLive, "itemId", video.id)
end if
else
m.log.debug("Source keeps the server's answer", "blocker", upstreamBlocker, "isLive", isLive, "itemId", video.id)
end if
' 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 (for example 5.1)
' even though the actual decode workload is within Roku's capability. But for resolutions
' above the hardware limit (for example 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)
if not isValid(video.content) then return
streamFormat = directPlayStreamFormat(m.playbackInfo.MediaSources[0], isLive)
if streamFormat <> "" then video.content.StreamFormat = streamFormat
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
' upstreamManifestAnswers: Whether THIS device can load a source's upstream manifest URL.
'
' The device check before playing a source from its own URL (getUpstreamManifestBlocker). The server
' reaching a URL says nothing about the Roku reaching it, and a Roku handed one it cannot reach does
' not error: an unroutable host or an unresolvable name leaves it buffering, so the direct-play
' fallback does not run. A GET within timeouts.UPSTREAM_PREFLIGHT_MS that returns a manifest of the
' declared format is the answer (isUpstreamManifestResponse); anything else uses the server's stream.
' Raw roUrlTransfer on this Task thread, no Jellyfin auth (the URL is not Jellyfin's) and the URL is
' never logged, because upstream URLs carry session tokens.
'
' @param {dynamic} url - the source's upstream manifest URL (MediaSource.Path)
' @param {string} format - "hls" or "dash" (upstreamManifestFormat)
' @returns {boolean}
function upstreamManifestAnswers(url as dynamic, format as string) as boolean
target = textOrEmpty(url)
if target = "" then return false
req = CreateObject("roUrlTransfer")
port = CreateObject("roMessagePort")
req.setMessagePort(port)
req.setUrl(target)
if LCase(Left(target, 8)) = "https://" then req.setCertificatesFile("common:/certs/ca-bundle.crt")
' The player decodes a gzip-encoded manifest, so the check must too: a CDN serving
' pre-compressed objects sends one even to a request that did not ask for it.
req.EnableEncodings(true)
elapsed = CreateObject("roTimespan")
elapsed.mark()
if not req.AsyncGetToString() then return false
remaining = timeouts.UPSTREAM_PREFLIGHT_MS
while remaining > 0
msg = wait(remaining, port)
if type(msg) = "roUrlEvent"
fetchedMs = elapsed.TotalMilliseconds()
answers = isUpstreamManifestResponse(format, msg.GetResponseCode(), msg.GetString())
m.log.info("Upstream manifest check", "answers", answers, "responseCode", msg.GetResponseCode(), "fetchMs", fetchedMs, "checkMs", elapsed.TotalMilliseconds() - fetchedMs)
return answers
end if
remaining = timeouts.UPSTREAM_PREFLIGHT_MS - elapsed.TotalMilliseconds()
end while
req.AsyncCancel()
m.log.info("Upstream manifest check timed out", "format", format, "ms", elapsed.TotalMilliseconds())
return false
end function
' 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
' reloadProbedStreams: Replace the item's media sources with the ones the server saved when the
' first PlaybackInfo probed them. Leaves meta as it was when the read fails, so the caller's picks
' stand and nothing else changes.
'
' @param {object} meta - item metadata (from ItemMetaData), updated in place
' @param {dynamic} mediaSourceId - the MediaSource being played
sub reloadProbedStreams(meta as object, mediaSourceId as dynamic)
probed = ItemMetaData(meta.id)
if not isValid(probed) or not isValid(probed.mediaSourcesData) then return
meta.mediaSourcesData = probed.mediaSourcesData
probedSource = mediaSourceForId(probed.mediaSourcesData.mediaSources, mediaSourceId)
streamCount = 0
if isValid(probedSource) and isValid(probedSource.MediaStreams) then streamCount = probedSource.MediaStreams.count()
m.log.info("Reloaded the streams the first PlaybackInfo probed", "streamCount", streamCount, "itemId", meta.id)
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)
playedSource = mediaSourceForId(meta.mediaSourcesData.mediaSources, mediaSourceId)
if isValid(playedSource) then mediaStreams = playedSource.MediaStreams
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, meta.originalLanguage ?? "")
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
' 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
' The list starts at the current episode. Queue what follows it once per episode, leaving
' out any other copy of the current one (episodeQueue); each starts fresh when the queue
' arrives at it (QueueManager.advanceTo).
following = []
for i = 1 to data.Items.Count() - 1
following.push(data.Items[i])
end for
for each episode in episodeQueue.collapseCopies(following, episodeQueue.episodeKey(data.Items[0]), "nextEpisodes-copies")
queueManager.callFunc("push", nodeHelpers.createQueueItem(episode))
end for
end if
end sub