import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPipeline.bs"
import "pkg:/source/api/apiPool.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"
import "pkg:/source/utils/translate.bs"
sub init()
m.log = new log.Logger("LoadItemsTask2")
m.top.functionName = "loadItems"
m.transformer = JellyfinDataTransformer()
m.top.limit = 100
usersettingLimit = m.global.user.settings.itemGridLimit
if isValid(usersettingLimit)
m.top.limit = usersettingLimit
end if
end sub
sub loadItems()
globalUser = m.global.user
results = []
' Split the run into time spent WAITING on the pool versus time spent EMITTING
' (transform + ContentNode + appendChild) on this thread. Same decomposition
' LoadLatestRowsTask records — see there and docs/dev/home-first-paint-performance.md
' for WHY it is permanent, and why it rides `perfTiming` rather than `debug`. The
' shared rationale lives in those two places on purpose; a third copy here drifted
' once already.
'
' What IS specific to this task: two very different shapes behind one function. A
' plain grid load is ONE query plus a transform loop. A Genres load is one query plus
' a fetch per genre — and the split is what chose that path's mechanism. Measured at
' 78% wait / 22% emit (the INVERSE of Home's latest rows), so the genre fetches were
' pipelined rather than handed to the orchestration job pool; see the two-pass loop
' below. `genreFetches` is logged alongside the split because a line without it
' cannot be interpreted: the genre count is what makes that path I/O-heavy, and a
' run that never reached a Genres view reports zero.
'
' The gate also matters more here than on Home: the per-item mark/read calls sit in
' the hottest loop the grid has, running up to `m.top.limit` times (default 100).
'
' Timing vars are declared unconditionally so bslint's flow analysis (LINT1003) sees
' every path assign them — it does not correlate assignments across `#if` blocks.
runClock = invalid
stepClock = invalid
waitMs = 0
emitMs = 0
genreFetches = 0
' When the Genres view got its rows on screen. That is NOT `task`: the skeletons ship
' as soon as the genre LIST query returns, while `task` runs on until the last sample
' lands. -1 means the run published no skeletons (a plain grid, or music genres), where
' first paint and `task` are the same moment.
firstPaintMs = -1
#if perfTiming
runClock = CreateObject("roTimespan")
stepClock = CreateObject("roTimespan")
#end if
sortField = m.top.sortField
if m.top.sortAscending = true
sortOrder = "Ascending"
else
sortOrder = "Descending"
end if
if m.top.ItemType = "LogoImage"
' HEAD request to check if logo image exists — res.ok = true means 200
req = GetApi().BuildHeadItemImageRequest(m.top.itemId, "logo", 0)
res = fetchRes(req, "logoHead")
if isValid(res) and res.ok
m.top.content = [GetApi().GetImageURL(m.top.itemId, "logo", 0, { "maxHeight": 212, "maxWidth": 500, "quality": "90" })]
else
m.top.content = []
end if
return
end if
' Build Fields string dynamically - start with Overview, add optional fields
fields = "Overview"
if m.top.additionalFields <> ""
fields = fields + "," + m.top.additionalFields
end if
params = {
limit: m.top.limit,
StartIndex: m.top.startIndex,
SortBy: sortField,
SortOrder: sortOrder,
recursive: m.top.isRecursive,
Fields: fields,
StudioIds: m.top.studioIds,
genreIds: m.top.genreIds
}
' Only include parentid when non-empty — sending parentid="" causes the Jellyfin API
' to return root UserViews instead of filtering by other params (e.g. genreIds)
if m.top.itemId <> ""
params.parentid = m.top.itemId
end if
' Handle special case when getting names starting with numeral
if m.top.NameStartsWith <> ""
if m.top.NameStartsWith = "#"
if m.top.ItemType = "LiveTV" or m.top.ItemType = "TvChannel"
params.searchterm = "A"
params.append({ parentid: " " })
else
params.NameLessThan = "A"
end if
else
if m.top.ItemType = "LiveTV" or m.top.ItemType = "TvChannel"
params.searchterm = m.top.nameStartsWith
params.append({ parentid: " " })
else
params.NameStartsWith = m.top.nameStartsWith
end if
end if
end if
'reset data
if LCase(m.top.searchTerm) = LCase(translate(translationKeys.LabelAll))
params.searchTerm = " "
else if m.top.searchTerm <> ""
params.searchTerm = m.top.searchTerm
end if
filter = LCase(m.top.filter)
if filter = "all"
' do nothing
else if filter = "favorites"
params.append({ Filters: "IsFavorite" })
params.append({ isFavorite: true })
else if filter = "unplayed"
params.append({ Filters: "IsUnplayed" })
else if filter = "played"
params.append({ Filters: "IsPlayed" })
else if filter = "resumable"
params.append({ Filters: "IsResumable" })
end if
if isValid(m.top.filterOptions)
if m.top.filterOptions.count() > 0
params.append(m.top.filterOptions)
end if
end if
if m.top.ItemType <> ""
params.append({ IncludeItemTypes: m.top.ItemType })
end if
' queryType drives which Build*Request() is called — used for both the main request
' and the optional 2nd lookup for the # (special characters) filter below.
queryType = "usersItems"
if m.top.ItemType = "LiveTV"
queryType = "liveTV"
params.append({ UserId: globalUser.id })
else if m.top.view = "Networks"
queryType = "studios"
params.append({ UserId: globalUser.id })
else if m.top.view = "Genres"
queryType = "genres"
params.append({ UserId: globalUser.id, includeItemTypes: m.top.itemType })
else if m.top.ItemType = "MusicArtist"
queryType = "artists"
' Merge Genres with existing fields instead of overriding
if fields.inStr(",Genres") = -1 and fields <> "Genres"
fields = fields + ",Genres"
end if
params.append({
UserId: globalUser.id,
Fields: fields,
IncludeItemTypes: "MusicAlbum,Audio"
})
else if m.top.ItemType = "AlbumArtists"
queryType = "albumArtists"
' Merge Genres with existing fields instead of overriding
if fields.inStr(",Genres") = -1 and fields <> "Genres"
fields = fields + ",Genres"
end if
params.append({
UserId: globalUser.id,
Fields: fields,
IncludeItemTypes: "MusicAlbum,Audio"
})
else if m.top.ItemType = "MusicAlbum"
params.append({ ImageTypeLimit: 1 })
params.append({ EnableImageTypes: "Primary,Backdrop,Banner,Thumb" })
end if
' MusicAlbum and the generic else both use queryType = "usersItems" (default)
#if perfTiming
stepClock.mark()
#end if
data = executeItemQuery(queryType, params)
#if perfTiming
waitMs += stepClock.totalMilliseconds()
#end if
' If user has filtered by #, include special characters sorted after Z as well
if isValid(params.NameLessThan)
if LCase(params.NameLessThan) = "a"
' Use same params except for name filter param
params.NameLessThan = ""
params.NameStartsWithOrGreater = "z"
' Perform 2nd API lookup for items starting with Z or greater
#if perfTiming
stepClock.mark()
#end if
startsWithZAndGreaterData = executeItemQuery(queryType, params)
#if perfTiming
waitMs += stepClock.totalMilliseconds()
#end if
if isValidAndNotEmpty(startsWithZAndGreaterData)
specialCharacterItems = []
' Filter out items starting with Z
for each item in startsWithZAndGreaterData.Items
itemName = LCase(item.name)
if not itemName.StartsWith("z")
specialCharacterItems.Push(item)
end if
end for
' Append data to results from before A
data.Items.Append(specialCharacterItems)
data.TotalRecordCount += specialCharacterItems.Count()
end if
end if
end if
' `slots` holds one entry per source item, INCLUDING the ones that transform to
' invalid, so a genre's sampled items can be written back into its original
' position after the pipeline hands them over in completion order. The server
' sorted these (SortBy/SortOrder) and the grid shows them in that order, so the
' emit order must not become the arrival order. Flattened once at the end.
slots = []
genreEntries = []
if isValid(data)
if isValid(data.TotalRecordCount) then m.top.totalRecordCount = data.TotalRecordCount
' Pass 1 — emit everything that needs no further I/O, and collect the genre
' requests instead of issuing them one at a time.
for each item in data.Items
tmp = invalid
#if perfTiming
stepClock.mark()
#end if
if item.Type = "Genre"
' Genre view: the row container now, its sampled items after pass 2.
tmp = CreateObject("roSGNode", "ContentNode")
tmp.title = item.name
genreReq = GetApi().BuildGetItemsByQueryRequest({
SortBy: "Random",
SortOrder: "Ascending",
IncludeItemTypes: m.top.itemType,
Recursive: true,
Fields: "PrimaryImageAspectRatio,MediaSourceCount,BasicSyncInfo,BackdropImageTags",
ImageTypeLimit: 1,
EnableImageTypes: "Primary,Backdrop",
Limit: 6,
GenreIds: item.id,
EnableTotalRecordCount: false,
ParentId: m.top.itemId
})
if isValid(genreReq)
genreEntries.push({
requestId: "genreItems_" + item.id,
req: genreReq,
slotIndex: slots.count(),
item: item
})
end if
else
' All other item types: transform to JellyfinBaseItem
tmp = m.transformer.transformBaseItem(item)
' Set library context for navigation on virtual items (Genre/Studio/MusicGenre)
' that have no meaningful parentId in the API response
if not isValidAndNotEmpty(tmp.parentId)
tmp.parentId = m.top.itemId
end if
' Studio items (IsFolder=false, type="Studio") don't carry a CollectionType from the API.
' Infer it from the item type being fetched so DeterminePresenterType can route correctly.
if LCase(tmp.type) = "studio" and not isValidAndNotEmpty(tmp.collectionType)
if LCase(m.top.itemType) = "movie"
tmp.collectionType = "movies"
else if LCase(m.top.itemType) = "series"
tmp.collectionType = "tvshows"
end if
end if
end if
slots.push(tmp)
#if perfTiming
emitMs += stepClock.totalMilliseconds()
#end if
end for
end if
' Ship the row TITLES now, so the view can draw the list before a single sample has
' landed. The whole genre list is already known at this point — only the per-genre
' artwork is still outstanding — so making the user stare at a spinner until the
' slowest of N fetches returns is wasted structure we already have.
'
' What crosses is deliberately tiny: an array of `{ id, title }` AAs, no nodes. Every
' write from this thread to a render-thread-owned node is a RENDEZVOUS, priced by how
' much data it carries, so the view builds its own skeleton row nodes from these
' strings — exactly as HomeRows.createSkeletonRows() does for Home. An earlier revision
' shipped the built ContentNodes instead and paid ~136 ms for that one crossing.
'
' Only when EVERY item is a genre: this is a pure hint, and a mixed list would draw
' skeletons for rows the batch below never fills. A Genres query returns nothing but
' genres, so the equality just makes that a checked precondition. startIndex = 0
' encodes the other precondition the receiving handler assumes — "this publish is the
' COMPLETE list" — in the gate itself, rather than leaving it to observer lifecycle in
' another file (genre views never paginate today, so this is a tripwire, not a path).
if genreEntries.count() > 0 and genreEntries.count() = slots.count() and m.top.startIndex = 0
skeletons = []
for each entry in genreEntries
skeletons.push({ id: entry.item.id, title: entry.item.name })
end for
m.top.genreSkeletons = skeletons
#if perfTiming
firstPaintMs = runClock.totalMilliseconds()
#end if
' RTA-only: hold the skeleton stage open so specs can drive it deterministically.
' Compiled out of dev and prod builds (see setGlobalNodes); sleeps THIS task thread
' only, exactly like a slow server would.
#if ENABLE_RTA
if isValid(m.global.rtaSkeletonHoldMs) and m.global.rtaSkeletonHoldMs > 0
sleep(m.global.rtaSkeletonHoldMs)
end if
#end if
end if
' Pass 2 — the genre sample fetches, pipelined onto THIS thread rather than run
' back to back. Measured 78% wait / 22% emit, so the win is overlapping the round
' trips: apiPipeline keeps up to apiPool.SLOT_COUNT of them in flight without
' adding a thread, which a task-per-genre fan-out would (epic #728).
'
' Safe to use submitApiRequest's non-blocking path here for the reason
' apiPool.bs's ORDERING DEPENDENCY note requires: executeItemQuery above is a
' blocking fetchJson, so the pool is provably up by the time this runs.
if genreEntries.count() > 0
#if perfTiming
genreFetches = genreEntries.count()
#end if
pipe = apiPipelineBegin(genreEntries)
#if perfTiming
stepClock.mark()
#end if
result = apiPipelineNext(pipe)
#if perfTiming
waitMs += stepClock.totalMilliseconds()
#end if
while isValid(result)
#if perfTiming
stepClock.mark()
#end if
populateGenreRow(slots[result.entry.slotIndex], result.entry.item, result.res)
#if perfTiming
emitMs += stepClock.totalMilliseconds()
stepClock.mark()
#end if
result = apiPipelineNext(pipe)
#if perfTiming
waitMs += stepClock.totalMilliseconds()
#end if
end while
end if
' The handoff is emit work too — a grid can carry hundreds of nodes across it.
#if perfTiming
stepClock.mark()
#end if
' Pass 3 — flatten back to source order. `slots` held a place for every source item,
' which is what kept the genre write-back indices valid; this drops the placeholders
' again. The isValid guard is defensive only: both branches of pass 1 assign `tmp`
' and immediately dereference it, so a slot that reached here invalid would already
' have faulted upstream. Kept because it costs nothing and `results` must never
' carry an invalid entry across the handoff.
for each slot in slots
if isValid(slot) then results.push(slot)
end for
m.top.content = results
' NOTE: roku-log's Logger.info takes at most 10 params (message + value..value9),
' and the roku-log BSC plugin spends the first on the injected pkg path — so at
' most NINE args may be passed here. Exceeding it is not a compile error: it
' faults at runtime with "Wrong number of function parameters" (&hf1), which
' drops the app into the BrightScript debugger and hangs it mid-load.
'
' The line carries its own build flags so a sample can never be silently compared
' against one taken in a distorting build — a `debug=true` build attaches
' `rawApiData` to every item and inflates `emit`. Provenance belongs in the sample,
' not in someone's memory of which manifest was checked out at the time.
#if perfTiming
emitMs += stepClock.totalMilliseconds()
#if debug
buildFlags = " [debug=true perfTiming=true]"
#else
buildFlags = " [debug=false perfTiming=true]"
#end if
' firstPaint rides in the MESSAGE rather than as a fourth value pair: message + three
' pairs is seven args, and two more would sit exactly on the nine-arg ceiling above.
m.log.info("item-grid load done - items " + results.count().toStr() + " genreFetches " + genreFetches.toStr() + " firstPaint " + firstPaintMs.toStr() + buildFlags, "task", runClock.totalMilliseconds(), "wait", waitMs, "emit", emitMs)
#end if
end sub
' Fills one genre row with the sampled items its request returned.
'
' `res` is the pool response AA, or invalid when the request never got an answer —
' the pool was down, or the whole-run budget expired. An HTTP error arrives as a
' valid `res` with `ok = false`. Both leave the row in place with its title and no
' children, which is exactly what the blocking `fetchJson` this replaced did when it
' returned invalid: a genre that can't be sampled still belongs in the list, and
' removing it would renumber the grid around a transient failure.
sub populateGenreRow(row as dynamic, item as object, res as dynamic)
if not isValid(row) then return
genreData = invalid
if isValid(res) and res.ok then genreData = res.json
if not isValid(genreData) then return
if genreData.Items.Count() > 5
' Add View All item to the start of the row — transform the genre item itself
viewAllNode = m.transformer.transformBaseItem(item)
viewAllNode.title = translate(translationKeys.LabelViewAll) + " " + item.name
' Set library context for genre navigation (genre items have no parentId from API)
if not isValidAndNotEmpty(viewAllNode.parentId)
viewAllNode.parentId = m.top.itemId
end if
' Infer collection type from sampled items so DeterminePresenterType can route
' music genres to MusicPresenter instead of GenericPresenter
firstItemType = LCase(genreData.Items[0].Type ?? "")
if firstItemType = "audio" or firstItemType = "musicalbum" or firstItemType = "musicartist"
viewAllNode.collectionType = "music"
else if firstItemType = "movie"
viewAllNode.collectionType = "movies"
else if firstItemType = "series" or firstItemType = "episode"
viewAllNode.collectionType = "tvshows"
end if
row.appendChild(viewAllNode)
end if
for each genreItem in genreData.Items
row.appendChild(m.transformer.transformBaseItem(genreItem))
end for
end sub
' Routes an item query through Build*Request() + pool based on queryType.
' Used for both the main request and the optional 2nd lookup for the # filter.
' queryType values: "liveTV", "studios", "genres", "artists", "albumArtists", "usersItems"
function executeItemQuery(queryType as string, params as object) as dynamic
if queryType = "liveTV"
return fetchJson(GetApi().BuildGetLiveTVChannelsRequest(params), "itemQuery_liveTV")
else if queryType = "studios"
return fetchJson(GetApi().BuildGetStudiosRequest(params), "itemQuery_studios")
else if queryType = "genres"
return fetchJson(GetApi().BuildGetGenresRequest(params), "itemQuery_genres")
else if queryType = "artists"
return fetchJson(GetApi().BuildGetArtistsRequest(params), "itemQuery_artists")
else if queryType = "albumArtists"
return fetchJson(GetApi().BuildGetAlbumArtistsRequest(params), "itemQuery_albumArtists")
end if
' default: "usersItems"
return fetchJson(GetApi().BuildGetItemsByQueryRequest(params), "itemQuery_usersItems")
end function