source_api_items.bs

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