source_api_Items.bs

import "pkg:/source/api/Image.bs"
import "pkg:/source/api/sdk.bs"
import "pkg:/source/enums/SubtitleSelection.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/misc.bs"

function ItemPostPlaybackInfo(id as string, mediaSourceId = "" as string, audioTrackIndex = -1 as integer, subtitleTrackIndex = SubtitleSelection.none as integer, startTimeTicks = 0& as longinteger, videoMetadata = invalid as dynamic)
  postData = {
    "UserId": m.global.user.id,
    "StartTimeTicks": startTimeTicks,
    "AutoOpenLiveStream": true
    ' "AlwaysBurnInSubtitleWhenTranscoding": true
  }
  postData.DeviceProfile = getDeviceProfile()

  if subtitleTrackIndex <> SubtitleSelection.none and subtitleTrackIndex <> SubtitleSelection.notset
    postData.SubtitleStreamIndex = subtitleTrackIndex
  end if

  ' Note: Jellyfin v10.9+ now remuxs LiveTV and does not allow DirectPlay anymore.
  ' Because of this, we need to tell the server "EnableDirectPlay = false" so that we receive the
  ' transcoding URL (which is just a remux and not a transcode; unless it is)
  ' The web handles this by disabling EnableDirectPlay on a Retry, but we don't currently Retry a Live
  ' TV stream, thus we just turn it off on the first try here.
  if mediaSourceId <> ""
    postData.MediaSourceId = mediaSourceId
  else
    ' No mediaSourceId? Must be LiveTV...
    postData.EnableDirectPlay = false
  end if

  if audioTrackIndex > -1
    if isValid(videoMetadata) and isValid(videoMetadata.json) and isValid(videoMetadata.json.MediaStreams)
      ' Find the audio stream with the matching Jellyfin index
      selectedAudioStream = invalid
      for each stream in videoMetadata.json.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

        ' Remove AAC from codec list in two scenarios:
        ' 1. ANY multichannel source (>2ch) when user has passthrough support - prevents transcoding to AAC which would downmix to stereo
        ' 2. Unsupported AAC profiles (Main, HE-AAC) - TODO: Remove after server supports transcoding between AAC profiles
        shouldRemoveAac = false

        ' Scenario 1: ANY multichannel source with passthrough support
        ' Removes AAC to force server to use surround passthrough codecs (eac3, ac3, dts) instead of transcoding to stereo AAC
        if channelCount > 2 and hasPassthruSupport
          shouldRemoveAac = true
        end if

        ' Scenario 2: AAC with unsupported profile
        if isValid(selectedAudioStream.Codec) and LCase(selectedAudioStream.Codec) = "aac"
          if isValid(selectedAudioStream.Profile) and (LCase(selectedAudioStream.Profile) = "main" or LCase(selectedAudioStream.Profile) = "he-aac")
            shouldRemoveAac = true
          end if
        end if

        if shouldRemoveAac
          removeUnsupportedAacFromProfile(postData.DeviceProfile, channelCount)
        end if
      end if
    end if
  end if

  req = APIRequest(Substitute("Items/{0}/PlaybackInfo", id))
  req.SetRequest("POST")
  return postJson(req, FormatJson(postData))
end function

' Search across all libraries
function searchMedia(query as string)
  if query <> ""
    data = api.users.GetItemsByQuery(m.global.user.id, {
      "searchTerm": query,
      "IncludePeople": true,
      "IncludeMedia": true,
      "IncludeShows": true,
      "IncludeGenres": true,
      "IncludeStudios": true,
      "IncludeArtists": true,
      "IncludeItemTypes": "LiveTvChannel,Movie,BoxSet,Series,Episode,Video,Person,Audio,MusicAlbum,MusicArtist,Playlist",
      "EnableTotalRecordCount": false,
      "ImageTypeLimit": 1,
      "Recursive": true,
      "limit": 100
    })

    if not isValid(data) then return []

    results = []
    for each item in data.Items
      tmp = CreateObject("roSGNode", "SearchData")
      tmp.image = PosterImage(item.id)
      tmp.json = item
      results.push(tmp)
    end for
    data.Items = results
    return data
  end if
  return []
end function

' MetaData about an item
function ItemMetaData(id as string)
  url = Substitute("Users/{0}/Items/{1}", m.global.user.id, id)
  resp = APIRequest(url, { "fields": "Chapters" })
  data = getJson(resp)
  if not isValid(data) then return invalid

  imgParams = {}
  if data.type <> "Audio"
    if isValid(data.UserData) and isValid(data.UserData.PlayedPercentage)
      param = { "PercentPlayed": data.UserData.PlayedPercentage }
      imgParams.Append(param)
    end if
  end if
  if data.type = "Movie" or data.type = "MusicVideo"
    tmp = CreateObject("roSGNode", "MovieData")
    tmp.image = PosterImage(data.id, imgParams)
    tmp.json = data
    return tmp
  else if data.type = "Series"
    tmp = CreateObject("roSGNode", "SeriesData")
    tmp.image = PosterImage(data.id)
    tmp.json = data
    return tmp
  else if data.type = "Episode"
    tmp = CreateObject("roSGNode", "TVEpisodeData")
    tmp.image = PosterImage(data.id, imgParams)
    tmp.json = data
    return tmp
  else if data.type = "Recording"
    tmp = CreateObject("roSGNode", "RecordingData")
    tmp.image = PosterImage(data.id, imgParams)
    tmp.json = data
    return tmp
  else if data.type = "BoxSet" or data.type = "Playlist"
    tmp = CreateObject("roSGNode", "CollectionData")
    tmp.image = PosterImage(data.id, imgParams)
    tmp.json = data
    return tmp
  else if data.type = "Season"
    tmp = CreateObject("roSGNode", "TVSeasonData")
    tmp.image = PosterImage(data.id)
    tmp.json = data
    return tmp
  else if data.type = "Video"
    tmp = CreateObject("roSGNode", "VideoData")
    tmp.json = data
    return tmp
  else if data.type = "Trailer"
    tmp = CreateObject("roSGNode", "VideoData")
    tmp.json = data
    return tmp
  else if data.type = "TvChannel" or data.type = "Program"
    tmp = CreateObject("roSGNode", "ChannelData")
    tmp.image = PosterImage(data.id)
    tmp.isFavorite = data.UserData.isFavorite
    tmp.json = data
    return tmp
  else if data.type = "Person"
    tmp = CreateObject("roSGNode", "PersonData")
    tmp.image = PosterImage(data.id, { "MaxWidth": 300, "MaxHeight": 450 })
    tmp.json = data
    return tmp
  else if data.type = "MusicArtist"
    ' User clicked on an artist and wants to see the list of their albums
    tmp = CreateObject("roSGNode", "MusicArtistData")
    tmp.image = PosterImage(data.id)
    tmp.json = data
    return tmp
  else if data.type = "MusicAlbum"
    ' User clicked on an album and wants to see the list of songs
    tmp = CreateObject("roSGNode", "MusicAlbumSongListData")
    tmp.image = PosterImage(data.id)
    tmp.json = data
    return tmp
  else if data.type = "Audio"
    ' User clicked on a song and wants it to play
    tmp = CreateObject("roSGNode", "MusicSongData")

    ' Try using song's parent for poster image
    tmp.image = PosterImage(data.ParentId, { "MaxWidth": 500, "MaxHeight": 500 })

    ' Song's parent poster image is no good, try using the song's poster image
    if not isValid(tmp.image)
      tmp.image = PosterImage(data.id, { "MaxWidth": 500, "MaxHeight": 500 })
    end if

    tmp.json = data
    return tmp
  else if data.type = "Recording"
    ' We know it's "Recording", but we don't do any special preprocessing
    ' for this data type at the moment, so just return the json.
    return data
  else
    print "Items.brs::ItemMetaData processed unhandled type: " data.type
    ' Return json if we don't know what it is
    return data
  end if
end function

' Music Artist Data
function ArtistOverview(name as string)
  req = createObject("roUrlTransfer")
  url = Substitute("Artists/{0}", req.escape(name))
  resp = APIRequest(url)
  data = getJson(resp)
  if not isValid(data) then return invalid
  return data.overview
end function

' Get list of albums belonging to an artist
function MusicAlbumList(id as string)
  url = Substitute("Users/{0}/Items", m.global.user.id)
  resp = APIRequest(url, {
    "AlbumArtistIds": id,
    "includeitemtypes": "MusicAlbum",
    "sortBy": "SortName",
    "Recursive": true
  })

  data = getJson(resp)
  results = []
  for each item in data.Items
    tmp = CreateObject("roSGNode", "MusicAlbumData")
    tmp.image = PosterImage(item.id)
    tmp.json = item
    results.push(tmp)
  end for
  data.Items = results
  return data
end function

' Get list of albums an artist appears on
function AppearsOnList(id as string)
  url = Substitute("Users/{0}/Items", m.global.user.id)
  resp = APIRequest(url, {
    "ContributingArtistIds": id,
    "ExcludeItemIds": id,
    "includeitemtypes": "MusicAlbum",
    "sortBy": "PremiereDate,ProductionYear,SortName",
    "SortOrder": "Descending",
    "Recursive": true
  })

  data = getJson(resp)
  results = []
  for each item in data.Items
    tmp = CreateObject("roSGNode", "MusicAlbumData")
    tmp.image = PosterImage(item.id)
    tmp.json = item
    results.push(tmp)
  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)
  url = Substitute("Users/{0}/Items", m.global.user.id)
  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

  resp = APIRequest(url, paramArray)
  data = getJson(resp)
  results = []

  if not isValid(data) then return invalid
  if not isValid(data.Items) then return invalid
  if data.Items.Count() = 0 then return invalid

  for each item in data.Items
    tmp = CreateObject("roSGNode", "MusicAlbumData")
    tmp.image = PosterImage(item.id)
    tmp.json = item
    results.push(tmp)
  end for
  data.Items = results
  return data
end function

' Get Items that are under the provided item
function PlaylistItemList(id as string)
  url = Substitute("Playlists/{0}/Items", id)
  resp = APIRequest(url, {
    "UserId": m.global.user.id
  })

  results = []
  data = getJson(resp)

  if not isValid(data) then return invalid
  if not isValid(data.Items) then return invalid
  if data.Items.Count() = 0 then return invalid

  for each item in data.Items
    tmp = CreateObject("roSGNode", "PlaylistData")
    tmp.image = PosterImage(item.id)
    tmp.json = item
    results.push(tmp)
  end for
  data.Items = results
  return data
end function

' Get Songs that are on an Album
function MusicSongList(id as string)
  url = Substitute("Users/{0}/Items", m.global.user.id, id)
  resp = APIRequest(url, {
    "UserId": m.global.user.id,
    "parentId": id,
    "includeitemtypes": "Audio",
    "sortBy": "SortName"
  })

  results = []
  data = getJson(resp)

  if not isValid(data) then return invalid
  if not isValid(data.Items) then return invalid
  if data.Items.Count() = 0 then return invalid

  for each item in data.Items
    tmp = CreateObject("roSGNode", "MusicSongData")
    tmp.image = PosterImage(item.id)
    tmp.json = item
    results.push(tmp)
  end for
  data.Items = results
  return data
end function

' Get Songs that are on an Album
function AudioItem(id as string)
  url = Substitute("Users/{0}/Items/{1}", m.global.user.id, id)
  resp = APIRequest(url, {
    "UserId": m.global.user.id,
    "includeitemtypes": "Audio",
    "sortBy": "SortName"
  })

  return getJson(resp)
end function

' Get Instant Mix based on item
function CreateInstantMix(id as string)
  url = Substitute("/Items/{0}/InstantMix", id)
  resp = APIRequest(url, {
    "UserId": m.global.user.id,
    "Limit": 201
  })

  return getJson(resp)
end function

' Get Instant Mix based on item
function CreateArtistMix(id as string)
  url = Substitute("Users/{0}/Items", m.global.user.id)
  resp = APIRequest(url, {
    "ArtistIds": id,
    "Recursive": "true",
    "MediaTypes": "Audio",
    "Filters": "IsNotFolder",
    "SortBy": "SortName",
    "Limit": 300,
    "Fields": "Chapters",
    "ExcludeLocationTypes": "Virtual",
    "EnableTotalRecordCount": false,
    "CollapseBoxSetItems": false
  })

  return getJson(resp)
end function

' Get Intro Videos for an item
function GetIntroVideos(id as string)
  url = Substitute("Users/{0}/Items/{1}/Intros", m.global.user.id, id)
  resp = APIRequest(url, {
    "UserId": m.global.user.id
  })

  return getJson(resp)
end function

function AudioStream(id as string)
  songData = AudioItem(id)
  if isValid(songData)
    content = createObject("RoSGNode", "ContentNode")
    if isValid(songData.title)
      content.title = songData.title
    end if

    playbackInfo = ItemPostPlaybackInfo(songData.id, songData.mediaSources[0].id)
    if isValid(playbackInfo)
      content.id = playbackInfo.PlaySessionId

      if useTranscodeAudioStream(playbackInfo)
        ' Transcode the audio
        content.url = buildURL(playbackInfo.mediaSources[0].TranscodingURL)
      else
        ' Direct Stream the audio
        params = {
          "Static": "true",
          "Container": songData.mediaSources[0].container,
          "MediaSourceId": songData.mediaSources[0].id
        }
        content.streamformat = songData.mediaSources[0].container
        content.url = buildURL(Substitute("Audio/{0}/stream", songData.id), params)
      end if
    else
      return invalid
    end if

    return content
  else
    return invalid
  end if
end function

function useTranscodeAudioStream(playbackInfo)
  return isValid(playbackInfo.mediaSources[0]) and isValid(playbackInfo.mediaSources[0].TranscodingURL)
end function

function BackdropImage(id as string)
  imgParams = { "maxHeight": "720", "maxWidth": "1280" }
  return ImageURL(id, "Backdrop", imgParams)
end function

' Seasons for a TV Show
function TVSeasons(id as string) as dynamic
  url = Substitute("Shows/{0}/Seasons", id)
  resp = APIRequest(url, { "UserId": m.global.user.id })

  data = getJson(resp)
  ' validate data
  if not isValid(data) or not isValid(data.Items) then return invalid

  results = []
  for each item in data.Items
    tmp = CreateObject("roSGNode", "TVSeasonData")
    tmp.image = PosterImage(item.id)
    tmp.json = item
    results.push(tmp)
  end for
  data.Items = results
  return data
end function

' Returns a list of TV Shows for a given TV Show and season
' Accepts strings for the TV Show Id and the season Id
function TVEpisodes(showId as string, seasonId as string) as dynamic
  ' Get and validate data
  data = api.shows.GetEpisodes(showId, { "seasonId": seasonId, "UserId": m.global.user.id, "fields": "MediaStreams,MediaSources" })
  if not isValid(data) or not isValid(data.Items) then return invalid

  results = []
  for each item in data.Items
    tmp = CreateObject("roSGNode", "TVEpisodeData")
    tmp.image = PosterImage(item.id, { "maxWidth": 400, "maxheight": 250 })
    if isValid(tmp.image)
      tmp.image.posterDisplayMode = "scaleToZoom"
    end if
    tmp.json = item
    tmpMetaData = ItemMetaData(item.id)

    ' validate meta data
    if isValid(tmpMetaData) and isValid(tmpMetaData.overview)
      tmp.overview = tmpMetaData.overview
    end if
    results.push(tmp)
  end for
  data.Items = results
  return data
end function

' Returns a list of extra features for a TV Show season
' Accepts a string that is a TV Show season id
function TVSeasonExtras(seasonId as string) as dynamic
  ' Get and validate TV extra features data
  data = api.users.GetSpecialFeatures(m.global.user.id, seasonId)
  if not isValid(data) then return invalid

  results = []
  for each item in data
    tmp = CreateObject("roSGNode", "TVEpisodeData")
    tmp.image = PosterImage(item.id, { "maxWidth": 400, "maxheight": 250 })
    if isValid(tmp.image)
      tmp.image.posterDisplayMode = "scaleToZoom"
    end if
    tmp.json = item

    ' Force item type to Video so episode auto queue is not attempted
    tmp.type = "Video"
    tmpMetaData = ItemMetaData(item.id)

    ' Validate meta data
    if isValid(tmpMetaData) and isValid(tmpMetaData.overview)
      tmp.overview = tmpMetaData.overview
    end if
    results.push(tmp)
  end for

  ' Build that data format that the TVEpisodeRow expects
  return { Items: results }
end function

' Removes AAC from the device profile codec list to prevent stereo downmix of multichannel audio.
' Also handles unsupported AAC profiles (Main, HE-AAC).
' For stereo sources (≤2ch), also removes surround passthrough codecs (eac3, ac3, dts)
' so transcoding falls through to MP3 (better compatibility + smaller files).
sub removeUnsupportedAacFromProfile(deviceProfile as object, channelCount as integer)
  ' Validate inputs
  if not isValid(deviceProfile) then return
  if not isValid(deviceProfile.TranscodingProfiles) then return

  ' Surround passthrough codecs that should be removed for stereo sources
  surroundCodecs = ["eac3", "ac3", "dts"]

  for each rule in deviceProfile.TranscodingProfiles
    if rule.Container = "ts" or rule.Container = "mp4"
      if isValid(rule.AudioCodec)
        ' Split codec list into array
        codecList = rule.AudioCodec.split(",")
        newCodecList = []

        ' Remove AAC always, and surround codecs for stereo sources
        for each codec in codecList
          skipCodec = false

          ' Always skip AAC to prevent downmix
          if codec = "aac"
            skipCodec = true
          end if

          ' For stereo sources (≤2ch), also skip surround codecs
          ' This ensures transcoding goes to MP3 instead of trying AC3/EAC3
          if channelCount <= 2 and arrayHasValue(surroundCodecs, codec)
            skipCodec = true
          end if

          if not skipCodec
            newCodecList.push(codec)
          end if
        end for

        ' Rebuild codec string
        rule.AudioCodec = newCodecList.join(",")
      end if
    end if
  end for
end sub