import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/items.bs"
import "pkg:/source/data/JellyfinDataTransformer.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/misc.bs"
sub init()
m.log = new log.Logger("LoadItemsTask")
m.top.functionName = "loadItems"
end sub
sub loadItems()
globalUser = m.global.user
m.transformer = JellyfinDataTransformer()
results = []
' Load Libraries
if m.top.itemsToLoad = "libraries"
data = fetchJson(GetApi().BuildGetViewsRequest(), "libraries")
if isValid(data) and isValid(data.Items)
for each item in data.Items
' Skip Books for now as we don't support it (issue #525)
if item.CollectionType <> "books"
results.push(m.transformer.transformBaseItem(item))
end if
end for
end if
' Load Latest Additions to Libraries
else if m.top.itemsToLoad = "latest"
activeUser = globalUser.id
if isValid(activeUser)
params = {}
params["Limit"] = 16
params["ParentId"] = m.top.itemId
params["EnableImageTypes"] = "Primary,Backdrop,Thumb"
params["ImageTypeLimit"] = 1
params["EnableTotalRecordCount"] = false
data = fetchJson(GetApi().BuildGetLatestMediaRequest(params), "latest")
' /Items/Latest returns a bare array of BaseItemDto (not paginated).
if isValid(data)
for each item in data
' Skip Books for now as we don't support it (issue #525)
if item.Type <> "Book"
results.push(m.transformer.transformBaseItem(item))
end if
end for
end if
end if
' Load Next Up
else if m.top.itemsToLoad = "nextUp"
userSettings = globalUser.settings
params = {}
params["recursive"] = true
params["SortBy"] = "DatePlayed"
params["SortOrder"] = "Descending"
params["EnableRewatching"] = userSettings.uiDetailsEnableRewatchingNextUp
params["DisableFirstEpisode"] = false
params["limit"] = 69
params["EnableTotalRecordCount"] = false
params["EnableResumable"] = false
maxDaysInNextUp = userSettings.uiDetailsMaxDaysNextUp
if isValid(maxDaysInNextUp)
if maxDaysInNextUp > 0
dateToday = CreateObject("roDateTime")
dateCutoff = CreateObject("roDateTime")
dateCutoff.FromSeconds(dateToday.AsSeconds() - (maxDaysInNextUp * 86400))
params["NextUpDateCutoff"] = dateCutoff.ToISOString()
end if
end if
data = fetchJson(GetApi().BuildGetNextUpRequest(params), "nextUp")
if isValid(data) and isValid(data.Items)
for each item in data.Items
results.push(m.transformer.transformBaseItem(item))
end for
end if
' Load Continue Watching
else if m.top.itemsToLoad = "continue"
activeUser = globalUser.id
if isValid(activeUser)
params = {}
params["recursive"] = true
params["SortBy"] = "DatePlayed"
params["SortOrder"] = "Descending"
params["Filters"] = "IsResumable"
params["EnableTotalRecordCount"] = false
data = fetchJson(GetApi().BuildGetResumeItemsRequest(params), "continue")
if isValid(data) and isValid(data.Items)
for each item in data.Items
' Skip Books for now as we don't support it (issue #558)
if item.Type <> "Book"
results.push(m.transformer.transformBaseItem(item))
end if
end for
end if
end if
else if m.top.itemsToLoad = "favorites"
params = {}
params["Filters"] = "IsFavorite"
params["recursive"] = true
params["SortBy"] = "SortName"
params["SortOrder"] = "Ascending"
params["EnableTotalRecordCount"] = false
data = fetchJson(GetApi().BuildGetItemsByQueryRequest(params), "favorites")
if isValid(data) and isValid(data.Items)
for each item in data.Items
' Skip Books for now as we don't support it (issue #558)
if item.Type <> "Book"
' Live TV recordings come back from /Items as the content type (Movie/Episode).
' The only reliable discriminator is the container format: recordings are always
' stored as MPEG transport streams ("ts"), while library items never are.
if LCase(item.Container ?? "") = "ts"
item.Type = "Recording"
end if
results.push(m.transformer.transformBaseItem(item))
end if
end for
end if
' People — favorited persons are not returned by /Items; requires /Persons endpoint.
personData = fetchJson(GetApi().BuildGetPersonsRequest({
"IsFavorite": true,
"SortBy": "SortName",
"SortOrder": "Ascending",
"EnableTotalRecordCount": false
}), "favoritePeople")
if isValid(personData) and isValid(personData.Items)
for each item in personData.Items
results.push(m.transformer.transformBaseItem(item))
end for
end if
' Note: MusicArtist items ARE returned by /Items above (they are regular library items),
' so no separate /Artists call is needed here (unlike search, where /Items misses them).
else if m.top.itemsToLoad = "onNow"
params = {}
params["userId"] = globalUser.id
params["isAiring"] = true
params["limit"] = 16 ' 16 to be consistent with "Latest In"
params["imageTypeLimit"] = 1
params["enableImageTypes"] = "Primary,Thumb,Backdrop"
params["enableTotalRecordCount"] = false
params["fields"] = "ChannelInfo,PrimaryImageAspectRatio"
data = fetchJson(GetApi().BuildGetLiveTvRecommendedProgramsRequest(params), "onNow")
if isValid(data) and isValid(data.Items)
for each item in data.Items
results.push(m.transformer.transformBaseItem(item))
end for
end if
else if m.top.itemsToLoad = "activeRecordings"
params = {}
params["userId"] = globalUser.id
params["isInProgress"] = true
params["limit"] = 16 ' parity with onNow
params["imageTypeLimit"] = 1
params["enableImageTypes"] = "Primary,Thumb,Backdrop"
params["enableTotalRecordCount"] = false
params["fields"] = "ChannelInfo,PrimaryImageAspectRatio"
data = fetchJson(GetApi().BuildGetLiveTvRecordingsRequest(params), "activeRecordings")
if isValid(data) and isValid(data.Items)
for each item in data.Items
results.push(m.transformer.transformBaseItem(item))
end for
end if
' Extract array of persons from Views and download full metadata for each
else if m.top.itemsToLoad = "people"
if not isValid(m.top.peopleList)
m.top.content = results
return
end if
for each person in m.top.peopleList
node = m.transformer.transformPerson(person)
if isValid(node) then results.push(node)
end for
else if m.top.itemsToLoad = "specialfeatures"
data = fetchJson(GetApi().BuildGetSpecialFeaturesRequest(m.top.itemId), "specialfeatures")
if isValid(data) and data.count() > 0
for each specfeat in data
node = m.transformer.transformBaseItem(specfeat)
if isValid(node) then results.push(node)
end for
end if
else if m.top.itemsToLoad = "additionalparts"
data = fetchJson(GetApi().BuildGetAdditionalPartsRequest(m.top.itemId), "additionalparts")
if isValid(data)
for each part in data.items
node = m.transformer.transformBaseItem(part)
if isValid(node) then results.push(node)
end for
end if
else if m.top.itemsToLoad = "likethis"
params = { "userId": globalUser.id, "limit": 16 }
data = fetchJson(GetApi().BuildGetSimilarItemsRequest(m.top.itemId, params), "likethis")
if isValid(data) and isValid(data.Items)
for each item in data.items
node = m.transformer.transformBaseItem(item)
if isValid(node) then results.push(node)
end for
end if
else if m.top.itemsToLoad = "personMovies"
getPersonVideos("Movie", results)
else if m.top.itemsToLoad = "personTVShows"
getPersonVideos("Episode", results)
else if m.top.itemsToLoad = "personSeries"
getPersonVideos("Series", results)
else if m.top.itemsToLoad = "seasons"
data = fetchJson(GetApi().BuildGetSeasonsRequest(m.top.itemId, { Fields: "PrimaryImageAspectRatio,Overview", EnableImages: true }), "seasons")
if isValid(data) and isValid(data.Items)
for each item in data.Items
node = m.transformer.transformBaseItem(item)
if isValid(node) then results.push(node)
end for
end if
else if m.top.itemsToLoad = "seasonEpisodes"
' itemId = seriesId, metadata.seasonId = season to load
if not isValid(m.top.metadata) or not isValidAndNotEmpty(m.top.metadata.seasonId)
m.log.warn("seasonEpisodes requested but metadata.seasonId is missing or empty; skipping GetEpisodes call")
m.top.content = results
return
end if
data = fetchJson(GetApi().BuildGetEpisodesRequest(m.top.itemId, {
SeasonId: m.top.metadata.seasonId,
Fields: "PrimaryImageAspectRatio,Overview",
EnableImages: true
}), "seasonEpisodes")
if isValid(data) and isValid(data.Items)
for each item in data.Items
node = m.transformer.transformBaseItem(item)
if isValid(node) then results.push(node)
end for
end if
else if m.top.itemsToLoad = "seriesFirstEpisode"
data = fetchJson(GetApi().BuildGetEpisodesRequest(m.top.itemId, { Limit: 1, SortBy: "IndexNumber,SortName", SortOrder: "Ascending" }), "seriesFirstEpisode")
if isValid(data) and isValid(data.Items) and data.Items.count() > 0
node = m.transformer.transformBaseItem(data.Items[0])
if isValid(node) then results.push(node)
end if
else if m.top.itemsToLoad = "seriesResume"
' For the series Resume button, prefer an in-progress (resumable) episode and fall back to the next unstarted episode.
' Uses the same endpoint as the Continue Watching home row so the Resume button is consistent with it.
resumeData = fetchJson(GetApi().BuildGetResumeItemsRequest({
ParentId: m.top.itemId,
Filters: "IsResumable",
SortBy: "DatePlayed",
SortOrder: "Descending",
Limit: 1,
recursive: true,
Fields: "PrimaryImageAspectRatio",
EnableTotalRecordCount: false
}), "seriesResume")
if isValid(resumeData) and isValid(resumeData.Items) and resumeData.Items.Count() > 0
node = m.transformer.transformBaseItem(resumeData.Items[0])
if isValid(node) then results.push(node)
else
' No resumable episode — fall back to next unstarted episode.
' DisableFirstEpisode: true so a Resume button never appears for a completely unwatched series.
nextUpData = fetchJson(GetApi().BuildGetNextUpRequest({ SeriesId: m.top.itemId, Limit: 1, DisableFirstEpisode: true, Fields: "PrimaryImageAspectRatio" }), "seriesNextUp")
if isValid(nextUpData) and isValid(nextUpData.Items) and nextUpData.Items.Count() > 0
node = m.transformer.transformBaseItem(nextUpData.Items[0])
if isValid(node) then results.push(node)
end if
end if
else if m.top.itemsToLoad = "metaData"
results.push(ItemMetaData(m.top.itemId))
else if m.top.itemsToLoad = "metaDataDetails"
results.push(ItemDetailsMetaData(m.top.itemId, m.top.shouldForceRefresh))
else if m.top.itemsToLoad = "audioStream"
results.push(AudioStream(m.top.itemId))
else if m.top.itemsToLoad = "backdropImage"
results.push(BackdropImage(m.top.itemId))
else if m.top.itemsToLoad = "boxsetitems"
data = fetchJson(GetApi().BuildGetItemsByQueryRequest({
"ParentId": m.top.itemId,
"SortBy": "PremiereDate,ProductionYear,SortName",
"SortOrder": "Ascending",
"Fields": "PrimaryImageAspectRatio,Overview",
"EnableTotalRecordCount": false
}), "boxsetitems")
if isValid(data) and isValid(data.Items)
for each item in data.Items
node = m.transformer.transformBaseItem(item)
if isValid(node) then results.push(node)
end for
end if
else if m.top.itemsToLoad = "artistAlbums"
' Albums where this artist is the album artist (AlbumArtistIds), sorted by year descending
data = MusicAlbumList(m.top.itemId)
if isValid(data) and isValid(data.Items)
for each item in data.Items
results.push(item)
end for
end if
else if m.top.itemsToLoad = "artistAppearsOn"
' Albums where this artist contributes (ContributingArtistIds) but is not the album artist
data = AppearsOnList(m.top.itemId)
if isValid(data) and isValid(data.Items)
for each item in data.Items
results.push(item)
end for
end if
else if m.top.itemsToLoad = "albumSongs"
' All Audio tracks under a MusicAlbum, sorted by track number
data = MusicSongList(m.top.itemId)
if isValid(data) and isValid(data.Items)
for each item in data.Items
results.push(item)
end for
end if
else if m.top.itemsToLoad = "lyrics"
' Fetch lyrics for an Audio item and join lines into a single string.
' Returns a one-element array containing the lyrics string, or empty on failure.
lyricsData = GetItemLyrics(m.top.itemId)
if isValid(lyricsData) and isValid(lyricsData.Lyrics)
lines = []
for each lyric in lyricsData.Lyrics
if isValid(lyric.Text) and lyric.Text <> ""
lines.push(lyric.Text)
end if
end for
if lines.count() > 0
results.push(lines.join(Chr(10)))
end if
end if
else if m.top.itemsToLoad = "artistSimilar"
' Similar artists via the artist-specific endpoint (more reliable than Items/{id}/Similar for MusicArtist)
params = { "userId": globalUser.id, "limit": 16 }
data = fetchJson(GetApi().BuildGetArtistSimilarRequest(m.top.itemId, params), "artistSimilar")
if isValid(data) and isValid(data.Items)
for each item in data.Items
node = m.transformer.transformBaseItem(item)
if isValid(node) then results.push(node)
end for
end if
else if m.top.itemsToLoad = "artistSongs"
' All Audio tracks by this artist (ArtistIds = any capacity: album artist OR contributing).
' Sorted by album then track number so songs group naturally even without album rows.
' Limit 100 to avoid overwhelming the row for prolific artists.
data = GetSongsByArtistBroad(m.top.itemId)
if isValid(data) and isValid(data.Items)
for each item in data.Items
results.push(item)
end for
end if
else if m.top.itemsToLoad = "playlistItems"
' All items in a Playlist, sorted newest-added first.
data = fetchJson(GetApi().BuildGetPlaylistItemsRequest(m.top.itemId, {
"SortBy": "DateCreated",
"SortOrder": "Descending",
"Fields": "PrimaryImageAspectRatio,Overview",
"EnableTotalRecordCount": false
}), "playlistItems")
if isValid(data) and isValid(data.Items)
for each item in data.Items
node = m.transformer.transformBaseItem(item)
if isValid(node) then results.push(node)
end for
end if
else if m.top.itemsToLoad = "photoAlbumItems"
' All photos in a PhotoAlbum, sorted by name.
data = fetchJson(GetApi().BuildGetItemsByQueryRequest({
"ParentId": m.top.itemId,
"IncludeItemTypes": "Photo",
"SortBy": "SortName",
"SortOrder": "Ascending",
"Fields": "PrimaryImageAspectRatio",
"EnableTotalRecordCount": false
}), "photoAlbumItems")
if isValid(data) and isValid(data.Items)
for each item in data.Items
node = m.transformer.transformBaseItem(item)
if isValid(node) then results.push(node)
end for
end if
else if m.top.itemsToLoad = "channelPrograms"
' Upcoming (not yet aired) programs on a Live TV channel, sorted by start time.
' ChannelInfo required for subtitle display (channel name) in JRRowItem.
data = fetchJson(GetApi().BuildGetLiveTVProgramsRequest({
"ChannelIds": m.top.itemId,
"HasAired": false,
"SortBy": "StartDate",
"SortOrder": "Ascending",
"Limit": 20,
"Fields": "ChannelInfo,PrimaryImageAspectRatio,Overview",
"EnableTotalRecordCount": false
}), "channelPrograms")
if isValid(data) and isValid(data.Items)
for each item in data.Items
node = m.transformer.transformBaseItem(item)
if isValid(node) then results.push(node)
end for
end if
end if
m.top.content = results
end sub
sub getPersonVideos(videoType as string, dest as object)
params = { personIds: m.top.itemId, recursive: true, includeItemTypes: videoType, Limit: 50, SortBy: "Random" }
data = fetchJson(GetApi().BuildGetItemsByQueryRequest(params), "personVideos_" + videoType)
if isValid(data) and data.count() > 0
for each item in data.items
node = m.transformer.transformBaseItem(item)
if isValid(node) then dest.push(node)
end for
end if
end sub