import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPipeline.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/apiResponse.bs"
import "pkg:/source/api/items.bs"
import "pkg:/source/constants/timeouts.bs"
import "pkg:/source/data/JellyfinDataTransformer.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/versionDisplay.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 = []
' Set by the Home row and Favorites loads only; see the `status` field.
status = ""
' Load Libraries
if m.top.itemsToLoad = "libraries"
items = fetchRowItems(GetApi().BuildGetViewsRequest(), "libraries")
status = statusOf(items)
if isValid(items)
for each item in 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 Next Up
else if m.top.itemsToLoad = "nextUp"
userSettings = globalUser.settings
params = buildHomeNextUpParams(m.global.server.version, userSettings.uiDetailsEnableRewatchingNextUp, userSettings.uiDetailsMaxDaysNextUp, CreateObject("roDateTime").AsSeconds())
' Only a server not asked to drop unstarted series needs the client to (and to pay for
' its lookups). The played-specials lookup does not need Next Up's answer, so it is sent
' first and its wait overlaps Next Up's. Not sent while the pool is still coming up, and
' the filter then goes without it (keepStartedSeries).
needsStartedFilter = nextUpNeedsStartedFilter(params)
specialsPort = CreateObject("roMessagePort")
specialsReq = invalid
if needsStartedFilter then specialsReq = submitApiRequest(GetApi().BuildGetItemsByQueryRequest(playedSpecialsQuery()), "nextUpPlayedSpecials", specialsPort)
items = fetchRowItems(GetApi().BuildGetNextUpRequest(params), "nextUp")
status = statusOf(items)
if isValid(items)
if needsStartedFilter then items = keepStartedSeries(items, specialsReq, specialsPort)
for each item in items
results.push(m.transformer.transformBaseItem(item))
end for
else
dropApiRequest(specialsReq)
end if
' Load Continue Watching
else if m.top.itemsToLoad = "continue"
activeUser = globalUser.id
' No user means no request was made, which is not an answer.
status = "failed"
if isValid(activeUser)
' No `limit`, on purpose, and now stated rather than merely absent: the silence here
' was previously indistinguishable from nobody having got round to it. Continue Watching
' is a WORKLIST: capping it can only hide something the user is mid-way through. Next Up
' is uncapped for the same reason; `uiHomeRowLimit` sizes the browse feeds only.
params = {}
params["EnableTotalRecordCount"] = false
' Alternate-versions badge: see buildHomeNextUpParams for the measured cost.
params["Fields"] = "MediaSourceCount"
items = fetchRowItems(GetApi().BuildGetResumeItemsRequest(params), "continue")
status = statusOf(items)
if isValid(items)
for each item in 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
' ChildCount gives a collection or photo album tile its "N items" line, as in the details
' Collections row; a list query returns it for those only when asked.
params["Fields"] = "MediaSourceCount,ChildCount"
items = fetchRowItems(GetApi().BuildGetItemsByQueryRequest(params), "favorites")
status = statusOf(items)
if isValid(items)
' A favorited movie is the same tile the library grid draws, so it gets the same
' correction: from 12.0 its own UserData can belong to a version other than the one Play
' would start. Unscoped, since favorites span every library. Continue Watching and the series
' resume row below need none of this: they are built FROM the resume list, so each row
' already IS the version in progress.
changed = versionDisplay.correctDisplayProgress(items, m.global.server.version, "", "favoritesVersionDisplay", {})
if changed > 0 then m.log.debug("Corrected version progress on favorites", "count", changed)
for each item in 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
' 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).
' People: favorited persons are not returned by /Items; requires /Persons endpoint. Its own
' load, so a failure of one request says nothing about the other's rows.
else if m.top.itemsToLoad = "favoritePeople"
items = fetchRowItems(GetApi().BuildGetPersonsRequest({
"IsFavorite": true,
"SortBy": "SortName",
"SortOrder": "Ascending",
"EnableTotalRecordCount": false
}), "favoritePeople")
status = statusOf(items)
if isValid(items)
for each item in items
results.push(m.transformer.transformBaseItem(item))
end for
end if
else if m.top.itemsToLoad = "onNow"
params = {}
params["userId"] = globalUser.id
params["isAiring"] = true
' The comment this replaces read "16 to be consistent with Latest In": a parity three
' files asserted by hand and nothing enforced. Reading the same setting makes it hold.
params["limit"] = resolveHomeRowLimit(globalUser.settings.uiHomeRowLimit)
params["imageTypeLimit"] = 1
params["enableImageTypes"] = "Primary,Thumb,Backdrop"
params["enableTotalRecordCount"] = false
params["fields"] = "ChannelInfo,PrimaryImageAspectRatio"
items = fetchRowItems(GetApi().BuildGetLiveTvRecommendedProgramsRequest(params), "onNow")
status = statusOf(items)
if isValid(items)
for each item in 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"] = resolveHomeRowLimit(globalUser.settings.uiHomeRowLimit)
params["imageTypeLimit"] = 1
params["enableImageTypes"] = "Primary,Thumb,Backdrop"
params["enableTotalRecordCount"] = false
params["fields"] = "ChannelInfo,PrimaryImageAspectRatio"
items = fetchRowItems(GetApi().BuildGetLiveTvRecordingsRequest(params), "activeRecordings")
status = statusOf(items)
if isValid(items)
for each item in items
results.push(m.transformer.transformBaseItem(item))
end for
end if
' ItemDetails' extras rows are not loaded here: LoadExtrasRowsTask owns all of them.
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,
Limit: 1,
Fields: "PrimaryImageAspectRatio,MediaSourceCount",
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, but only once a regular
' episode has been watched: a Resume button must never appear for an unwatched series.
' Checked here because Next Up's DisableFirstEpisode is ignored from Jellyfin 10.11 (#925).
playedData = fetchJson(GetApi().BuildGetItemsByQueryRequest(watchedRegularEpisodeQuery(m.top.itemId)), "seriesPlayed")
if hasWatchedRegularEpisode(playedData)
nextUpData = fetchJson(GetApi().BuildGetNextUpRequest({ SeriesId: m.top.itemId, Limit: 1, 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
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 = "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
end if
' `status` before `content`: the caller reads it in its `content` handler.
if status <> "" then m.top.status = status
' Keep this the LAST statement: a caller may release (STOP) the node in its `content`
' handler, before this function returns (see releaseTask), and work added below would
' then silently not run.
m.top.content = results
end sub
' The items a Home row's request answered with, or invalid when it did not answer. Logs why
' at warn, once: the row's failure tile says only that it failed, so the log is where the
' cause lives (timeout, HTTP status, unreadable reply).
'
' Every Home row endpoint answers with a query result (`{ Items: [...] }`), read through
' apiResponse.listFrom, so a 200 whose body holds no list is a failure too, not an empty row.
'
' @param req - the request AA, or invalid when it could not be built
' @param requestId - the pool request id, also the log's label
' @return an roArray of BaseItemDto AAs (possibly empty), or invalid
function fetchRowItems(req as dynamic, requestId as string) as dynamic
if not isValid(req)
m.log.warn("home row load failed", requestId, "cause", "not sent")
return invalid
end if
res = fetchRes(req, requestId)
cause = apiResponse.jsonFailure(res)
items = invalid
if cause = ""
items = apiResponse.listFrom(res.json)
if not isValid(items) then cause = "unexpected reply shape"
end if
if cause <> ""
m.log.warn("home row load failed", requestId, "cause", cause)
return invalid
end if
return items
end function
' Drops the Next Up items whose series has no watched regular episode (classifyNextUpSeries).
'
' The played-specials lookup was sent alongside Next Up (specialsReq). Its answer splits the
' series (planNextUpSeriesLookups), so the count lookups and the exact per-series checks go out
' together and only one round of requests waits after Next Up's. The Next Up fetch before this
' blocked on the pool, so the pool is up for apiPipeline.
'
' The whole filter gets one request's wait (timeouts.API_WAIT_MS), shared by collecting the
' specials and the round, so a slow server holds the row back by that much at most. A lookup
' with no answer, including the specials when they could not be sent early, keeps the items it
' would have judged.
function keepStartedSeries(items as object, specialsReq as dynamic, specialsPort as object) as object
seriesIds = nextUpSeriesIds(items)
if seriesIds.count() = 0
dropApiRequest(specialsReq)
return items
end if
clock = CreateObject("roTimespan")
found = newNextUpLookups()
playedSpecials = invalid
res = collectApiRequest(specialsReq, specialsPort, timeouts.API_WAIT_MS)
if isValid(res) and res.ok then playedSpecials = apiResponse.listFrom(res.json)
if not isValid(playedSpecials) then found.unanswered++
api = GetApi()
entries = nextUpLookupQueries(planNextUpSeriesLookups(seriesIds, playedSpecials))
for each entry in entries
entry.req = api.BuildGetItemsByQueryRequest(entry.query)
end for
pipe = apiPipelineBegin(entries, timeouts.API_WAIT_MS - clock.TotalMilliseconds())
result = apiPipelineNext(pipe)
while isValid(result)
foldNextUpLookup(found, result.entry, result.res)
result = apiPipelineNext(pipe)
end while
starts = classifyNextUpSeries(found.seriesItems)
starts.append(found.starts)
kept = keepStartedSeriesItems(items, starts)
specialsCount = -1
if isValid(playedSpecials) then specialsCount = playedSpecials.count()
if found.unanswered > 0 then m.log.warn("Next Up kept series it could not check", "unanswered lookups", found.unanswered)
m.log.debug("Next Up started-series filter", "dropped", items.count() - kept.count(), "series", seriesIds.count(), "played specials", specialsCount, "ms", clock.TotalMilliseconds())
return kept
end function
' `status` for a Home row load from what fetchRowItems returned.
function statusOf(items as dynamic) as string
if isValid(items) then return "ok"
return "failed"
end function