import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/image.bs"
import "pkg:/source/constants/timeouts.bs"
import "pkg:/source/data/JellyfinDataTransformer.bs"
import "pkg:/source/enums/SubtitleSelection.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/mediaSegments.bs"
import "pkg:/source/utils/misc.bs"
function ItemPostPlaybackInfo(id as string, mediaSourceId = "" as string, audioTrackIndex = -1 as integer, subtitleTrackIndex = SubtitleSelection.NONE as integer, startTimeTicks = 0& as longinteger, videoMetadata = invalid as dynamic, bypassDoviPreservation = false as boolean, forceTranscoding = false as boolean)
globalUser = m.global.user
postData = {
"UserId": globalUser.id,
"StartTimeTicks": startTimeTicks,
"AutoOpenLiveStream": true
' "AlwaysBurnInSubtitleWhenTranscoding": true
}
postData.DeviceProfile = getDeviceProfile()
' Dynamically adjust transcoding segment length and bitrate based on the video's
' bitrate, duration, and the device's video buffer size to prevent buffer overflow
' and playlist-too-large errors during HLS playback.
hasMediaSource = isValid(videoMetadata) and isValid(videoMetadata.mediaSourcesData) and isValidAndNotEmpty(videoMetadata.mediaSourcesData.mediaSources)
if hasMediaSource
mediaSource = videoMetadata.mediaSourcesData.mediaSources[0]
sourceBitrate& = 0
videoDurationSeconds = 0
if isValid(mediaSource.Bitrate)
sourceBitrate& = mediaSource.Bitrate
end if
if isValid(mediaSource.RunTimeTicks) and mediaSource.RunTimeTicks > 0
videoDurationSeconds = Int(mediaSource.RunTimeTicks / 10000000)
end if
if sourceBitrate& > 0 and videoDurationSeconds > 0
bufferSize& = getDeviceBufferSize()
transcodingParams = calculateOptimalTranscodingParams(sourceBitrate&, videoDurationSeconds, bufferSize&)
' Apply optimized segment length to all video transcoding profiles
if isValid(postData.DeviceProfile.TranscodingProfiles)
for each profile in postData.DeviceProfile.TranscodingProfiles
if isValid(profile.Type) and profile.Type = "Video"
profile.SegmentLength = transcodingParams.segmentLength
end if
end for
end if
' Reduce max streaming bitrate only when segment length alone isn't enough.
' Never set MaxStreamingBitrate below the reported source bitrate + 10% headroom —
' Jellyfin would reject a remux/direct-stream as exceeding the limit.
if transcodingParams.maxBitrate > 0
minAllowedBitrate& = sourceBitrate& * 11& \ 10&
if transcodingParams.maxBitrate >= minAllowedBitrate&
postData.DeviceProfile.MaxStreamingBitrate = transcodingParams.maxBitrate
end if
end if
end if
if videoDurationSeconds = 0
applyLiveTvMinSegments(postData)
end if
end if
if subtitleTrackIndex <> SubtitleSelection.NONE and subtitleTrackIndex <> SubtitleSelection.NOT_SET
postData.SubtitleStreamIndex = subtitleTrackIndex
end if
applyMediaSourceToPostData(postData, mediaSourceId, forceTranscoding)
mediaStreams = invalid
if isValid(videoMetadata) and isValid(videoMetadata.mediaSourcesData) and isValidAndNotEmpty(videoMetadata.mediaSourcesData.mediaSources) and isValid(videoMetadata.mediaSourcesData.mediaSources[0].MediaStreams)
mediaStreams = videoMetadata.mediaSourcesData.mediaSources[0].MediaStreams
end if
' DOVI not supported using MKV container, so force remux for DOVI content
' Only run DOVI logic if the user setting exists and is enabled, and caller has not bypassed it
' (bypassDoviPreservation is set when retrying after a buffer overflow to allow direct play)
if not bypassDoviPreservation and isValid(mediaStreams) and isValid(globalUser.settings.playbackPreserveDovi) and globalUser.settings.playbackPreserveDovi
' Check if device supports DOVI before proceeding
deviceSupportsDovi = false
di = CreateObject("roDeviceInfo")
if canPlay4k()
dp = di.GetDisplayProperties()
if dp.DolbyVision
deviceSupportsDovi = true
end if
end if
' Only proceed with DOVI logic if device supports DOVI
if deviceSupportsDovi
for each stream in mediaStreams
' always use the first video stream for now
' Note: don't force remux on AV1 codec - see #194
if isValid(stream.Type) and stream.Type = "Video" and isValid(stream.Codec) and LCase(stream.Codec) <> "av1"
' Determine container string
containerString = invalid
if isValid(videoMetadata.container)
containerString = LCase(videoMetadata.container)
end if
' DOVI remux logic requires VideoRangeType which was added in Jellyfin 10.9
' Only apply this for API v2+ servers (10.9+)
apiVersion = getApiVersionFromGlobal()
if apiVersion >= 2 and isValid(containerString) and containerString = "mkv" and isValid(stream.VideoRangeType) and stream.VideoRangeType <> ""
' is DOVI in the string?
if inStr(LCase(stream.VideoRangeType), "dovi") > 0
' add a condition to the mkv container profile to force remux (container swap)
postData.DeviceProfile.ContainerProfiles.push({
"Type": "Video",
"Container": "mkv",
"Conditions": [
{
"Condition": "NotEquals",
"Property": "VideoRangeType",
"Value": stream.VideoRangeType,
"IsRequired": true
}
]
})
end if
end if
exit for
end if
end for
end if
end if
' Cap H264 codec profile when the source video is H264.
' Applied dynamically here rather than in the static device profile so it doesn't
' interfere with non-H264 content (e.g. HEVC DoVi remux needs unrestricted resolution).
' 1080p (1920x1080) is the H264 hardware ceiling on all Roku devices, but a lower
' user resolution cap is respected if set.
if isValid(mediaStreams)
for each stream in mediaStreams
if isValid(stream.Type) and stream.Type = "Video" and isValid(stream.Codec) and LCase(stream.Codec) = "h264"
if isValid(postData.DeviceProfile.CodecProfiles)
' Start from the H264 hardware ceiling, then clamp down to the user's cap if lower
h264CapHeight = 1080
h264CapWidth = 1920
userResConditions = getResolutionConditions()
for each condition in userResConditions
if condition.Property = "Height" and condition.Value.toInt() < h264CapHeight
h264CapHeight = condition.Value.toInt()
end if
if condition.Property = "Width" and condition.Value.toInt() < h264CapWidth
h264CapWidth = condition.Value.toInt()
end if
end for
for each codecProfile in postData.DeviceProfile.CodecProfiles
' Codec field is a comma-separated list of aliases set by getCodecProfiles().
' Known values: "h264,avc", "h265,hevc", "vp9", "vp8", "av1", "mpeg2video", "mpeg4".
' Substring match is safe: no other codec contains "h264" as a substring.
if isValid(codecProfile.Codec) and inStr(1, LCase(codecProfile.Codec), "h264") > 0
applyResolutionCapToProfile(codecProfile, h264CapHeight, h264CapWidth, true)
end if
end for
end if
exit for
end if
end for
end if
if audioTrackIndex > -1
if isValid(mediaStreams)
' Find the audio stream with the matching Jellyfin index
selectedAudioStream = invalid
for each stream in mediaStreams
if isValid(stream.index) and stream.index = audioTrackIndex
selectedAudioStream = stream
exit for
end if
end for
if isValid(selectedAudioStream)
postData.AudioStreamIndex = audioTrackIndex
' Get channel count for AAC handling logic
channelCount = 2 ' default stereo
if isValid(selectedAudioStream.Channels)
if type(selectedAudioStream.Channels) = "roString" or type(selectedAudioStream.Channels) = "String"
channelCount = selectedAudioStream.Channels.ToInt()
else if type(selectedAudioStream.Channels) = "roInt" or type(selectedAudioStream.Channels) = "Integer"
channelCount = selectedAudioStream.Channels
end if
end if
' Check if device has surround passthrough by looking at MaxAudioChannels in TranscodingProfiles
hasPassthruSupport = false
if isValid(postData.DeviceProfile) and isValid(postData.DeviceProfile.TranscodingProfiles)
for each profile in postData.DeviceProfile.TranscodingProfiles
if isValid(profile.Type) and profile.Type = "Video" and isValid(profile.MaxAudioChannels)
profileMaxChannels = 0
if type(profile.MaxAudioChannels) = "roString" or type(profile.MaxAudioChannels) = "String"
profileMaxChannels = profile.MaxAudioChannels.ToInt()
else if type(profile.MaxAudioChannels) = "roInt" or type(profile.MaxAudioChannels) = "Integer"
profileMaxChannels = profile.MaxAudioChannels
end if
if profileMaxChannels > 2
hasPassthruSupport = true
exit for
end if
end if
end for
end if
' Tailor the device profile's transcoding AudioCodec list to the source in two scenarios:
' 1. Multichannel source (>2ch) with passthrough support - drops stereo-output codecs so
' the server only offers surround variants (eac3/ac3/dts) in the HLS master playlist.
' Without this, Roku's HLS player has been observed to pick the stereo variant.
' 2. AAC source with unsupported profile (Main, HE-AAC) - drops AAC so transcoding
' falls through to MP3. TODO: remove once server transcodes between AAC profiles.
shouldOptimize = false
if channelCount > 2 and hasPassthruSupport
shouldOptimize = true
end if
if isValid(selectedAudioStream.Codec) and LCase(selectedAudioStream.Codec) = "aac"
if isValid(selectedAudioStream.Profile) and (LCase(selectedAudioStream.Profile) = "main" or LCase(selectedAudioStream.Profile) = "he-aac")
shouldOptimize = true
end if
end if
if shouldOptimize
optimizeAudioCodecListForSource(postData.DeviceProfile, channelCount)
end if
end if
end if
end if
return fetchJson(GetApi().BuildPostPlaybackInfoRequest(id, postData), "playbackInfo")
end function
' Render-thread wrapper: delegates search to SearchTask (task thread) and blocks until complete.
' Used by Main.bs SearchBox flow which shows a ProgressDialog while waiting.
function searchMedia(query as string) as object
if query = "" then return { Items: [], TotalRecordCount: 0 }
searchTask = CreateObject("roSGNode", "SearchTask")
port = CreateObject("roMessagePort")
searchTask.observeField("results", port)
searchTask.query = query
searchTask.control = "RUN"
' Block until SearchTask completes (timeouts.HTTP_MS)
msg = wait(timeouts.HTTP_MS, port)
searchTask.unobserveField("results")
searchTask.control = "STOP"
if isValid(msg) and isValid(searchTask.results)
return searchTask.results
end if
return { Items: [], TotalRecordCount: 0 }
end function
' MetaData about an item — returns a JellyfinBaseItem node.
' @param {string} fields - Comma-separated Jellyfin fields to request. Defaults to Chapters and Trickplay only.
function ItemMetaData(id as string, fields = "Chapters,Trickplay" as string, forceRefresh = false as boolean)
req = GetApi().BuildGetItemRequest(id, { "fields": fields })
if forceRefresh and isValid(req)
req.headers = { "Cache-Control": "no-cache" }
end if
data = fetchJson(req, "itemMetaData")
if not isValid(data) then return invalid
transformer = JellyfinDataTransformer()
return transformer.transformBaseItem(data)
end function
' MetaData for an item detail screen — includes People, Genres, and Studios in addition to
' Chapters and Trickplay. Use this instead of ItemMetaData() when populating a detail view.
function ItemDetailsMetaData(id as string, forceRefresh = false as boolean)
return ItemMetaData(id, "Chapters,Trickplay,Genres,Studios,People", forceRefresh)
end function
' Music Artist Data
function ArtistOverview(name as string)
data = fetchJson(GetApi().BuildGetArtistByNameRequest(name), "artistOverview")
if not isValid(data) then return invalid
return data.overview
end function
' Get list of albums where this artist is the album artist (strict: AlbumArtistIds only).
' Sorted by year descending so the discography reads newest-first.
' Does NOT include albums the artist merely contributes to — see AppearsOnList().
function MusicAlbumList(id as string)
data = fetchJson(GetApi().BuildGetItemsByQueryRequest({
"AlbumArtistIds": id,
"includeitemtypes": "MusicAlbum",
"sortBy": "PremiereDate,ProductionYear,SortName",
"SortOrder": "Descending",
"Recursive": true
}), "albumList")
if not isValid(data) then return invalid
if not isValid(data.Items) then return invalid
transformer = JellyfinDataTransformer()
results = []
for each item in data.Items
results.push(transformer.transformBaseItem(item))
end for
data.Items = results
return data
end function
' Get list of albums an artist appears on as a contributing artist (not album artist).
' Uses ContributingArtistIds which Jellyfin defines as artists who appear on the album
' but are NOT tagged as the album artist — so this row is mutually exclusive with MusicAlbumList().
' Sorted by year descending.
function AppearsOnList(id as string)
data = fetchJson(GetApi().BuildGetItemsByQueryRequest({
"ContributingArtistIds": id,
"includeitemtypes": "MusicAlbum",
"sortBy": "PremiereDate,ProductionYear,SortName",
"SortOrder": "Descending",
"Recursive": true
}), "appearsOn")
if not isValid(data) then return invalid
if not isValid(data.Items) then return invalid
transformer = JellyfinDataTransformer()
results = []
for each item in data.Items
results.push(transformer.transformBaseItem(item))
end for
data.Items = results
return data
end function
' Get list of songs belonging to an artist
function GetSongsByArtist(id as string, params = {} as object)
paramArray = {
"AlbumArtistIds": id,
"includeitemtypes": "Audio",
"sortBy": "SortName",
"Recursive": true
}
' overwrite defaults with the params provided
for each param in params
paramArray.AddReplace(param, params[param])
end for
data = fetchJson(GetApi().BuildGetItemsByQueryRequest(paramArray), "songsByArtist")
results = []
if not isValid(data) then return invalid
if not isValid(data.Items) then return invalid
if data.Items.Count() = 0 then return invalid
transformer = JellyfinDataTransformer()
for each item in data.Items
results.push(transformer.transformBaseItem(item))
end for
data.Items = results
return data
end function
' Get all Audio tracks by an artist in any capacity (album artist OR contributing artist).
' Broader than GetSongsByArtist (which uses AlbumArtistIds). Used for the artist Songs row
' so songs on compilation albums or items with mismatched album-level tags still surface.
' @param {string} id - MusicArtist item ID
function GetSongsByArtistBroad(id as string) as dynamic
data = fetchJson(GetApi().BuildGetItemsByQueryRequest({
"ArtistIds": id,
"includeitemtypes": "Audio",
"sortBy": "Album,ParentIndexNumber,IndexNumber,SortName",
"Recursive": true,
"Limit": 100,
"EnableTotalRecordCount": false
}), "songsByArtistBroad")
if not isValid(data) then return invalid
if not isValid(data.Items) then return invalid
if data.Items.Count() = 0 then return invalid
transformer = JellyfinDataTransformer()
results = []
for each item in data.Items
results.push(transformer.transformBaseItem(item))
end for
data.Items = results
return data
end function
' Get Songs that are on an Album
function MusicSongList(id as string)
data = fetchJson(GetApi().BuildGetItemsByQueryRequest({
"parentId": id,
"includeitemtypes": "Audio",
"sortBy": "SortName"
}), "songList")
results = []
if not isValid(data) then return invalid
if not isValid(data.Items) then return invalid
if data.Items.Count() = 0 then return invalid
transformer = JellyfinDataTransformer()
for each item in data.Items
results.push(transformer.transformBaseItem(item))
end for
data.Items = results
return data
end function
' Get audio item metadata (raw, no transform)
function AudioItem(id as string)
return fetchJson(GetApi().BuildGetItemRawRequest(id, {
"includeitemtypes": "Audio",
"sortBy": "SortName"
}), "audioItem")
end function
' Get Intro Videos for an item
function GetIntroVideos(id as string)
return fetchJson(GetApi().BuildGetIntrosRequest(id), "intros")
end function
' GetMediaSegments: Fetches media segments for an item (Intro, Outro, Recap, etc.).
'
' Only available on Jellyfin 10.10.0+ servers. Returns invalid for older servers.
'
' @param {string} id - The item ID
' @returns {dynamic} - { Items: [MediaSegmentDto], TotalRecordCount } or invalid
function GetMediaSegments(id as string)
if not supportsMediaSegments() then return invalid
return fetchJson(GetApi().BuildGetMediaSegmentsRequest(id), "mediaSegments")
end function
function AudioStream(id as string)
songData = AudioItem(id)
if isValid(songData)
content = createObject("RoSGNode", "ContentNode")
if isValid(songData.title)
content.title = songData.title
end if
playbackInfo = ItemPostPlaybackInfo(songData.id, songData.mediaSources[0].id)
if isValid(playbackInfo)
content.id = playbackInfo.PlaySessionId
if useTranscodeAudioStream(playbackInfo)
' Transcode the audio
audioUrl = buildURL(playbackInfo.mediaSources[0].TranscodingURL)
if not isValid(audioUrl) then return invalid
content.url = audioUrl
else
' Direct Stream the audio
params = {
"Static": "true",
"Container": songData.mediaSources[0].container,
"MediaSourceId": songData.mediaSources[0].id
}
content.streamformat = songData.mediaSources[0].container
audioUrl = buildURL(Substitute("Audio/{0}/stream", songData.id), params)
if not isValid(audioUrl) then return invalid
content.url = audioUrl
end if
else
return invalid
end if
return content
else
return invalid
end if
end function
function useTranscodeAudioStream(playbackInfo)
return isValid(playbackInfo.mediaSources[0]) and isValid(playbackInfo.mediaSources[0].TranscodingURL)
end function
' Get lyrics for an Audio item.
' @param {string} itemId - The Audio item ID
' @returns {dynamic} LyricDto: { Metadata: {...}, Lyrics: [{Start: ticks, Text: string}] }, or invalid
function GetItemLyrics(itemId as string) as dynamic
return fetchJson(GetApi().BuildGetItemLyricsRequest(itemId), "lyrics")
end function
function BackdropImage(id as string)
' Use UI resolution for backdrop images
localDevice = m.global.device
imgParams = { "maxHeight": localDevice.uiResolution[1], "maxWidth": localDevice.uiResolution[0] }
return ImageURL(id, "Backdrop", imgParams)
end function
' Seasons for a TV Show
function TVSeasons(id as string) as dynamic
data = fetchJson(GetApi().BuildGetSeasonsRequest(id), "seasons")
' validate data
if not isValid(data) or not isValid(data.Items) then return invalid
transformer = JellyfinDataTransformer()
results = []
for each item in data.Items
results.push(transformer.transformBaseItem(item))
end for
data.Items = results
return data
end function
' applyLiveTvMinSegments: For HLS transcodes that report no source duration (live channels),
' request ≥8 segments per Roku's recommendation. Gives ~48 s seekable DVR window.
sub applyLiveTvMinSegments(postData as object)
if not isValid(postData) then return
if not isValid(postData.DeviceProfile) then return
if not isValid(postData.DeviceProfile.TranscodingProfiles) then return
for each profile in postData.DeviceProfile.TranscodingProfiles
if isValid(profile.Type) and profile.Type = "Video"
profile.MinSegments = 8
end if
end for
end sub
' applyMediaSourceToPostData: Applies the media source ID or live TV retry flags to a postData object.
' Live TV is detected by an empty mediaSourceId. On the first attempt, direct play is not disabled
' so the server can evaluate compatibility (matching web client behaviour). On retry,
' forceTranscoding=true sets EnableDirectPlay=false so the server provides a transcode URL instead.
'
' @param {object} postData - The request body assoc array to modify
' @param {string} mediaSourceId - Media source ID, or "" for live TV
' @param {boolean} forceTranscoding - True when retrying with forced transcoding
sub applyMediaSourceToPostData(postData as object, mediaSourceId as string, forceTranscoding as boolean)
if mediaSourceId <> ""
postData.MediaSourceId = mediaSourceId
else
if forceTranscoding
postData.EnableDirectPlay = false
end if
end if
end sub
' Tailors a video transcoding profile's AudioCodec list to the current source's channel count
' so the server (and downstream HLS variant selection) can't fall back to a less-desirable codec.
'
' Multichannel source (>2ch):
' Strips ALL stereo-output codecs (aac, mp3, flac, alac, pcm, lpcm, wav, vorbis). Without this,
' Jellyfin's HLS muxer offers BOTH the surround codec AND a stereo fallback as separate variants
' in the master playlist, and Roku's HLS player has been observed (via in-house testing on
' physical hardware; no upstream tracking issue at time of writing) to pick the stereo variant —
' defeating the entire point of having surround passthrough hardware. Caller must already have
' verified the device has surround passthrough before invoking with channelCount > 2.
'
' Stereo source (≤2ch):
' Strips surround passthrough codecs (eac3, ac3, dts) plus aac so transcoding falls through to
' mp3. Avoids handing the server a surround target codec for content that's only 2ch anyway.
sub optimizeAudioCodecListForSource(deviceProfile as object, channelCount as integer)
' Validate inputs
if not isValid(deviceProfile) then return
if not isValid(deviceProfile.TranscodingProfiles) then return
' Codecs that, as a server transcode target, can't carry surround channels — strip these for
' multichannel sources so the server is forced to pick a surround codec from the remaining list.
' Note: this list is intentionally distinct from `stereoOutputCodecs` in deviceCapabilities.bs;
' that list represents Roku decode-path behavior (different concept), so the contents differ.
stereoOnlyCodecs = ["aac", "mp3", "flac", "alac", "pcm", "lpcm", "wav", "vorbis"]
' Surround passthrough codecs — pointless for stereo sources, strip them.
surroundCodecs = ["eac3", "ac3", "dts"]
for each rule in deviceProfile.TranscodingProfiles
if isValid(rule.Type) and rule.Type = "Video"
if isValid(rule.AudioCodec)
codecList = rule.AudioCodec.split(",")
newCodecList = []
for each codec in codecList
skipCodec = false
if channelCount > 2 and arrayHasValue(stereoOnlyCodecs, codec)
skipCodec = true
end if
if channelCount <= 2 and (codec = "aac" or arrayHasValue(surroundCodecs, codec))
skipCodec = true
end if
if not skipCodec
newCodecList.push(codec)
end if
end for
if newCodecList.count() = 0 and codecList.count() > 0
print "[optimizeAudioCodecListForSource] WARN: AudioCodec list for container '" + rule.Container + "' became empty after optimization (source ch=" + channelCount.toStr() + ", original=" + rule.AudioCodec + "); server may reject the transcoding rule"
end if
rule.AudioCodec = newCodecList.join(",")
end if
end if
end for
end sub