import "pkg:/source/api/baseRequest.bs"
import "pkg:/source/utils/misc.bs"
' ========================================
' VIDEO BUFFER PROTECTION CONSTANTS
' ========================================
' Roku devices have fixed-size video buffers that cannot be configured.
' HLS segments exceeding the buffer size cause fatal playback errors.
' These constants are used to dynamically adjust transcoding parameters per-video.
' Video buffer size for 512MB RAM devices (from error logs: max_wrap_allocation)
const DEVICE_BUFFER_SIZE_LOW_MEMORY = 20553728
' Video buffer size for all other devices (from error logs: entire buffer size)
const DEVICE_BUFFER_SIZE_NORMAL = 31616000
' Percentage of buffer available for segment data (leaves room for metadata/overhead).
' Combined with BUFFER_SEGMENT_COUNT=2, each segment is allocated 37.5% of the total buffer.
const BUFFER_SAFETY_FACTOR = 0.75
' Number of segments the buffer must hold simultaneously.
' Roku's player buffers multiple segments for look-ahead playback.
' The buffer must fit at least this many segments concurrently.
const BUFFER_SEGMENT_COUNT = 2
' Max HLS playlist entries before Roku's MPR rejects the playlist as too large.
' Exact Roku limit is undocumented - this is a conservative estimate.
' Tune this value based on testing if "mpr playlist file is too large" errors occur.
const MAX_HLS_PLAYLIST_ENTRIES = 5000
' Translate Jellyfin codec names to Roku API codec names where they differ.
' Covers both audio and video codecs.
' "h264" → "mpeg4 avc" (Jellyfin uses "h264"; Roku API uses "mpeg4 avc" with a space)
' "truehd" → "mat" (TrueHD/Dolby MAT — "truehd" is unknown to the Roku API)
' "mpeg1video"→ "mpeg1" (Jellyfin uses "mpeg1video"; Roku API uses "mpeg1")
function jellyfinToRokuCodecName(codec as string) as string
if codec = "h264" then return "mpeg4 avc"
if codec = "truehd" then return "mat"
if codec = "mpeg1video" then return "mpeg1"
return codec
end function
' Returns the Device Capabilities for Roku.
' Also prints out the device profile for debugging
function getDeviceCapabilities() as object
deviceProfile = {
"PlayableMediaTypes": [
"Audio",
"Video",
"Photo"
],
"SupportedCommands": [],
"SupportsPersistentIdentifier": true,
"SupportsMediaControl": false,
"SupportsContentUploading": false,
"SupportsSync": false,
"DeviceProfile": getDeviceProfile(),
"AppStoreUrl": "https://channelstore.roku.com/details/232f9e82db11ce628e3fe7e01382a330:a85d6e9e520567806e8dae1c0cabadd5/jellyrock"
}
return deviceProfile
end function
function getDeviceProfile() as object
globalDevice = m.global.device
' Check server version to determine which DeviceProfile format to use
apiVersion = getApiVersionFromGlobal()
' V1 (10.7.x - 10.8.x): Uses full DeviceProfile with Identification object
' V2 (10.9+): Uses simplified DeviceProfile without Identification
if apiVersion >= 2
return getDeviceProfileV2(globalDevice)
end if
return getDeviceProfileV1(globalDevice)
end function
' V1 DeviceProfile for Jellyfin 10.7.x - 10.8.x servers
' Uses the full DeviceProfile format with Identification object
function getDeviceProfileV1(globalDevice as object) as object
return {
"Name": "JellyRock",
"Id": globalDevice.id,
"Identification": {
"FriendlyName": globalDevice.friendlyName,
"ModelNumber": globalDevice.model,
"ModelName": globalDevice.name,
"ModelDescription": "Type: " + globalDevice.modelType,
"Manufacturer": globalDevice.modelDetails.VendorName
},
"MaxStreamingBitrate": 140000000,
"MaxStaticBitrate": 140000000,
"MusicStreamingTranscodingBitrate": 192000,
"DirectPlayProfiles": GetDirectPlayProfiles(),
"TranscodingProfiles": getTranscodingProfiles(),
"ContainerProfiles": getContainerProfiles(),
"CodecProfiles": getCodecProfiles(),
"SubtitleProfiles": getSubtitleProfiles(),
"SupportedMediaTypes": "Video,Audio",
"ResponseProfiles": []
}
end function
' V2 DeviceProfile for Jellyfin 10.9+ servers
' Uses the simplified DeviceProfile format without Identification object
function getDeviceProfileV2(globalDevice as object) as object
return {
"Name": "JellyRock",
"Id": globalDevice.id,
"MaxStreamingBitrate": 140000000,
"MaxStaticBitrate": 140000000,
"MusicStreamingTranscodingBitrate": 192000,
' "MaxStaticMusicBitrate": 192000,
"DirectPlayProfiles": GetDirectPlayProfiles(),
"TranscodingProfiles": getTranscodingProfiles(),
"ContainerProfiles": getContainerProfiles(),
"CodecProfiles": getCodecProfiles(),
"SubtitleProfiles": getSubtitleProfiles()
}
end function
' Test if device can decode a specific codec at a given channel count
' This is a public wrapper for getActualCodecSupport that can be called from other files
' Returns true if the Roku can decode this codec at this channel count
function canDeviceDecodeCodec(codec as string, channelCount as integer) as boolean
di = CreateObject("roDeviceInfo")
return getActualCodecSupport(codec, channelCount, di)
end function
' Override false positives from Roku API using known hardware limits
' Returns true if codec can actually decode/passthrough the specified channel count
' For surround codecs (>2ch), prioritizes PassThru check to detect receiver support
' Accepts Jellyfin codec names (e.g. "truehd") and translates to Roku API names internally
function getActualCodecSupport(codec as string, channelCount as integer, di as object) as boolean
rokuCodec = jellyfinToRokuCodecName(codec) ' e.g. "truehd" → "mat"
' Codecs where the Roku API does not accept ChCnt (returns chcnt: "n.a.").
' These are passthrough-only; check PassThru without ChCnt.
' dtsx (DTS:X) is an extension of DTS-HD MA — covered by dtshd in the profile.
passThroughOnlyCodecs = ["dtshd", "dtsx"]
if arrayHasValue(passThroughOnlyCodecs, rokuCodec)
if channelCount > 2
return di.CanDecodeAudio({ Codec: rokuCodec, PassThru: 1 }).Result
end if
return false ' Passthrough-only codecs are not relevant at 2ch
end if
' Codecs that Roku can OUTPUT multichannel audio (passthru + possible native decode).
' mat is the Roku API name for TrueHD/Dolby MAT (Jellyfin name: "truehd").
surroundOutputCodecs = ["eac3", "ac3", "dts", "mat"]
' For multichannel surround codecs (>2ch), prioritize PassThru check
if channelCount > 2 and arrayHasValue(surroundOutputCodecs, rokuCodec)
' First: Check if receiver supports this via PassThru
if di.CanDecodeAudio({ Codec: rokuCodec, ChCnt: channelCount, PassThru: 1 }).Result
return true ' Receiver connected and supports this channel count!
end if
' No PassThru support - check if Roku can natively decode (will downmix to stereo)
if not di.CanDecodeAudio({ Codec: rokuCodec, ChCnt: channelCount }).Result
return false ' API says can't decode at all
end if
' API says yes - check for false positive using known hardware limits
rokuDecodeMaxChannels = {
"eac3": 6,
"ac3": 6,
"dts": 0, ' DTS is passthrough-only, cannot natively decode multichannel
"mat": 0 ' TrueHD (mat): API accepts ChCnt but native decode outputs stereo PCM only
}
' Defensive: Ensure codec is a valid key in rokuDecodeMaxChannels
' This should always be true due to the surroundOutputCodecs check above,
' but this guards against future changes or typos.
if not rokuDecodeMaxChannels.DoesExist(rokuCodec)
return false
end if
if channelCount > rokuDecodeMaxChannels[rokuCodec]
return false ' Exceeds native decode capability - false positive
end if
return true ' Can natively decode (will downmix to stereo for no-receiver users)
end if
' For stereo (2ch) or non-surround codecs (aac, mp3, pcm, etc)
if not di.CanDecodeAudio({ Codec: rokuCodec, ChCnt: channelCount }).Result
return false ' API says no support
end if
' API says yes - check for false positives on non-surround codecs
rokuDecodeMaxChannels = {
"aac": 6,
"pcm": 2,
"lpcm": 2
}
if rokuDecodeMaxChannels.DoesExist(rokuCodec) and channelCount > rokuDecodeMaxChannels[rokuCodec]
return false ' False positive
end if
return true
end function
' Get list of surround codecs that support passthrough, returned as Jellyfin codec strings.
' Returns array in priority order: eac3, ac3, dts, truehd, dtshd
'
' Two groups due to differing Roku API behaviour:
' Group 1 (ChCnt-aware): eac3, ac3, dts, mat — checked with ChCnt + PassThru: 1
' Group 2 (ChCnt N/A): dtshd — checked with PassThru: 1 only
'
' mat is the Roku API name for TrueHD/Dolby MAT; returned here as "truehd" (Jellyfin name).
' dtsx (DTS:X) is an extension of DTS-HD MA — covered by dtshd; not added separately.
function getSupportedPassthruCodecs(di as object, channelCount as integer) as object
supportedCodecs = []
' Group 1: codecs that accept ChCnt — use PassThru + ChCnt check
' "mat" is the Roku API string for TrueHD; map back to "truehd" for Jellyfin callers
chCntCodecs = ["eac3", "ac3", "dts", "mat"]
for each codec in chCntCodecs
if di.CanDecodeAudio({ Codec: codec, ChCnt: channelCount, PassThru: 1 }).Result
if codec = "mat"
supportedCodecs.push("truehd")
else
supportedCodecs.push(codec)
end if
end if
end for
' Group 2: codecs where ChCnt is N/A — use PassThru-only check
' If passthrough is supported at all, include for any channelCount > 2 (passthrough is bitstream)
chCntNACodecs = ["dtshd"]
if channelCount > 2
for each codec in chCntNACodecs
if di.CanDecodeAudio({ Codec: codec, PassThru: 1 }).Result
supportedCodecs.push(codec)
end if
end for
end if
return supportedCodecs
end function
function GetDirectPlayProfiles() as object
' ONE rendezvous to get user settings
globalUserSettings = m.global.user.settings
directPlayProfiles = []
di = CreateObject("roDeviceInfo")
' MPEG-TS is intentionally excluded. Broadcast TS files carry wall-clock PTS timestamps
' rather than zero-based ones, so Roku's progressive reader computes a nonsensical
' duration (often 20+ hours for a short clip) and seek / progress / trickplay all break.
' Letting Jellyfin remux TS to HLS gives Roku a manifest with accurate EXTINF durations.
supportedContainers = {
mp4: {
audio: [],
video: []
},
hls: {
audio: [],
video: []
},
mkv: {
audio: [],
video: []
},
ism: {
audio: [],
video: []
},
dash: {
audio: [],
video: []
}
}
' all possible codecs (besides those restricted by user settings)
videoCodecs = ["h264", "hevc", "vp8", "vp9", "mpeg1video", "h263"]
audioCodecs = ["mp3", "mp2", "pcm", "lpcm", "wav", "ac3", "ac4", "aiff", "wma", "flac", "alac", "aac", "opus", "dts", "wmapro", "vorbis", "eac3", "truehd", "dtshd", "mpg123"]
' check video codecs for each container
for each container in supportedContainers
for each videoCodec in videoCodecs
rokuCodec = jellyfinToRokuCodecName(videoCodec)
if di.CanDecodeVideo({ Codec: rokuCodec, Container: container }).Result
if videoCodec = "h264"
' push both Jellyfin aliases — server may use either string
supportedContainers[container]["video"].push("h264")
supportedContainers[container]["video"].push("avc")
else if videoCodec = "hevc"
supportedContainers[container]["video"].push("hevc")
supportedContainers[container]["video"].push("h265")
else
supportedContainers[container]["video"].push(videoCodec)
end if
end if
end for
end for
' user setting overrides
if globalUserSettings.playbackMpeg4
for each container in supportedContainers
supportedContainers[container]["video"].push("mpeg4")
end for
end if
if globalUserSettings.playbackMpeg2
for each container in supportedContainers
supportedContainers[container]["video"].push("mpeg2video")
end for
end if
' video codec overrides
' these codecs play fine but are not correctly detected using CanDecodeVideo()
if di.CanDecodeVideo({ Codec: "av1" }).Result
' codec must be checked by itself or the result will always be false
for each container in supportedContainers
supportedContainers[container]["video"].push("av1")
end for
end if
' check audio codecs for each container
' translate Jellyfin names to Roku API names for the detection call (e.g. truehd → mat)
' but push the Jellyfin name so the profile contains the correct string
for each container in supportedContainers
for each audioCodec in audioCodecs
rokuCodec = jellyfinToRokuCodecName(audioCodec)
if rokuCodec = "mat"
' mat does not accept the Container parameter; TrueHD is only valid in mkv and ts
if (container = "mkv" or container = "ts") and di.CanDecodeAudio({ Codec: rokuCodec }).Result
supportedContainers[container]["audio"].push(audioCodec)
end if
else if rokuCodec = "dtshd"
' dtshd: ChCnt is N/A; standard container check returns false on passthrough-only devices.
' Check native decode first, then fall back to PassThru (current hardware support path).
nativeDecode = di.CanDecodeAudio({ Codec: rokuCodec, Container: container }).Result
passthroughSupport = di.CanDecodeAudio({ Codec: rokuCodec, PassThru: 1 }).Result
if nativeDecode or passthroughSupport
supportedContainers[container]["audio"].push(audioCodec)
end if
else if di.CanDecodeAudio({ Codec: rokuCodec, Container: container }).Result
supportedContainers[container]["audio"].push(audioCodec)
end if
end for
end for
' remove audio codecs not supported as standalone audio files (opus)
' also add aac back to the list so it gets added to the direct play profile
audioCodecs = ["aac", "mp3", "mp2", "pcm", "lpcm", "wav", "ac3", "ac4", "aiff", "wma", "flac", "alac", "aac", "dts", "wmapro", "vorbis", "eac3", "truehd", "dtshd", "mpg123"]
' check audio codecs with no container
' translate Jellyfin names to Roku API names for the detection call
supportedAudio = []
for each audioCodec in audioCodecs
if di.CanDecodeAudio({ Codec: jellyfinToRokuCodecName(audioCodec) }).Result
supportedAudio.push(audioCodec)
end if
end for
' build return array
for each container in supportedContainers
videoCodecString = supportedContainers[container]["video"].Join(",")
if videoCodecString <> ""
containerString = container
if container = "mp4"
containerString = "mp4,mov,m4v"
else if container = "mkv"
containerString = "mkv,webm"
end if
directPlayProfiles.push({
"Container": containerString,
"Type": "Video",
"VideoCodec": videoCodecString,
"AudioCodec": supportedContainers[container]["audio"].Join(",")
})
end if
end for
directPlayProfiles.push({
"Container": supportedAudio.Join(","),
"Type": "Audio"
})
return directPlayProfiles
end function
function getTranscodingProfiles() as object
' ONE rendezvous to get user settings
globalUserSettings = m.global.user.settings
transcodingProfiles = []
di = CreateObject("roDeviceInfo")
' ========================================
' AUDIO CAPABILITY DETECTION
' ========================================
' Detect actual passthrough support for multichannel audio
sixChannelPassthruCodecs = getSupportedPassthruCodecs(di, 6)
eightChannelPassthruCodecs = getSupportedPassthruCodecs(di, 8)
' Get user's preferred codec for ordering (applied later in transcoding profile)
preferredCodec = globalUserSettings.playbackPreferredMultichannelCodec
' ========================================
' AUDIO-ONLY TRANSCODING PROFILES
' ========================================
' AAC for stereo audio (always 2 channels)
transcodingProfiles.push({
"Container": "aac",
"Type": "Audio",
"AudioCodec": "aac",
"Context": "Streaming",
"Protocol": "http",
"MaxAudioChannels": "2"
})
transcodingProfiles.push({
"Container": "aac",
"Type": "Audio",
"AudioCodec": "aac",
"Context": "Static",
"Protocol": "http",
"MaxAudioChannels": "2"
})
' MP3 for stereo audio (fixed from incorrect maxAudioChannels value)
transcodingProfiles.push({
"Container": "mp3",
"Type": "Audio",
"AudioCodec": "mp3",
"Context": "Streaming",
"Protocol": "http",
"MaxAudioChannels": "2"
})
transcodingProfiles.push({
"Container": "mp3",
"Type": "Audio",
"AudioCodec": "mp3",
"Context": "Static",
"Protocol": "http",
"MaxAudioChannels": "2"
})
' ========================================
' VIDEO CODEC DETECTION (per container)
' ========================================
transcodingContainers = ["ts", "mp4"]
containerVideoCodecs = {}
for each container in transcodingContainers
' Video codecs
videoCodecList = []
if di.CanDecodeVideo({ Codec: "mpeg4 avc", Container: container }).Result
videoCodecList.push("h264")
videoCodecList.push("avc")
end if
if di.CanDecodeVideo({ Codec: "hevc", Container: container }).Result
videoCodecList.push("h265")
videoCodecList.push("hevc")
end if
if di.CanDecodeVideo({ Codec: "vp9", Container: container }).Result
if not arrayHasValue(videoCodecList, "vp9")
videoCodecList.push("vp9")
end if
end if
if globalUserSettings.playbackMpeg2
if di.CanDecodeVideo({ Codec: "mpeg2", Container: container }).Result
videoCodecList.push("mpeg2video")
end if
end if
containerVideoCodecs[container] = videoCodecList.join(",")
end for
' ========================================
' VIDEO TRANSCODING PROFILES
' Single profile per container with codecs in optimal order
' ========================================
' Determine max supported audio channels
maxAudioChannels = "2" ' default stereo
allSurroundCodecs = []
if eightChannelPassthruCodecs.count() > 0
maxAudioChannels = "8"
allSurroundCodecs = eightChannelPassthruCodecs
else if sixChannelPassthruCodecs.count() > 0
maxAudioChannels = "6"
allSurroundCodecs = sixChannelPassthruCodecs
end if
' Build optimal audio codec list for transcoding
' Order: AAC (stereo), passthrough surround, multichannel decode, stereo fallbacks
for each container in transcodingContainers
audioCodecList = []
' 1. AAC always first (efficient for stereo). On multichannel + passthru playback, all
' stereo-output codecs (including AAC) are stripped at playback time by
' optimizeAudioCodecListForSource (see items.bs).
audioCodecList.push("aac")
' 2. Add surround passthrough codecs if supported (in preference order)
if allSurroundCodecs.count() > 0
' Apply user's preferred codec ordering
if isValid(preferredCodec) and preferredCodec <> "" and preferredCodec <> "auto"
' Preferred codec first (if supported)
if arrayHasValue(allSurroundCodecs, preferredCodec)
audioCodecList.push(preferredCodec)
end if
' Then other surround codecs in priority order: eac3 > ac3 > dts
surroundPriority = ["eac3", "ac3", "dts"]
for each codec in surroundPriority
if arrayHasValue(allSurroundCodecs, codec) and codec <> preferredCodec
audioCodecList.push(codec)
end if
end for
else
' Auto mode: use default priority order (eac3 > ac3 > dts)
surroundPriority = ["eac3", "ac3", "dts"]
for each codec in surroundPriority
if arrayHasValue(allSurroundCodecs, codec)
audioCodecList.push(codec)
end if
end for
end if
end if
' 3. Add stereo fallback codecs if device supports them (MP3 most compatible, then lossless as fallbacks)
stereoFallbacks = ["mp3", "flac", "alac", "pcm"]
for each codec in stereoFallbacks
if not arrayHasValue(audioCodecList, codec)
' Validate device can decode this codec at 2 channels in this container
if di.CanDecodeAudio({ Codec: codec, ChCnt: 2, Container: container }).Result
audioCodecList.push(codec)
end if
end if
end for
' Create single profile per container
profile = {
"Container": container,
"Context": "Streaming",
"Protocol": "hls",
"Type": "Video",
"VideoCodec": containerVideoCodecs[container],
"AudioCodec": audioCodecList.join(","),
"MaxAudioChannels": maxAudioChannels,
"MinSegments": 1,
"BreakOnNonKeyFrames": false,
"SegmentLength": 6
}
' Add resolution restriction if configured
resolutionConditions = getResolutionConditions(true)
if resolutionConditions.count() > 0
profile.Conditions = resolutionConditions
end if
transcodingProfiles.push(profile)
end for
return transcodingProfiles
end function
function getContainerProfiles() as object
containerProfiles = []
return containerProfiles
end function
function getCodecProfiles() as object
' ONE rendezvous to get user settings
globalUserSettings = m.global.user.settings
codecProfiles = []
profileSupport = {
"h264": {},
"hevc": {},
"vp9": {},
"mpeg2": {},
"av1": {}
}
di = CreateObject("roDeviceInfo")
resolutionConditions = getResolutionConditions()
' ========================================
' USER SETTING — FORCE SERVER-SIDE MULTICHANNEL HANDLING
' ========================================
'
' playbackDecodeMultichannelAudio = false means the user has explicitly asked us to keep
' multichannel sources off the Roku's decoder — usually because Roku's downmix sounded worse
' than the server's on their system, or because they want bitstream surround output preserved
' via server transcoding instead of relying on Roku PCM output.
'
' Effect: cap stereo-output codecs at 2ch in the codec profile so the server is forced to
' transcode multichannel sources rather than offering them for direct play.
userWantsDecodeLimit = not globalUserSettings.playbackDecodeMultichannelAudio
' ========================================
' CODEC CATEGORIES
' ========================================
' Codecs whose decode-then-output path on Roku does not always pass through multichannel —
' depending on the user's Audio Output Mode and HDMI/eARC sink, these may downmix to 2ch PCM.
' We do NOT cap these at 2ch by default: most modern HDMI sinks accept multichannel PCM and
' the official Roku decode behavior produces correct multichannel output for capable users.
' The cap is applied only when userWantsDecodeLimit is true (see setting above).
'
' Note: this list is intentionally distinct from `stereoOnlyCodecs` in items.bs; that list
' represents server transcode-target capability (different concept), so the contents differ.
stereoOutputCodecs = ["aac", "flac", "alac", "pcm", "lpcm", "wav", "opus", "vorbis"]
' ========================================
' AUDIO CODEC PROFILES
' ========================================
audioCodecs = ["aac", "mp3", "mp2", "opus", "pcm", "lpcm", "wav", "flac", "alac", "ac3", "ac4", "aiff", "dts", "wmapro", "vorbis", "eac3", "truehd", "dtshd", "mpg123"]
audioChannels = [8, 6, 2] ' highest first
for each audioCodec in audioCodecs
' When the user has explicitly disabled on-device multichannel decoding, cap stereo-output
' codecs at 2ch to force server-side transcoding for multichannel sources.
if arrayHasValue(stereoOutputCodecs, audioCodec) and userWantsDecodeLimit
' Force to 2 channels maximum to prevent Roku from decoding the multichannel source.
' Server transcodes to a surround codec (eac3/ac3/dts) if surround passthrough is
' available, otherwise transcodes to stereo.
for each codecType in ["VideoAudio", "Audio"]
' Special AAC profile restrictions (Main and HE-AAC not supported)
if audioCodec = "aac"
codecProfiles.push({
"Type": codecType,
"Codec": audioCodec,
"Conditions": [
{
"Condition": "NotEquals",
"Property": "AudioProfile",
"Value": "Main",
"IsRequired": true
},
{
"Condition": "NotEquals",
"Property": "AudioProfile",
"Value": "HE-AAC",
"IsRequired": true
},
{
"Condition": "LessThanEqual",
"Property": "AudioChannels",
"Value": "2",
"IsRequired": true
}
]
})
else
' All other stereo-output codecs: just limit channels
codecProfiles.push({
"Type": codecType,
"Codec": audioCodec,
"Conditions": [
{
"Condition": "LessThanEqual",
"Property": "AudioChannels",
"Value": "2",
"IsRequired": true
}
]
})
end if
end for
else
' Standard logic for all other codecs (and AAC when no surround passthru)
for each audioChannel in audioChannels
' Use override logic to catch false positives
if getActualCodecSupport(audioCodec, audioChannel, di)
' Create codec profile for this channel count
for each codecType in ["VideoAudio", "Audio"]
if audioCodec = "aac"
codecProfiles.push({
"Type": codecType,
"Codec": audioCodec,
"Conditions": [
{
"Condition": "NotEquals",
"Property": "AudioProfile",
"Value": "Main",
"IsRequired": true
},
{
"Condition": "NotEquals",
"Property": "AudioProfile",
"Value": "HE-AAC",
"IsRequired": true
},
{
"Condition": "LessThanEqual",
"Property": "AudioChannels",
"Value": audioChannel.ToStr(),
"IsRequired": true
}
]
})
else if audioCodec = "opus" and codecType = "Audio"
' Opus audio files not supported by Roku - skip
else
codecProfiles.push({
"Type": codecType,
"Codec": audioCodec,
"Conditions": [
{
"Condition": "LessThanEqual",
"Property": "AudioChannels",
"Value": audioChannel.ToStr(),
"IsRequired": true
}
]
})
end if
end for
' Found highest supported channel count, stop testing lower
exit for
end if
end for
end if
end for
' check device for codec profile and level support
' AVC / h264
h264Profiles = ["main", "high"]
h264Levels = ["4.1", "4.2"]
for each profile in h264Profiles
for each level in h264Levels
if di.CanDecodeVideo({ Codec: "mpeg4 avc", Profile: profile, Level: level }).Result
profileSupport = updateProfileArray(profileSupport, "h264", profile, level)
end if
end for
end for
' HEVC / h265
hevcProfiles = ["main", "main 10"]
hevcLevels = ["4.1", "5.0", "5.1"]
for each profile in hevcProfiles
for each level in hevcLevels
if di.CanDecodeVideo({ Codec: "hevc", Profile: profile, Level: level }).Result
profileSupport = updateProfileArray(profileSupport, "hevc", profile, level)
end if
end for
end for
' VP9
vp9Profiles = ["profile 0", "profile 2"]
for each profile in vp9Profiles
if di.CanDecodeVideo({ Codec: "vp9", Profile: profile }).Result
profileSupport = updateProfileArray(profileSupport, "vp9", profile)
end if
end for
' MPEG2
' mpeg2 uses levels with no profiles. see https://developer.roku.com/en-ca/docs/references/brightscript/interfaces/ifdeviceinfo.md#candecodevideovideo_format-as-object-as-object
' NOTE: the mpeg2 levels are being saved in the profileSupport array as if they were profiles
mpeg2Levels = ["main", "high"]
for each level in mpeg2Levels
if di.CanDecodeVideo({ Codec: "mpeg2", Level: level }).Result
profileSupport = updateProfileArray(profileSupport, "mpeg2", level)
end if
end for
' AV1
av1Profiles = ["main", "main 10"]
av1Levels = ["4.1", "5.0", "5.1"]
for each profile in av1Profiles
for each level in av1Levels
if di.CanDecodeVideo({ Codec: "av1", Profile: profile, Level: level }).Result
profileSupport = updateProfileArray(profileSupport, "av1", profile, level)
end if
end for
end for
' HDR SUPPORT
h264VideoRangeTypes = "SDR|DOVIWithSDR"
hevcVideoRangeTypes = "SDR|DOVIWithSDR"
vp9VideoRangeTypes = "SDR|DOVIWithSDR"
av1VideoRangeTypes = "SDR|DOVIWithSDR"
if canPlay4k()
print "This device supports 4k video"
dp = di.GetDisplayProperties()
if dp.DolbyVision
h264VideoRangeTypes = h264VideoRangeTypes + "|DOVI|DOVIWithEL|DOVIWithELHDR10Plus"
hevcVideoRangeTypes = hevcVideoRangeTypes + "|DOVI|DOVIWithEL|DOVIWithELHDR10Plus"
av1VideoRangeTypes = av1VideoRangeTypes + "|DOVI|DOVIWithEL|DOVIWithELHDR10Plus"
end if
if dp.Hdr10
hevcVideoRangeTypes = hevcVideoRangeTypes + "|HDR10|DOVIWithHDR10|DOVIWithEL"
vp9VideoRangeTypes = vp9VideoRangeTypes + "|HDR10|DOVIWithHDR10|DOVIWithEL"
av1VideoRangeTypes = av1VideoRangeTypes + "|HDR10|DOVIWithHDR10|DOVIWithEL"
end if
if dp.Hdr10Plus
hevcVideoRangeTypes = hevcVideoRangeTypes + "|HDR10Plus|DOVIWithHDR10Plus|DOVIWithELHDR10Plus"
vp9VideoRangeTypes = vp9VideoRangeTypes + "|HDR10Plus|DOVIWithHDR10Plus|DOVIWithELHDR10Plus"
av1VideoRangeTypes = av1VideoRangeTypes + "|HDR10Plus|DOVIWithHDR10Plus|DOVIWithELHDR10Plus"
end if
if dp.HLG
hevcVideoRangeTypes = hevcVideoRangeTypes + "|HLG|DOVIWithHLG"
vp9VideoRangeTypes = vp9VideoRangeTypes + "|HLG|DOVIWithHLG"
av1VideoRangeTypes = av1VideoRangeTypes + "|HLG|DOVIWithHLG"
end if
end if
' H264
h264LevelSupported = 0.0
h264AssProfiles = {
"Baseline": true,
"Constrained Baseline": true,
"Extended": true
}
for each profile in profileSupport["h264"]
h264AssProfiles.AddReplace(profile, true)
for each level in profileSupport["h264"][profile]
levelFloat = level.ToFloat()
if levelFloat > h264LevelSupported
h264LevelSupported = levelFloat
end if
end for
end for
' convert to string
h264LevelString = h264LevelSupported.ToStr()
' remove decimals
h264LevelString = removeDecimals(h264LevelString)
h264ProfileArray = {
"Type": "Video",
"Codec": "h264,avc",
"Conditions": [
{
"Condition": "EqualsAny",
"Property": "VideoProfile",
"Value": h264AssProfiles.Keys().join("|"),
"IsRequired": false
},
{
"Condition": "EqualsAny",
"Property": "VideoRangeType",
"Value": h264VideoRangeTypes,
"IsRequired": false
}
]
}
' Always send VideoLevel so the server produces compatible transcodes.
' The playbackTryDirectH264ProfileLevel setting is handled client-side in
' LoadVideoContentTask — if the only transcode reason is VideoLevelNotSupported,
' the client will attempt direct play with a transcode fallback.
h264ProfileArray.Conditions.push({
"Condition": "LessThanEqual",
"Property": "VideoLevel",
"Value": h264LevelString,
"IsRequired": false
})
anamorphicCondition = getAnamorphicCondition()
if anamorphicCondition.count() > 0
h264ProfileArray.Conditions.push(anamorphicCondition)
end if
' set max resolution
h264ProfileArray.Conditions.Append(resolutionConditions)
' set bitrate restrictions based on user settings
bitRateArray = GetBitRateLimit("h264")
if bitRateArray.count() > 0
h264ProfileArray.Conditions.push(bitRateArray)
end if
codecProfiles.push(h264ProfileArray)
' MPEG2
' NOTE: the mpeg2 levels are being saved in the profileSupport array as if they were profiles
if globalUserSettings.playbackMpeg2
mpeg2Levels = []
for each level in profileSupport["mpeg2"]
if not arrayHasValue(mpeg2Levels, level)
mpeg2Levels.push(level)
end if
end for
mpeg2ProfileArray = {
"Type": "Video",
"Codec": "mpeg2video",
"Conditions": [
{
"Condition": "EqualsAny",
"Property": "VideoLevel",
"Value": mpeg2Levels.join("|"),
"IsRequired": false
}
]
}
' set max resolution
mpeg2ProfileArray.Conditions.Append(resolutionConditions)
' set bitrate restrictions based on user settings
bitRateArray = GetBitRateLimit("mpeg2")
if bitRateArray.count() > 0
mpeg2ProfileArray.Conditions.push(bitRateArray)
end if
anamorphicCondition = getAnamorphicCondition()
if anamorphicCondition.count() > 0
mpeg2ProfileArray.Conditions.push(anamorphicCondition)
end if
codecProfiles.push(mpeg2ProfileArray)
end if
' MPEG-4 Part 2
if globalUserSettings.playbackMpeg4
mpeg4ProfileArray = {
"Type": "Video",
"Codec": "mpeg4",
"Conditions": [
{
"Condition": "EqualsAny",
"Property": "VideoRangeType",
"Value": "SDR",
"IsRequired": false
}
]
}
' set max resolution
mpeg4ProfileArray.Conditions.Append(resolutionConditions)
anamorphicCondition = getAnamorphicCondition()
if anamorphicCondition.count() > 0
mpeg4ProfileArray.Conditions.push(anamorphicCondition)
end if
codecProfiles.push(mpeg4ProfileArray)
end if
if di.CanDecodeVideo({ Codec: "av1" }).Result
av1LevelSupported = 0.0
av1AssProfiles = {}
for each profile in profileSupport["av1"]
av1AssProfiles.AddReplace(profile, true)
for each level in profileSupport["av1"][profile]
levelFloat = level.ToFloat()
if levelFloat > av1LevelSupported
av1LevelSupported = levelFloat
end if
end for
end for
av1ProfileArray = {
"Type": "Video",
"Codec": "av1",
"Conditions": [
{
"Condition": "EqualsAny",
"Property": "VideoProfile",
"Value": av1AssProfiles.Keys().join("|"),
"IsRequired": false
},
{
"Condition": "EqualsAny",
"Property": "VideoRangeType",
"Value": av1VideoRangeTypes,
"IsRequired": false
},
{
"Condition": "LessThanEqual",
"Property": "VideoLevel",
"Value": (120 * av1LevelSupported).ToStr(),
"IsRequired": false
}
]
}
' set max resolution
av1ProfileArray.Conditions.Append(resolutionConditions)
' set bitrate restrictions based on user settings
bitRateArray = GetBitRateLimit("av1")
if bitRateArray.count() > 0
av1ProfileArray.Conditions.push(bitRateArray)
end if
anamorphicCondition = getAnamorphicCondition()
if anamorphicCondition.count() > 0
av1ProfileArray.Conditions.push(anamorphicCondition)
end if
codecProfiles.push(av1ProfileArray)
end if
if di.CanDecodeVideo({ Codec: "hevc" }).Result
hevcLevelSupported = 0.0
hevcAssProfiles = {}
for each profile in profileSupport["hevc"]
hevcAssProfiles.AddReplace(profile, true)
for each level in profileSupport["hevc"][profile]
levelFloat = level.ToFloat()
if levelFloat > hevcLevelSupported
hevcLevelSupported = levelFloat
end if
end for
end for
hevcLevelString = convertHevcLevelToString(hevcLevelSupported)
hevcProfileArray = {
"Type": "Video",
"Codec": "h265,hevc",
"Conditions": [
{
"Condition": "EqualsAny",
"Property": "VideoProfile",
"Value": profileSupport["hevc"].Keys().join("|"),
"IsRequired": false
},
{
"Condition": "EqualsAny",
"Property": "VideoRangeType",
"Value": hevcVideoRangeTypes,
"IsRequired": false
}
]
}
' Always send VideoLevel so the server produces compatible transcodes.
' The playbackTryDirectHevcProfileLevel setting is handled client-side in
' LoadVideoContentTask — if the only transcode reason is VideoLevelNotSupported,
' the client will attempt direct play with a transcode fallback.
hevcProfileArray.Conditions.push({
"Condition": "LessThanEqual",
"Property": "VideoLevel",
"Value": hevcLevelString,
"IsRequired": false
})
' set max resolution
hevcProfileArray.Conditions.Append(resolutionConditions)
' set bitrate restrictions based on user settings
bitRateArray = GetBitRateLimit("h265")
if bitRateArray.count() > 0
hevcProfileArray.Conditions.push(bitRateArray)
end if
anamorphicCondition = getAnamorphicCondition()
if anamorphicCondition.count() > 0
hevcProfileArray.Conditions.push(anamorphicCondition)
end if
codecProfiles.push(hevcProfileArray)
end if
if di.CanDecodeVideo({ Codec: "vp9" }).Result
vp9Profiles = []
for each profile in profileSupport["vp9"]
vp9Profiles.push(profile)
end for
vp9ProfileArray = {
"Type": "Video",
"Codec": "vp9",
"Conditions": [
{
"Condition": "EqualsAny",
"Property": "VideoProfile",
"Value": vp9Profiles.join("|"),
"IsRequired": false
},
{
"Condition": "EqualsAny",
"Property": "VideoRangeType",
"Value": vp9VideoRangeTypes,
"IsRequired": false
}
]
}
' set max resolution
vp9ProfileArray.Conditions.Append(resolutionConditions)
' set bitrate restrictions based on user settings
bitRateArray = GetBitRateLimit("vp9")
if bitRateArray.count() > 0
vp9ProfileArray.Conditions.push(bitRateArray)
end if
anamorphicCondition = getAnamorphicCondition()
if anamorphicCondition.count() > 0
vp9ProfileArray.Conditions.push(anamorphicCondition)
end if
codecProfiles.push(vp9ProfileArray)
end if
' VP8
' No profile/level system to test — SDR only
if di.CanDecodeVideo({ Codec: "vp8" }).Result
vp8ProfileArray = {
"Type": "Video",
"Codec": "vp8",
"Conditions": [
{
"Condition": "EqualsAny",
"Property": "VideoRangeType",
"Value": "SDR",
"IsRequired": false
}
]
}
' set max resolution
vp8ProfileArray.Conditions.Append(resolutionConditions)
anamorphicCondition = getAnamorphicCondition()
if anamorphicCondition.count() > 0
vp8ProfileArray.Conditions.push(anamorphicCondition)
end if
codecProfiles.push(vp8ProfileArray)
end if
' Filter out version-specific conditions based on server API version
apiVersion = getApiVersionFromGlobal()
' VideoRangeType was added in Jellyfin 10.9 (API v2)
' Remove it for older servers (10.7.x, 10.8.x) that don't support this property
if apiVersion < 2
codecProfiles = filterCodecProfileConditions(codecProfiles, ["VideoRangeType"])
end if
return codecProfiles
end function
' filterCodecProfileConditions: Removes conditions with specified properties from codec profiles
' Used for version compatibility - e.g., removing VideoRangeType for pre-10.9 servers
'
' @param {roArray} codecProfiles - Array of codec profile objects
' @param {roArray} propertiesToRemove - Array of property names to filter out
' @return {roArray} Filtered codec profiles
function filterCodecProfileConditions(codecProfiles as object, propertiesToRemove as object) as object
filteredProfiles = []
for each profile in codecProfiles
if isValid(profile.Conditions) and profile.Conditions.count() > 0
filteredConditions = []
for each condition in profile.Conditions
if isValid(condition.Property)
shouldKeep = true
for each prop in propertiesToRemove
if condition.Property = prop
shouldKeep = false
exit for
end if
end for
if shouldKeep
filteredConditions.push(condition)
end if
else
filteredConditions.push(condition)
end if
end for
profile.Conditions = filteredConditions
end if
filteredProfiles.push(profile)
end for
return filteredProfiles
end function
function getSubtitleProfiles() as object
subtitleProfiles = []
subtitleProfiles.push({
"Format": "vtt",
"Method": "External"
})
subtitleProfiles.push({
"Format": "srt",
"Method": "External"
})
subtitleProfiles.push({
"Format": "ttml",
"Method": "External"
})
subtitleProfiles.push({
"Format": "sub",
"Method": "External"
})
return subtitleProfiles
end function
function GetBitRateLimit(codec as string) as object
' ONE rendezvous to get user settings
globalUserSettings = m.global.user.settings
if globalUserSettings.playbackBitrateMaxLimited
userSetLimit = globalUserSettings.playbackBitrateLimit
if isValid(userSetLimit) and type(userSetLimit) = "Integer" and userSetLimit > 0
userSetLimit *= 1000000
return {
"Condition": "LessThanEqual",
"Property": "VideoBitrate",
"Value": userSetLimit.ToStr(),
"IsRequired": true
}
else
codec = Lcase(codec)
' Some repeated values (e.g. same "40mbps" for several codecs)
' but this makes it easy to update in the future if the bitrates start to deviate.
if codec = "h264"
' Roku only supports h264 up to 10Mpbs
return {
"Condition": "LessThanEqual",
"Property": "VideoBitrate",
"Value": "10000000",
"IsRequired": true
}
else if codec = "av1"
' Roku only supports AV1 up to 40Mpbs
return {
"Condition": "LessThanEqual",
"Property": "VideoBitrate",
"Value": "40000000",
"IsRequired": true
}
else if codec = "h265"
' Roku only supports h265 up to 40Mpbs
return {
"Condition": "LessThanEqual",
"Property": "VideoBitrate",
"Value": "40000000",
"IsRequired": true
}
else if codec = "vp9"
' Roku only supports VP9 up to 40Mpbs
return {
"Condition": "LessThanEqual",
"Property": "VideoBitrate",
"Value": "40000000",
"IsRequired": true
}
end if
end if
end if
return {}
end function
' Returns an IsAnamorphic codec profile condition when the user has enabled
' forced transcoding of anamorphic video. Returns empty object when disabled.
' @return {object} - Condition AA or empty AA
function getAnamorphicCondition() as object
if m.global.user.settings.playbackForceTranscodeAnamorphic
return {
"Condition": "NotEquals",
"Property": "IsAnamorphic",
"Value": "true",
"IsRequired": false
}
end if
return {}
end function
' Checks whether a video stream's resolution is within the h264 hardware
' decoding ceiling (1080p / 1920x1080). Returns true for non-h264 codecs
' since they have higher ceilings handled elsewhere.
' Handles Height/Width fields arriving as strings or integers, and defaults
' to 0 (within ceiling) when metadata is missing.
' @param videoStream {object} - MediaStream object with codec, Height, Width
' @return {boolean} - true if within ceiling or not h264, false if exceeds ceiling
function isWithinH264HardwareCeiling(videoStream as object) as boolean
if not isValid(videoStream) or not isValid(videoStream.codec) then return false
if videoStream.codec <> "h264" then return true
sourceHeight = 0
sourceWidth = 0
if isValid(videoStream.Height)
sourceHeight = videoStream.Height
if type(sourceHeight) = "roString" or type(sourceHeight) = "String"
sourceHeight = sourceHeight.toInt()
end if
end if
if isValid(videoStream.Width)
sourceWidth = videoStream.Width
if type(sourceWidth) = "roString" or type(sourceWidth) = "String"
sourceWidth = sourceWidth.toInt()
end if
end if
return sourceHeight <= 1080 and sourceWidth <= 1920
end function
function getResolutionConditions(isRequired = false as boolean) as object
userMaxHeight = m.global.user.settings.playbackResolutionMax
if userMaxHeight = invalid or userMaxHeight = "" then userMaxHeight = "auto"
if userMaxHeight = "off" then return []
globalDevice = m.global.device ' cache - accessed twice below
deviceMaxHeight = globalDevice.videoHeight
maxVideoHeight = 1080 ' default to 1080p in case all our validation checks fail
maxVideoWidth = 1920
if userMaxHeight = "auto"
if isValid(deviceMaxHeight) and deviceMaxHeight <> 0 and deviceMaxHeight > maxVideoHeight
maxVideoHeight = deviceMaxHeight
maxVideoWidth = globalDevice.videoWidth ' paired width for device's native video mode
end if
else
userMaxHeight = userMaxHeight.ToInt()
if isValid(userMaxHeight) and userMaxHeight > 0 and userMaxHeight < maxVideoHeight
maxVideoHeight = userMaxHeight
' Settings only stores height, so derive the paired 16:9 width
' (same standard resolution pairs as SaveDeviceToGlobal in globals.bs)
heightToWidth = {
"480": 720,
"576": 720,
"720": 1280,
"1080": 1920,
"2160": 3840,
"4320": 7680
}
mappedWidth = heightToWidth[maxVideoHeight.toStr()]
maxVideoWidth = isValid(mappedWidth) ? mappedWidth : int(maxVideoHeight * 16 / 9)
end if
end if
return [
{
"Condition": "LessThanEqual",
"Property": "Height",
"Value": maxVideoHeight.toStr(),
"IsRequired": isRequired
},
{
"Condition": "LessThanEqual",
"Property": "Width",
"Value": maxVideoWidth.toStr(),
"IsRequired": isRequired
}
]
end function
' Apply a paired Height + Width resolution cap to a codec profile's Conditions.
' No-ops if a Height condition at or below maxHeight already exists.
' When the existing cap is wider than maxHeight, replaces it (avoids duplicate conditions
' that can confuse the server's condition evaluation order).
' @param codecProfile {object} - The codec profile AA containing a Conditions array
' @param maxHeight {integer} - The max height to cap at (e.g. 1080)
' @param maxWidth {integer} - The max width to cap at (e.g. 1920)
' @param isRequired {boolean} - Whether the condition is required for direct play
sub applyResolutionCapToProfile(codecProfile as object, maxHeight as integer, maxWidth as integer, isRequired = false as boolean)
if not isValid(codecProfile) or not isValid(codecProfile.Conditions) then return
' Scan for existing Height/Width conditions and track their indices
heightIndex = -1
widthIndex = -1
for i = 0 to codecProfile.Conditions.count() - 1
condition = codecProfile.Conditions[i]
if isValid(condition.Property)
if condition.Property = "Height"
if condition.Value.toInt() <= maxHeight
return ' Existing cap is already at or below the target — nothing to do
end if
heightIndex = i
else if condition.Property = "Width"
widthIndex = i
end if
end if
end for
' Replace existing conditions in-place to avoid duplicates
if heightIndex >= 0
codecProfile.Conditions[heightIndex] = {
"Condition": "LessThanEqual",
"Property": "Height",
"Value": maxHeight.toStr(),
"IsRequired": isRequired
}
else
codecProfile.Conditions.push({
"Condition": "LessThanEqual",
"Property": "Height",
"Value": maxHeight.toStr(),
"IsRequired": isRequired
})
end if
if widthIndex >= 0
codecProfile.Conditions[widthIndex] = {
"Condition": "LessThanEqual",
"Property": "Width",
"Value": maxWidth.toStr(),
"IsRequired": isRequired
}
else
codecProfile.Conditions.push({
"Condition": "LessThanEqual",
"Property": "Width",
"Value": maxWidth.toStr(),
"IsRequired": isRequired
})
end if
end sub
' Receives and returns an assArray of supported profiles and levels for each video codec
function updateProfileArray(profileArray as object, videoCodec as string, videoProfile as string, profileLevel = "" as string) as object
' validate params
if not isValid(profileArray) then return {}
if videoCodec = "" or videoProfile = "" then return profileArray
if not isValid(profileArray[videoCodec])
profileArray[videoCodec] = {}
end if
if not isValid(profileArray[videoCodec][videoProfile])
profileArray[videoCodec][videoProfile] = {}
end if
' add profileLevel if a value was provided
if profileLevel <> ""
if not isValid(profileArray[videoCodec][videoProfile][profileLevel])
profileArray[videoCodec][videoProfile].AddReplace(profileLevel, true)
end if
end if
return profileArray
end function
' Remove all decimals from a string
function removeDecimals(value as string) as string
r = CreateObject("roRegex", "\.", "")
value = r.ReplaceAll(value, "")
return value
end function
' Convert HEVC level from float format (e.g., 5.0, 5.1, 4.1) to Jellyfin string format
' HEVC levels are multiplied by 30 to convert to the integer representation
' Examples: 5.0 → "150", 5.1 → "153", 4.1 → "123"
function convertHevcLevelToString(level as float) as string
return (level * 30).toStr()
end function
' does this roku device support playing 4k video?
function canPlay4k() as boolean
deviceInfo = CreateObject("roDeviceInfo")
hdmiStatus = CreateObject("roHdmiStatus")
' Check if the output mode is 2160p or higher
maxVideoHeight = m.global.device.videoHeight
if not isValid(maxVideoHeight) then return false
if maxVideoHeight < 2160
print "maxVideoHeight is less than 2160p. Does the TV support 4K? If yes, then go to your Roku settings and set your display type to 4K"
return false
end if
' Check if HDCP 2.2 is enabled, skip check for TVs
if deviceInfo.GetModelType() = "STB" and hdmiStatus.IsHdcpActive("2.2") <> true
print "HDCP 2.2 is not active"
return false
end if
' Check if the Roku player can decode 4K 60fps HEVC streams
if deviceInfo.CanDecodeVideo({ Codec: "hevc", Profile: "main", Level: "5.1" }).result <> true
print "Device cannot decode 4K 60fps HEVC streams"
return false
end if
return true
end function
' ========================================
' VIDEO BUFFER PROTECTION FUNCTIONS
' ========================================
' Returns the device's video buffer size in bytes.
' 512MB RAM devices have a smaller buffer than all other devices.
' @return {LongInteger} Buffer size in bytes
function getDeviceBufferSize() as longinteger
if m.global.device.isLowMemoryDevice
return DEVICE_BUFFER_SIZE_LOW_MEMORY
end if
return DEVICE_BUFFER_SIZE_NORMAL
end function
' Calculates optimal HLS transcoding parameters to prevent buffer overflow and playlist errors.
'
' Solves two constraints simultaneously:
' 1. Buffer: (bitrate × segmentDuration) / 8 ≤ bufferSize × BUFFER_SAFETY_FACTOR
' 2. Playlist: videoDuration / segmentDuration ≤ MAX_HLS_PLAYLIST_ENTRIES
'
' Targets 6s segments for optimal startup and seek performance. Goes shorter if the buffer
' forces it, or longer (up to 9s) only when the playlist constraint requires it.
' Only reduces bitrate as a last resort when no segment length satisfies both constraints.
'
' @param {LongInteger} bitrateBps - Source media total bitrate in bits per second
' @param {Integer} videoDurationSeconds - Video duration in seconds
' @param {LongInteger} bufferSizeBytes - Device video buffer size in bytes
' @return {Object} { segmentLength: Integer, maxBitrate: LongInteger }
' segmentLength: optimal HLS segment duration (1-9 seconds)
' maxBitrate: max streaming bitrate in bps (0 = no reduction needed)
function calculateOptimalTranscodingParams(bitrateBps as longinteger, videoDurationSeconds as integer, bufferSizeBytes as longinteger) as object
defaultResult = { segmentLength: 6, maxBitrate: 0& }
' Guard: invalid inputs - return defaults
if bitrateBps <= 0 or videoDurationSeconds <= 0 or bufferSizeBytes <= 0
return defaultResult
end if
' Calculate per-segment budget: buffer must hold BUFFER_SEGMENT_COUNT segments simultaneously
' (Roku buffers multiple segments for look-ahead playback)
segmentBudget& = (bufferSizeBytes * BUFFER_SAFETY_FACTOR) / BUFFER_SEGMENT_COUNT
' Calculate max segment duration that fits in the per-segment budget
' Formula: maxDuration = (segmentBudget × 8) / bitrate
' Preserve raw (pre-clamp) value: a result of 0 means even a 1s segment overflows
' the buffer at the source bitrate, and must be treated as infeasible (bitrate reduction needed).
rawMaxSegDuration = Int((segmentBudget& * 8.0) / bitrateBps)
' Calculate min segment duration to keep playlist within entry limit
' Formula: minDuration = ceil(videoDuration / maxPlaylistEntries)
minSegDuration = 1
if videoDurationSeconds > MAX_HLS_PLAYLIST_ENTRIES
' Ceiling division without floating point: (a + b - 1) / b
minSegDuration = (videoDurationSeconds + MAX_HLS_PLAYLIST_ENTRIES - 1) \ MAX_HLS_PLAYLIST_ENTRIES
end if
' Clamp maxSegDuration to valid range [1, 9] (buffer upper bound)
maxSegDuration = rawMaxSegDuration
if maxSegDuration > 9 then maxSegDuration = 9
if maxSegDuration < 1 then maxSegDuration = 1
if minSegDuration < 1 then minSegDuration = 1
' Prefer 6s segments. Only deviate when forced by a constraint.
' Case 1: 6s satisfies both constraints (buffer supports it, playlist doesn't require longer).
if rawMaxSegDuration >= 6 and minSegDuration <= 6
return { segmentLength: 6, maxBitrate: 0& }
end if
' Case 2: Buffer forces shorter than 6s, and the shorter length satisfies the playlist constraint.
' No bitrate reduction needed; source bitrate already fits the smaller segments.
if rawMaxSegDuration >= 1 and rawMaxSegDuration < 6 and minSegDuration <= maxSegDuration
return { segmentLength: maxSegDuration, maxBitrate: 0& }
end if
' Case 3+: Playlist forces longer than 6s, or source bitrate overflows even 1s segments.
' Use minSegDuration (capped at 9s). Content over ~12.5h (45,000s) cannot satisfy the
' playlist constraint within this range, but that duration is beyond expected use.
segmentLength = minSegDuration
if segmentLength > 9 then segmentLength = 9
' No bitrate reduction needed if the buffer supports this length at source bitrate.
if rawMaxSegDuration >= segmentLength
return { segmentLength: segmentLength, maxBitrate: 0& }
end if
' Buffer overflow: reduce bitrate to fit segmentLength-sized segments in the per-segment budget.
' Formula: maxBitrate = (segmentBudget × 8) / segmentDuration
maxBitrate& = (segmentBudget& * 8&) \ segmentLength
return { segmentLength: segmentLength, maxBitrate: maxBitrate& }
end function