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"
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
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]) and 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
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": 500, "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
' 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 = ""
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
addSubtitlesToVideo(video, meta)
' Enable default subtitle track
if subtitle_idx = SubtitleSelection.notset
defaultSubtitleIndex = defaultSubtitleTrackFromVid(meta, audio_stream_idx)
if defaultSubtitleIndex <> SubtitleSelection.none
video.SelectedSubtitle = defaultSubtitleIndex
subtitle_idx = defaultSubtitleIndex
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
addSubtitlesToVideo(video, meta)
else
video.SelectedSubtitle = SubtitleSelection.none
end if
else
video.SelectedSubtitle = subtitle_idx
end if
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
fully_external = false
' 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
addVideoContentURL(video, mediaSourceId, audio_stream_idx, fully_external)
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)
if not fully_external
video.content = authRequest(video.content)
end if
end sub
' defaultSubtitleTrackFromVid: Identifies the default subtitle track given metadata and audio index
'
' @param {object} meta - metadata object containing MediaStreams
' @param {integer} selectedAudioIndex - index of selected audio stream
' @return {integer} indicating the default track's server-side index. Defaults to {SubtitleSelection.none} if one is not found
function defaultSubtitleTrackFromVid(meta as object, selectedAudioIndex as integer) as integer
userSession = m.global.user
if userSession.config.subtitleMode = "None"
return SubtitleSelection.none ' No subtitles desired: return none
end if
if not isValid(meta) then return SubtitleSelection.none
if not isValid(meta.json) then return SubtitleSelection.none
if not isValidAndNotEmpty(meta.json.mediaSources) then return SubtitleSelection.none
if not isValidAndNotEmpty(meta.json.MediaSources[0].MediaStreams) then return SubtitleSelection.none
subtitles = sortSubtitles(meta.id, meta.json.MediaSources[0].MediaStreams)
selectedAudioLanguage = ""
' Find the audio stream with the matching Jellyfin index
audioMediaStream = invalid
for each stream in meta.json.MediaSources[0].MediaStreams
if isValid(stream.index) and stream.index = selectedAudioIndex
audioMediaStream = stream
exit for
end if
end for
' Ensure audio media stream is valid before using language property
if isValid(audioMediaStream)
selectedAudioLanguage = audioMediaStream.Language ?? ""
end if
defaultTextSubs = defaultSubtitleTrack(subtitles["text"], selectedAudioLanguage, true) ' Find correct subtitle track (forced text)
if defaultTextSubs <> SubtitleSelection.none
return defaultTextSubs
end if
if not userSession.settings.playbackSubsOnlyText
return defaultSubtitleTrack(subtitles["all"], selectedAudioLanguage) ' if no appropriate text subs exist, allow non-text
end if
return SubtitleSelection.none
end function
' defaultSubtitleTrack:
'
' @param {dynamic} sortedSubtitles - array of subtitles sorted by type and language
' @param {string} selectedAudioLanguage - language for selected audio track
' @param {boolean} [requireText=false] - indicates if only text subtitles should be considered
' @return {integer} indicating the default track's server-side index. Defaults to {SubtitleSelection.none} is one is not found
function defaultSubtitleTrack(sortedSubtitles, selectedAudioLanguage as string, requireText = false as boolean) as integer
' ONE rendezvous to get user node
localUser = m.global.user
subtitleMode = isValid(localUser.config.subtitleMode) ? LCase(localUser.config.subtitleMode) : ""
allowSmartMode = false
' Only evaluate selected audio language if we have a value
if selectedAudioLanguage <> ""
allowSmartMode = selectedAudioLanguage <> localUser.config.subtitleLanguagePreference
end if
for each item in sortedSubtitles
' Only auto-select subtitle if language matches SubtitleLanguagePreference
languageMatch = true
if localUser.config.subtitleLanguagePreference <> ""
languageMatch = (localUser.config.subtitleLanguagePreference = item.Track.Language)
end if
' Ensure textuality of subtitle matches preferenced passed as arg
matchTextReq = ((requireText and item.IsTextSubtitleStream) or not requireText)
if languageMatch and matchTextReq
if subtitleMode = "default" and (item.isForced or item.IsDefault)
' Return first forced or default subtitle track
return item.Index
else if subtitleMode = "always"
' Return the first found subtitle track
return item.Index
else if subtitleMode = "onlyforced" and item.IsForced
' Return first forced subtitle track
return item.Index
else if subtitleMode = "smart" and allowSmartMode
' Return the first found subtitle track
return item.Index
end if
end if
end for
' User has chosed smart subtitle mode
' We already attempted to load subtitles in preferred language, but none were found.
' Fall back to default behaviour while ignoring preferredlanguage
if subtitleMode = "smart" and allowSmartMode
for each item in sortedSubtitles
' Ensure textuality of subtitle matches preferenced passed as arg
matchTextReq = ((requireText and item.IsTextSubtitleStream) or not requireText)
if matchTextReq
if item.isForced or item.IsDefault
' Return first forced or default subtitle track
return item.Index
end if
end if
end for
end if
return SubtitleSelection.none ' Keep current default behavior of "None", if no correct subtitle is identified
end function
sub addVideoContentURL(video, mediaSourceId, audio_stream_idx, fully_external)
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
fully_external = true
video.content.url = m.playbackInfo.MediaSources[0].Path
end if
else
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
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(meta.id, 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
'Checks available subtitle tracks and puts subtitles in forced, default, and non-default/forced but preferred language at the top
function sortSubtitles(id as string, MediaStreams)
tracks = { "forced": [], "default": [], "normal": [], "text": [] }
' ONE rendezvous to get user node
localUser = m.global.user
prefered_lang = localUser.config.subtitleLanguagePreference
for each stream in MediaStreams
if stream.type = "Subtitle"
url = ""
if isValid(stream.DeliveryUrl)
url = buildURL(stream.DeliveryUrl)
end if
stream = {
"Track": { "Language": stream.language, "Description": stream.displaytitle, "TrackName": url },
"IsTextSubtitleStream": stream.IsTextSubtitleStream,
"Index": stream.index,
"IsDefault": stream.IsDefault,
"IsForced": stream.IsForced,
"IsExternal": stream.IsExternal,
"IsEncoded": stream.DeliveryMethod = "Encode"
}
if stream.isForced
trackType = "forced"
else if stream.IsDefault
trackType = "default"
else
trackType = "normal"
end if
if prefered_lang <> "" and prefered_lang = stream.Track.Language
tracks[trackType].unshift(stream)
if stream.IsTextSubtitleStream
tracks["text"].unshift(stream)
end if
else
tracks[trackType].push(stream)
if stream.IsTextSubtitleStream
tracks["text"].push(stream)
end if
end if
end if
end for
tracks["default"].append(tracks["normal"])
tracks["forced"].append(tracks["default"])
return { "all": tracks["forced"], "text": tracks["text"] }
end function