source_api_items.bs

import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiIds.bs"
import "pkg:/source/api/apiResponse.bs"
import "pkg:/source/api/image.bs"
import "pkg:/source/data/JellyfinDataTransformer.bs"
import "pkg:/source/enums/NextUpSeriesStart.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"
import "pkg:/source/utils/streamSelection.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)
  res = ItemPostPlaybackInfoResponse(id, mediaSourceId, audioTrackIndex, subtitleTrackIndex, startTimeTicks, videoMetadata, bypassDoviPreservation, forceTranscoding)
  if isValid(res) and res.ok then return res.json
  return invalid
end function

' ItemPostPlaybackInfo's pool response itself, so a caller can tell an answer that never came
' (apiResponse.noAnswer) from a refusal.
'
' @param {integer} timeoutMs - the request's HTTP limit (apiTimeout); 0 keeps the default
' @return the pool response AA, or invalid when the caller's wait ran out
function ItemPostPlaybackInfoResponse(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, timeoutMs = 0 as integer) as dynamic
  postData = buildPlaybackInfoPostData(mediaSourceId, audioTrackIndex, subtitleTrackIndex, startTimeTicks, videoMetadata, bypassDoviPreservation, forceTranscoding)
  req = GetApi().BuildPostPlaybackInfoRequest(id, postData)
  if isValid(req) and timeoutMs > 0 then req.timeoutMs = timeoutMs
  return fetchRes(req, "playbackInfo")
end function

' buildPlaybackInfoPostData: The PlaybackInfo request body, tuned to the version being played.
'
' Split from ItemPostPlaybackInfo so the body can be tested without a server.
'
' @return {object} the POST body for BuildPostPlaybackInfoRequest
function buildPlaybackInfoPostData(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) as object
  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.
  '
  ' Everything below that reads the source (its bitrate and runtime here, its streams and
  ' container for the Dolby Vision, H264 and audio decisions) reads the version this request
  ' is for, not the server's default (mediaSourceForId).
  playedSource = invalid
  if isValid(videoMetadata) and isValid(videoMetadata.mediaSourcesData)
    playedSource = mediaSourceForId(videoMetadata.mediaSourcesData.mediaSources, mediaSourceId)
  end if
  if isValid(playedSource)
    mediaSource = playedSource
    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(playedSource) and isValid(playedSource.MediaStreams)
    mediaStreams = playedSource.MediaStreams
  end if

  playedContainer = ""
  if isValid(playedSource)
    playedContainer = playedSource.Container
  end if
  doviProfile = doviRemuxContainerProfile(mediaStreams, playedContainer, globalUser.settings.playbackPreserveDovi, bypassDoviPreservation, getApiVersionFromGlobal())
  if isValid(doviProfile)
    postData.DeviceProfile.ContainerProfiles.push(doviProfile)
  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 (for example, 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, globalUser.settings.playbackPreferredMultichannelCodec)
        end if
      end if
    end if
  end if

  return postData
end function

' doviRemuxContainerProfile: The container profile that forces a remux of Dolby Vision video in an mkv file, or invalid.
'
' DOVI is not supported in the MKV container, so a remux (container swap) is forced for it.
' The profile tells the server that mkv is only acceptable when VideoRangeType differs from the
' stream's own value.
'
' Hardware is read only after the cheap checks pass: the setting must be on and the caller must
' not have bypassed it. Only the first video stream with a codec other than AV1 is considered.
'
' @param {dynamic} mediaStreams - MediaStream array of the played source, or invalid
' @param {dynamic} container - the played source's container, or invalid
' @param {dynamic} preserveDovi - the playbackPreserveDovi user setting, or invalid
' @param {boolean} bypass - true when retrying after a buffer overflow, to allow direct play
' @param {integer} apiVersion - the server's API tier (VideoRangeType needs tier 2)
' @param {dynamic} mockCapabilities - capabilities AA with isMock = true, for tests
' @return {dynamic} the container profile AA to add to ContainerProfiles, or invalid
function doviRemuxContainerProfile(mediaStreams as dynamic, container as dynamic, preserveDovi as dynamic, bypass as boolean, apiVersion as integer, mockCapabilities = invalid as dynamic) as dynamic
  ' Only run DOVI logic if the user setting exists and is enabled, and caller has not bypassed it
  ' (bypass is set when retrying after a buffer overflow to allow direct play)
  if not (not bypass and isValid(mediaStreams) and isValid(preserveDovi) and preserveDovi)
    return invalid
  end if

  ' Only proceed with DOVI logic if device supports DOVI
  if not getDeviceVideoCapabilities(mockCapabilities).supportsDolbyVision
    return invalid
  end if

  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"
      containerString = JDT_normalizeContainer(container ?? "")

      ' DOVI remux logic requires VideoRangeType which was added in Jellyfin 10.9
      ' Only apply this for API v2+ servers (10.9+)
      if apiVersion >= 2 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)
          return {
            "Type": "Video",
            "Container": "mkv",
            "Conditions": [
              {
                "Condition": "NotEquals",
                "Property": "VideoRangeType",
                "Value": stream.VideoRangeType,
                "IsRequired": true
              }
            ]
          }
        end if
      end if
      exit for
    end if
  end for

  return invalid
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()
  item = transformer.transformBaseItem(data)
  inheritSeriesOriginalLanguage(item)
  return item
end function

' inheritSeriesOriginalLanguage: Give an episode its series' OriginalLanguage when track
' selection needs it (needsSeriesOriginalLanguage()), as the server does for its own pick.
' One extra request, made only for a viewer whose audio preference is "Original language".
'
' @param {dynamic} item - JellyfinBaseItem from ItemMetaData()
sub inheritSeriesOriginalLanguage(item as dynamic)
  localUser = m.global.user
  if not needsSeriesOriginalLanguage(item, localUser.settings, localUser.config) then return
  series = fetchJson(GetApi().BuildGetItemRequest(item.seriesId), "seriesOriginalLanguage")
  if isValid(series) and isValidAndNotEmpty(series.OriginalLanguage)
    item.originalLanguage = series.OriginalLanguage
  end if
end sub

' 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 extras requests ─────────────────────────────────────────────────
' Request builders for ItemDetails' music extras rows, which LoadExtrasRowsTask pipelines.
' They return a request AA rather than fetching, so the orchestrator owns the round trip,
' but the params are built here, in the API layer, because that is the scope the API usage
' manifest reads its request fields from (scripts/generate/api-usage-manifest.js). Built in
' a component instead, these fields would silently drop out of server-upgrade triage.

' Albums where this artist is the album artist (strict: AlbumArtistIds only), newest first.
' Does not include albums the artist only contributes to. See BuildArtistAppearsOnRequest.
function BuildArtistAlbumsRequest(artistId as string) as dynamic
  return GetApi().BuildGetItemsByQueryRequest({
    "AlbumArtistIds": artistId,
    "includeitemtypes": "MusicAlbum",
    "sortBy": "PremiereDate,ProductionYear,SortName",
    "SortOrder": "Descending",
    "Recursive": true
  })
end function

' Albums an artist appears on as a contributing artist but is not the album artist of:
' Jellyfin's ContributingArtistIds, so mutually exclusive with BuildArtistAlbumsRequest.
' Newest first.
function BuildArtistAppearsOnRequest(artistId as string) as dynamic
  return GetApi().BuildGetItemsByQueryRequest({
    "ContributingArtistIds": artistId,
    "includeitemtypes": "MusicAlbum",
    "sortBy": "PremiereDate,ProductionYear,SortName",
    "SortOrder": "Descending",
    "Recursive": true
  })
end function

' All Audio tracks by an artist in any capacity (album artist OR contributing), so songs on
' compilations or with mismatched album-level tags still surface. Grouped by album then
' track; capped at 100 for prolific artists.
function BuildArtistSongsRequest(artistId as string) as dynamic
  return GetApi().BuildGetItemsByQueryRequest({
    "ArtistIds": artistId,
    "includeitemtypes": "Audio",
    "sortBy": "Album,ParentIndexNumber,IndexNumber,SortName",
    "Recursive": true,
    "Limit": 100,
    "EnableTotalRecordCount": false
  })
end function

' The Audio tracks on an album.
function BuildAlbumSongsRequest(albumId as string) as dynamic
  return GetApi().BuildGetItemsByQueryRequest({
    "parentId": albumId,
    "includeitemtypes": "Audio",
    "sortBy": "SortName"
  })
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
  if not supportsLyrics() then return invalid
  return fetchJson(GetApi().BuildGetItemLyricsRequest(itemId), "lyrics")
end function

' supportsLyrics: Checks if the connected Jellyfin server supports the Lyrics API.
'
' The /Audio/{itemId}/Lyrics endpoint was introduced in Jellyfin 10.9.0.
' Servers below 10.9.0 do not have this endpoint and will return 404. Guarding
' the request mirrors supportsMediaSegments() (source/utils/mediaSegments.bs) and
' lets the endpoint-availability ledger record this as a validated version-guard.
'
' @returns {boolean} - true if server supports lyrics
function supportsLyrics() as boolean
  return versionChecker(m.global.server.version, "10.9.0")
end function

' hasWatchedRegularEpisode: Checks whether a series has a watched episode that is not a special.
'
' Mirrors Jellyfin's own "last watched" rule for Next Up (played, ParentIndexNumber <> 0 or
' null), which is what decides whether a series counts as started. The client needs its own
' copy because Next Up's disableFirstEpisode param, which used to apply that rule for us, is
' ignored from Jellyfin 10.11 and removed in 12.0 (#925).
'
' The REST API has no "not equals" filter, so the caller fetches watchedRegularEpisodeQuery():
' played episodes sorted ParentIndexNumber,IndexNumber descending with Limit 1. Specials
' (season 0) sort below every regular season, so a special on top means no regular episode
' was watched.
'
' @param {dynamic} playedEpisodes - Items query result: { Items: [BaseItemDto] } or invalid
' @returns {boolean} - true if the top played episode is not a special
function hasWatchedRegularEpisode(playedEpisodes as dynamic) as boolean
  if not isValid(playedEpisodes) or not isValidAndNotEmpty(playedEpisodes.Items) then return false
  seasonNumber = playedEpisodes.Items[0].ParentIndexNumber
  if not isValid(seasonNumber) then return true
  return seasonNumber <> 0
end function

' watchedRegularEpisodeQuery: Items query params whose result hasWatchedRegularEpisode() reads.
'
' @param {string} seriesId - The series to check
' @returns {object} - Params for BuildGetItemsByQueryRequest
function watchedRegularEpisodeQuery(seriesId as string) as object
  return {
    ParentId: seriesId,
    Recursive: true,
    IncludeItemTypes: "Episode",
    IsPlayed: true,
    SortBy: "ParentIndexNumber,IndexNumber",
    SortOrder: "Descending",
    Limit: 1,
    EnableTotalRecordCount: false,
    EnableImages: false
  }
end function

' nextUpSeriesIds: The series behind a Next Up response's items, each once, in response order.
'
' @param {dynamic} items - Next Up items: [BaseItemDto] or invalid
' @returns {object} - Array of SeriesId strings
function nextUpSeriesIds(items as dynamic) as object
  ids = []
  if not isValidAndNotEmpty(items) then return ids
  seen = {}
  for each item in items
    seriesId = item.SeriesId
    if isValidAndNotEmpty(seriesId) and not seen.DoesExist(seriesId)
      seen[seriesId] = true
      ids.push(seriesId)
    end if
  end for
  return ids
end function

' playedSpecialsQuery: Items query params for every played special (season 0) in the library.
'
' Next Up's started-series filter reads it (planNextUpSeriesLookups). It does not depend on the
' Next Up response, so it can be sent alongside it.
'
' @returns {object} - Params for BuildGetItemsByQueryRequest
function playedSpecialsQuery() as object
  return {
    Recursive: true,
    IncludeItemTypes: "Episode",
    IsPlayed: true,
    ParentIndexNumber: 0,
    EnableImages: false,
    EnableUserData: false,
    EnableTotalRecordCount: false
  }
end function

' planNextUpSeriesLookups: Splits Next Up's series between the two ways of deciding them.
'
' A series with a played special goes to the exact per-series check (hasWatchedRegularEpisode),
' because its counts include that special. Every other series is decided by the batched count
' lookup (classifyNextUpSeries). With no answer for the specials, every series is counted.
'
' @param {object} seriesIds - nextUpSeriesIds() result
' @param {dynamic} playedSpecials - playedSpecialsQuery() items: [BaseItemDto] or invalid
' @returns {object} - { counted: [SeriesId], exact: [SeriesId] }
function planNextUpSeriesLookups(seriesIds as object, playedSpecials as dynamic) as object
  hasPlayedSpecial = {}
  if isValid(playedSpecials)
    for each special in playedSpecials
      if isValidAndNotEmpty(special.SeriesId) then hasPlayedSpecial[special.SeriesId] = true
    end for
  end if

  plan = { counted: [], exact: [] }
  for each seriesId in seriesIds
    if hasPlayedSpecial.DoesExist(seriesId)
      plan.exact.push(seriesId)
    else
      plan.counted.push(seriesId)
    end if
  end for
  return plan
end function

' classifyNextUpSeries: Whether each Next Up series has a watched regular episode.
'
' From 10.11 the server offers a series' first episode once ANY of its episodes was started,
' even with none finished: jellyfin/jellyfin#13687 dropped the filter that 10.10 applied with
' DisableFirstEpisode (#973). The rule that filter applied is hasWatchedRegularEpisode()'s,
' and this decides it from batched lookups instead of one request per series.
'
' It reads the series' own counts (Fields=RecursiveItemCount): nothing played means
' NOT_STARTED, anything played means STARTED. Both counts skip folders and missing episodes, so
' they are equal exactly when nothing is played. The counts include specials, which is why
' planNextUpSeriesLookups() sends a series with a played special to its own exact check
' instead. When the specials lookup got no answer, a series whose only played episode is a
' special counts as STARTED: an unanswered lookup never removes an episode from the row.
'
' A series missing from the lookup, or without its counts, is left out.
'
' @param {object} seriesItems - Series lookup items: [BaseItemDto]
' @returns {object} - AA of SeriesId -> NextUpSeriesStart value
function classifyNextUpSeries(seriesItems as object) as object
  starts = {}
  for each series in seriesItems
    total = series.RecursiveItemCount
    unplayed = invalid
    if isValid(series.UserData) then unplayed = series.UserData.UnplayedItemCount
    if isValidAndNotEmpty(series.Id) and isValid(total) and isValid(unplayed)
      if unplayed >= total
        starts[series.Id] = NextUpSeriesStart.NOT_STARTED
      else
        starts[series.Id] = NextUpSeriesStart.STARTED
      end if
    end if
  end for
  return starts
end function

' nextUpLookupQueries: The lookups that decide the series in a plan, as pipeline entries.
'
' The counted series go out apiIds.PER_REQUEST to a request, so a long Next Up row cannot push
' a request line past what a server or proxy accepts. Each exact series gets its own
' watchedRegularEpisodeQuery(). Each entry carries `query` for BuildGetItemsByQueryRequest,
' and `lookup` (with `seriesId` for a check) for foldNextUpLookup().
'
' @param {object} plan - planNextUpSeriesLookups() result
' @returns {object} - Array of { requestId, query, lookup, seriesId? }
function nextUpLookupQueries(plan as object) as object
  entries = []
  chunkIndex = 0
  for each chunk in apiIds.chunks(plan.counted)
    entries.push({
      requestId: "nextUpSeries-" + chunkIndex.toStr(),
      query: { Ids: chunk.join(","), Fields: "RecursiveItemCount", EnableImages: false, EnableTotalRecordCount: false },
      lookup: "series"
    })
    chunkIndex++
  end for
  for each seriesId in plan.exact
    entries.push({
      requestId: "nextUpSeriesPlayed-" + seriesId,
      query: watchedRegularEpisodeQuery(seriesId),
      lookup: "check",
      seriesId: seriesId
    })
  end for
  return entries
end function

' newNextUpLookups: An empty tally for foldNextUpLookup().
'
' @returns {object} - { seriesItems: [BaseItemDto], starts: AA of SeriesId -> NextUpSeriesStart, unanswered: integer }
function newNextUpLookups() as object
  return { seriesItems: [], starts: {}, unanswered: 0 }
end function

' foldNextUpLookup: Adds one nextUpLookupQueries() answer to the tally.
'
' Series counts collect in `seriesItems` for classifyNextUpSeries(). A check decides its series
' in `starts` directly. A lookup with no answer, an error, or a body that is not a list only
' adds to `unanswered`, so its series stay undecided and keep their items.
'
' @param {object} found - newNextUpLookups() tally, updated in place
' @param {object} entry - The nextUpLookupQueries() entry the answer belongs to
' @param {dynamic} res - The pool response AA, or invalid when it never answered
sub foldNextUpLookup(found as object, entry as object, res as dynamic)
  if not isValid(res) or not res.ok
    found.unanswered++
    return
  end if

  answered = apiResponse.listFrom(res.json)
  if not isValid(answered)
    found.unanswered++
  else if entry.lookup = "series"
    found.seriesItems.append(answered)
  else if hasWatchedRegularEpisode(res.json)
    found.starts[entry.seriesId] = NextUpSeriesStart.STARTED
  else
    found.starts[entry.seriesId] = NextUpSeriesStart.NOT_STARTED
  end if
end sub

' keepStartedSeriesItems: Drops the Next Up items whose series is NOT_STARTED, keeping order.
'
' @param {object} items - Next Up items: [BaseItemDto]
' @param {object} starts - AA of SeriesId -> NextUpSeriesStart
' @returns {object} - The kept items
function keepStartedSeriesItems(items as object, starts as object) as object
  kept = []
  for each item in items
    if not (isValid(item.SeriesId) and starts[item.SeriesId] = NextUpSeriesStart.NOT_STARTED)
      kept.push(item)
    end if
  end for
  return kept
end function

' honorsDisableFirstEpisode: Whether a server acts on Next Up's DisableFirstEpisode parameter.
'
' Honored on 10.7–10.10. From 10.11 the server ignores it (#925), and 12.0 removes it. Send
' the parameter only where this returns true: it is registered as a version-gated parameter
' in docs/dev/jellyfin-endpoint-availability.yml, and lint:endpoint-availability fails a
' file that sends it without calling this.
'
' @param {string} serverVersion - Server version string, for example "10.10.7"
' @returns {boolean} - true below 10.11.0
function honorsDisableFirstEpisode(serverVersion as string) as boolean
  return not versionChecker(serverVersion, "10.11.0")
end function

' nextUpNeedsStartedFilter: Whether Home's Next Up row must drop unstarted series itself.
'
' True unless the request asks the server to drop them, so only servers that leave them in
' pay for the client's lookups (LoadItemsTask.keepStartedSeries). A server-side filter Jellyfin
' adds later is a version-gated parameter that buildHomeNextUpParams sends, and is recognized
' here next to DisableFirstEpisode.
'
' @param {object} params - buildHomeNextUpParams() result
' @returns {boolean} - true when the client has to filter
function nextUpNeedsStartedFilter(params as object) as boolean
  serverFilters = params.DisableFirstEpisode
  return not (isValid(serverFilters) and serverFilters = true)
end function

' buildHomeNextUpParams: Query parameters for the Home Next Up row.
'
' On 10.7–10.10, a user with no watch history inside the cutoff gets the first episode of
' every series (one per series, not a short list). DisableFirstEpisode=true drops those, but
' on 10.8–10.10 it also makes the server ignore NextUpDateCutoff. So it is sent only where
' it costs nothing: always on 10.7 (which has no cutoff), and on 10.8–10.10 only when Max
' Days is off. With Max Days on, those servers keep the cutoff and still add first episodes.
' From 10.11 the parameter does nothing, and the server adds the first episode of every series
' with an episode started but none finished. Wherever the parameter is not sent, LoadItemsTask
' drops those itself (nextUpNeedsStartedFilter), so every version shows the same row and a
' user with no finished episode gets no Next Up row. That is intended, and differs from
' jellyfin-web, which shows those first episodes.
'
' @param {string} serverVersion - Server version string, for example "10.10.7"
' @param {boolean} enableRewatching - uiDetailsEnableRewatchingNextUp
' @param {dynamic} maxDays - uiDetailsMaxDaysNextUp; 0 or invalid means off
' @param {integer} nowSeconds - Current time as epoch seconds, for the cutoff
' @returns {object} - Params for BuildGetNextUpRequest
function buildHomeNextUpParams(serverVersion as string, enableRewatching as boolean, maxDays as dynamic, nowSeconds as integer) as object
  params = {}
  params["recursive"] = true
  params["SortBy"] = "DatePlayed"
  params["SortOrder"] = "Descending"
  params["EnableRewatching"] = enableRewatching
  ' No `limit`, on purpose. Next Up is a worklist: the user is trying to get through it,
  ' so any cap hides episodes they are part-way into a series on, and no number is the right
  ' one to hide at. It sent 69 for a long time, which was arbitrary rather than derived.
  ' `uiHomeRowLimit` does not reach here, on purpose: that setting sizes the browse feeds
  ' (Recently Added / On Now / Active Recordings), where a limit chooses how much of a
  ' larger set to sample. Continue Watching, the other worklist, sends no limit either and
  ' has shipped that way. The two now match on purpose rather than by omission.
  params["EnableTotalRecordCount"] = false
  params["EnableResumable"] = false
  ' Drives the alternate-versions badge. The server only emits the field for an item that
  ' HAS several versions, so the cost scales with those rather than with the row: measured
  ' 2026-09-19 against a local 12.0 server at +21 bytes for one such item, and +85 bytes
  ' across a 100-item library page holding four of them.
  params["Fields"] = "MediaSourceCount"

  maxDaysOn = isValid(maxDays) and maxDays > 0
  if maxDaysOn
    dateCutoff = CreateObject("roDateTime")
    dateCutoff.FromSeconds(nowSeconds - (maxDays * 86400))
    params["NextUpDateCutoff"] = dateCutoff.ToISOString()
  end if

  if honorsDisableFirstEpisode(serverVersion)
    if not versionChecker(serverVersion, "10.8.0") or not maxDaysOn
      params["DisableFirstEpisode"] = true
    end if
  end if

  return params
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

' 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 and the transcode retry flag to a postData
' object. Live TV sends no mediaSourceId. On the first attempt, direct play is not disabled so the
' server can evaluate compatibility (matching web client behaviour). On the retry after a failed
' direct play, forceTranscoding=true sets EnableDirectPlay=false, live or not: a server that allowed
' direct play would otherwise answer the retry with the same "yes" and no transcode URL.
'
' @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 <> "" then postData.MediaSourceId = mediaSourceId
  if forceTranscoding then postData.EnableDirectPlay = false
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.
'   If filtering empties a previously non-empty list, falls back to "mp3" (the intended target).
'
' Multichannel surround target (issue #573):
'   For a >2ch source, the stripped list must lead with a server-encodeable surround codec.
'   Two failure modes this prevents, both observed on Jellyfin 10.11 via PlaybackInfo probes:
'     - Empty list: Jellyfin's StreamBuilder omits the audioCodec query param, the server's
'       InferAudioCodec falls through to the container name ("m3u8"), ffmpeg gets
'       `-codec:a:0 m3u8` and exits 8. Happens when passthrough is detected only via codecs
'       not in surroundPriority (truehd/dtshd-only) so no surround codec is in the list.
'     - On-device-decode leak: a list with no surround codec but an on-device-decode one like
'       opus resolves server-side to opus, silently abandoning the user's surround/passthrough
'       intent for a decoded stereo-ish path.
'   Fix: prepend the user's preferred multichannel codec (playbackPreferredMultichannelCodec),
'   clamped to a reliably-encodeable HLS surround target, eac3 or ac3 (dts is excluded: the
'   server emits no audio stream for a dts transcode target). This is a no-op for healthy
'   profiles (their stripped list already leads with eac3/ac3) and only rescues the degenerate
'   truehd/dtshd-only shape.
sub optimizeAudioCodecListForSource(deviceProfile as object, channelCount as integer, preferredMultichannelCodec = "eac3" as string)
  ' 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 channelCount > 2 and rule.AudioCodec <> ""
          ' Multichannel with a real source list: guarantee a user-preferred, server-encodeable
          ' surround codec leads the list so the server can never resolve to an empty list (which becomes m3u8)
          ' or an on-device-decode codec (opus/aac). See block comment above for the server-side
          ' bugs this prevents. (An originally-empty AudioCodec is left untouched: optimize rescues
          ' lists it would otherwise empty, it doesn't invent a codec where the profile had none.)
          surroundTarget = resolveMultichannelSurroundTarget(preferredMultichannelCodec)
          finalList = [surroundTarget]
          for each codec in newCodecList
            if codec <> surroundTarget
              finalList.push(codec)
            end if
          end for
          rule.AudioCodec = finalList.join(",")
        else if channelCount <= 2
          ' Stereo AAC-profile case: if filtering emptied a previously non-empty list, fall back
          ' to "mp3" (the intended fallthrough target) rather than re-emitting an empty AudioCodec.
          if newCodecList.count() = 0 and rule.AudioCodec <> ""
            newCodecList = ["mp3"]
          end if
          rule.AudioCodec = newCodecList.join(",")
        else
          ' Multichannel but the profile had an originally-empty AudioCodec: leave it empty
          ' (handled gracefully; optimize does not invent a codec where the profile had none).
          rule.AudioCodec = newCodecList.join(",")
        end if
      end if
    end if
  end for
end sub

' Resolve the surround transcode target for a multichannel source from the user's preferred
' multichannel codec setting (playbackPreferredMultichannelCodec). Clamped to the reliably-
' encodeable HLS surround targets: eac3 (default) and ac3. dts is intentionally excluded: the
' Jellyfin server emits no audio stream for a dts transcode target (verified on 10.11), so a dts
' fallback would itself re-trigger the issue #573 failure. Both eac3 and ac3 are valid HLS audio
' targets in every video transcoding container (ts and mp4), so no per-container clamp is needed.
function resolveMultichannelSurroundTarget(preferredMultichannelCodec as string) as string
  if isValidAndNotEmpty(preferredMultichannelCodec) and LCase(preferredMultichannelCodec) = "ac3"
    return "ac3"
  end if
  return "eac3"
end function