import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/Image.bs"
import "pkg:/source/data/JellyfinDataTransformer.bs"
import "pkg:/source/enums/SubtitleSelection.bs"
import "pkg:/source/utils/deviceCapabilities.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.
if isValid(videoMetadata) and isValid(videoMetadata.mediaSourcesData) and isValidAndNotEmpty(videoMetadata.mediaSourcesData.mediaSources)
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 source bitrate + 10% headroom - doing
' so would cause Jellyfin to 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
end if
if subtitleTrackIndex <> SubtitleSelection.none and subtitleTrackIndex <> SubtitleSelection.notset
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
' print "ItemPostPlaybackInfo videoMetadata =", videoMetadata
' print "ItemPostPlaybackInfo videoMetadata.mediaSourcesData =", videoMetadata.mediaSourcesData
' print "ItemPostPlaybackInfo mediaStreams =", 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
' print "deviceSupportsDovi mediaStreams stream =", stream
' 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
if isValid(codecProfile.Codec) and LCase(codecProfile.Codec) = "h264"
applyResolutionCapToProfile(codecProfile, h264CapHeight, h264CapWidth)
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
' print "ItemPostPlaybackInfo mediaStreams stream =", stream
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
' Remove AAC from codec list in two scenarios:
' 1. ANY multichannel source (>2ch) when user has passthrough support - prevents transcoding to AAC which would downmix to stereo
' 2. Unsupported AAC profiles (Main, HE-AAC) - TODO: Remove after server supports transcoding between AAC profiles
shouldRemoveAac = false
' Scenario 1: ANY multichannel source with passthrough support
' Removes AAC to force server to use surround passthrough codecs (eac3, ac3, dts) instead of transcoding to stereo AAC
if channelCount > 2 and hasPassthruSupport
shouldRemoveAac = true
end if
' Scenario 2: AAC with unsupported profile
if isValid(selectedAudioStream.Codec) and LCase(selectedAudioStream.Codec) = "aac"
if isValid(selectedAudioStream.Profile) and (LCase(selectedAudioStream.Profile) = "main" or LCase(selectedAudioStream.Profile) = "he-aac")
shouldRemoveAac = true
end if
end if
if shouldRemoveAac
removeUnsupportedAacFromProfile(postData.DeviceProfile, channelCount)
end if
end if
end if
end if
return GetApi().PostPlaybackInfo(id, postData)
end function
' Search across all libraries
function searchMedia(query as string)
if query <> ""
data = GetApi().GetItemsByQuery({
"searchTerm": query,
"IncludePeople": true,
"IncludeMedia": true,
"IncludeShows": true,
"IncludeGenres": true,
"IncludeStudios": true,
"IncludeArtists": true,
"IncludeItemTypes": "LiveTvChannel,Movie,BoxSet,Series,Episode,Video,Person,Audio,MusicAlbum,MusicArtist,Playlist",
"EnableTotalRecordCount": false,
"Recursive": true,
"limit": 100
})
if not isValid(data) then return []
transformer = JellyfinDataTransformer()
results = []
for each item in data.Items
results.push(transformer.transformBaseItem(item))
end for
data.Items = results
return data
end if
return []
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)
data = GetApi().GetItem(id, { "fields": fields })
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)
return ItemMetaData(id, "Chapters,Trickplay,Genres,Studios,People")
end function
' Music Artist Data
function ArtistOverview(name as string)
data = GetApi().GetArtistByName(name)
if not isValid(data) then return invalid
return data.overview
end function
' Get list of albums belonging to an artist
function MusicAlbumList(id as string)
data = GetApi().GetItemsByQuery({
"AlbumArtistIds": id,
"includeitemtypes": "MusicAlbum",
"sortBy": "SortName",
"Recursive": true
})
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
function AppearsOnList(id as string)
data = GetApi().GetItemsByQuery({
"ContributingArtistIds": id,
"ExcludeItemIds": id,
"includeitemtypes": "MusicAlbum",
"sortBy": "PremiereDate,ProductionYear,SortName",
"SortOrder": "Descending",
"Recursive": true
})
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 = GetApi().GetItemsByQuery(paramArray)
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 Items that are under the provided item
function PlaylistItemList(id as string)
data = GetApi().GetPlaylistItems(id)
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 Songs that are on an Album
function MusicSongList(id as string)
data = GetApi().GetItemsByQuery({
"parentId": id,
"includeitemtypes": "Audio",
"sortBy": "SortName"
})
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 Songs that are on an Album
function AudioItem(id as string)
return GetApi().GetItemRaw(id, {
"includeitemtypes": "Audio",
"sortBy": "SortName"
})
end function
' Get Instant Mix based on item
function CreateInstantMix(id as string)
return GetApi().GetInstantMix(id, { "Limit": 201 })
end function
' Get Instant Mix based on item
function CreateArtistMix(id as string)
return GetApi().GetItemsByQuery({
"ArtistIds": id,
"Recursive": "true",
"MediaTypes": "Audio",
"Filters": "IsNotFolder",
"SortBy": "SortName",
"Limit": 300,
"Fields": "Chapters",
"ExcludeLocationTypes": "Virtual",
"EnableTotalRecordCount": false,
"CollapseBoxSetItems": false
})
end function
' Get Intro Videos for an item
function GetIntroVideos(id as string)
return GetApi().GetIntros(id)
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
content.url = buildURL(playbackInfo.mediaSources[0].TranscodingURL)
else
' Direct Stream the audio
params = {
"Static": "true",
"Container": songData.mediaSources[0].container,
"MediaSourceId": songData.mediaSources[0].id
}
content.streamformat = songData.mediaSources[0].container
content.url = buildURL(Substitute("Audio/{0}/stream", songData.id), params)
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
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 = GetApi().GetSeasons(id)
' 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
' 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
' Removes AAC from the device profile codec list to prevent stereo downmix of multichannel audio.
' Also handles unsupported AAC profiles (Main, HE-AAC).
' For stereo sources (≤2ch), also removes surround passthrough codecs (eac3, ac3, dts)
' so transcoding falls through to MP3 (better compatibility + smaller files).
sub removeUnsupportedAacFromProfile(deviceProfile as object, channelCount as integer)
' Validate inputs
if not isValid(deviceProfile) then return
if not isValid(deviceProfile.TranscodingProfiles) then return
' Surround passthrough codecs that should be removed for stereo sources
surroundCodecs = ["eac3", "ac3", "dts"]
for each rule in deviceProfile.TranscodingProfiles
if isValid(rule.Type) and rule.Type = "Video"
if isValid(rule.AudioCodec)
' Split codec list into array
codecList = rule.AudioCodec.split(",")
newCodecList = []
' Remove AAC always, and surround codecs for stereo sources
for each codec in codecList
skipCodec = false
' Always skip AAC to prevent downmix
if codec = "aac"
skipCodec = true
end if
' For stereo sources (≤2ch), also skip surround codecs
' This ensures transcoding goes to MP3 instead of trying AC3/EAC3
if channelCount <= 2 and arrayHasValue(surroundCodecs, codec)
skipCodec = true
end if
if not skipCodec
newCodecList.push(codec)
end if
end for
' Rebuild codec string
rule.AudioCodec = newCodecList.join(",")
end if
end if
end for
end sub