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/data/JellyfinDataTransformer.bs"
import "pkg:/source/GridView/gridPage.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"
import "pkg:/source/utils/versionDisplay.bs"
sub init()
m.log = new log.Logger("LoadItemsTask2")
m.top.functionName = "loadItems"
m.transformer = JellyfinDataTransformer()
' The page size is the `limit` field's default (LoadItemsTask2.xml).
' RTA-only: a smaller page, so specs can page a grid on the demo server's small libraries.
' Compiled out of dev and prod builds (see setGlobalNodes).
#if ENABLE_RTA
if isValid(m.global.rtaGridPageSize) and m.global.rtaGridPageSize > 0
m.top.limit = m.global.rtaGridPageSize
end if
#end if
end sub
sub loadItems()
globalUser = m.global.user
results = []
' Read once: m.global is render-owned, so each read from this thread is a rendezvous. Both
' version-display call sites need the server version, and the cache is per RUN: a new node
' services every page (replaceTask), so it cannot leak a stale resume list into the next one.
m.serverVersion = m.global.server.version
m.versionDisplayCache = {}
' 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 2026-08-06 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
handoffClock = invalid
waitMs = 0
emitMs = 0
' The part of `emit` spent writing `status` + `content`, the one crossing that carries the
' page's nodes to the view. Split out because `emit` alone cannot say whether a page's cost is
' building the nodes or handing them over, and those have different fixes.
handoffMs = 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.
' MediaSourceCount is what tells a tile it has alternate versions. It is the only free
' signal for that: measured 2026-09-19 on a local 12.0 server, adding it to a 100-item page
' cost +85 bytes and no measurable time, where asking for MediaSources cost +87 ms and
' +326 KB. `gridPage.itemFields` says which types carry it.
fields = gridPage.itemFields(m.top.ItemType, m.top.additionalFields)
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 (genreIds, for example)
if m.top.itemId <> ""
params.parentid = m.top.itemId
end if
' Handle special case when getting names starting with numeral
' "#" outside Live TV is two ranges of names, asked for by executeHashPage.
isHash = false
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
isHash = true
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, for the page's request, or both of a
' "#" page's (executeHashPage).
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)
' The total is counted on the first page only (the view keeps it): on a slow server the
' count is most of a page's cost. Live TV's channel list takes no such parameter.
if m.top.startIndex > 0 and queryType <> "liveTV" then params.EnableTotalRecordCount = false
#if perfTiming
stepClock.mark()
#end if
if isHash
data = executeHashPage(queryType, params)
else
data = executeItemQuery(queryType, params)
end if
#if perfTiming
waitMs += stepClock.totalMilliseconds()
#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
' From 12.0 a tile's own UserData can belong to a version other than the one Play would
' start. Correct it here, on the raw reply, so the transformer / item node / JRPoster chain
' needs no knowledge of versions at all. Inside the isValid(data) guard because a failed
' query leaves `data` invalid, and a dot off invalid faults this task thread (&hec).
' A Genres query returns genres, which are never grouped, so this is a no-op for one:
' its sampled items are corrected in one pass after pass 2 instead.
correctVersionDisplay(data.Items)
' Pass 1: emit everything that needs no further I/O, and collect the genre
' requests instead of issuing them one at a time.
'
' Nothing in the per-item transform may read m.top or m.global: both are render-owned, so
' from this thread each read is a rendezvous, and while the user scrolls every one waits on
' the busy render thread. Read once here; the transformer gets the server version for the
' same reason (without it, it reads m.global.server.version itself, two crossings per item).
' The genre branch still crosses per genre inside BuildGetItemsByQueryRequest (user id, API
' version, server URL); genre views do not page, so that cost is never paid mid-scroll.
libraryId = m.top.itemId
requestedType = m.top.itemType
genreFields = gridPage.genreSampleFields(m.top.isMusic)
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: requestedType,
Recursive: true,
Fields: genreFields,
ImageTypeLimit: 1,
EnableImageTypes: "Primary,Backdrop",
Limit: 6,
GenreIds: item.id,
EnableTotalRecordCount: false,
ParentId: libraryId
})
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, m.serverVersion)
' 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 = libraryId
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(requestedType) = "movie"
tmp.collectionType = "movies"
else if LCase(requestedType) = "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 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 (measured 2026-08-08).
'
' 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 only 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 2026-08-06: 78% wait / 22% emit, so the win is overlapping the round
' trips: apiPipeline keeps one per pool slot 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)
pending = []
#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
' Held, not transformed yet: the version correction below rewrites the RAW items, and
' doing it per row would either block this drain loop on a fetch (starving the pipeline
' of the top-ups apiPipelineNext performs) or need a second correction shape. One pass
' after the loop costs the same round trip once and leaves the pipeline alone. Nothing is
' visible in the meantime: genreSkeletons already painted, and the rows themselves do not
' cross until `m.top.content` below.
pending.push({ slotIndex: result.entry.slotIndex, item: result.entry.item, res: result.res })
#if perfTiming
emitMs += stepClock.totalMilliseconds()
stepClock.mark()
#end if
result = apiPipelineNext(pipe)
#if perfTiming
waitMs += stepClock.totalMilliseconds()
#end if
end while
' Every sampled item across every genre row, corrected in one pass, so a grid of 8 genres
' costs the same lookup as a grid of 1. Counted as wait, not emit: it is a round trip.
#if perfTiming
stepClock.mark()
#end if
sampled = []
for each entry in pending
if isValid(entry.res) and entry.res.ok and isValid(entry.res.json) and type(entry.res.json.Items) = "roArray"
sampled.append(entry.res.json.Items)
end if
end for
correctVersionDisplay(sampled)
#if perfTiming
waitMs += stepClock.totalMilliseconds()
stepClock.mark()
#end if
for each entry in pending
populateGenreRow(slots[entry.slotIndex], entry.item, entry.res)
end for
#if perfTiming
emitMs += stepClock.totalMilliseconds()
#end if
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
#if perfTiming
handoffClock = CreateObject("roTimespan")
#end if
' `status` before `content`: the view reads it in its `content` handler.
if isValid(data)
m.top.status = "ok"
else
m.top.status = "failed"
end if
m.top.content = results
#if perfTiming
handoffMs = handoffClock.totalMilliseconds()
#end if
' 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 and handoff ride in the MESSAGE rather than as value pairs: message + three
' pairs is seven args, and two more would sit exactly on the nine-arg ceiling above.
' handoff is a PART of emit, not added to it.
m.log.info("item-grid load done - items " + results.count().toStr() + " genreFetches " + genreFetches.toStr() + " firstPaint " + firstPaintMs.toStr() + " handoff " + handoffMs.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, m.serverVersion)
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, m.serverVersion))
end for
end sub
' Rewrite this page's items so each tile's progress bar shows the version that would actually
' play. The rules and the measurements are versionResume's DISPLAY section; the requests are
' versionDisplay.correctDisplayProgress().
'
' The cache spans the whole run, so the genre pass below reuses whatever the main query already
' looked up rather than asking again. The resume list is scoped to this grid's container: a
' library, or (for a genre or studio view) the library the presenter set as `itemId`. That
' resolves through a collection too (verified 2026-09-20 against a 12.0 server: a resume query
' scoped to a BoxSet returns its members).
sub correctVersionDisplay(items as dynamic)
if not isValid(m.versionDisplayCache) then m.versionDisplayCache = {}
changed = versionDisplay.correctDisplayProgress(items, m.serverVersion, m.top.itemId, "versionDisplayProgress", m.versionDisplayCache)
if changed > 0 then m.log.debug("Corrected version progress on tiles", "count", changed)
end sub
' One page of the "#" letter filter: one or two requests across its two name ranges
' (gridPage.hashRanges), the second only when the page reaches past the first range's end, and
' always on the first page, whose total counts both. The first page reports the first range's
' count in `hashFirstRangeTotal`, and the view hands it back to every later page, which is how a
' page finds its place in the two ranges.
'
' Invalid when either request fails: a "#" page missing one range would silently leave its items
' out of the grid.
function executeHashPage(queryType as string, params as object) as dynamic
limit = m.top.limit
first = gridPage.hashFirstRequest(m.top.startIndex, limit, m.top.hashFirstRangeTotal)
data = executeItemQuery(queryType, gridPage.hashRequestParams(params, first))
if not isValid(data) then return invalid
' Before `content`, like `totalRecordCount`: the view reads both in its `content` handler.
if first.counts and isValid(data.TotalRecordCount) then m.top.hashFirstRangeTotal = data.TotalRecordCount
delivered = 0
if type(data.Items) = "roArray" then delivered = data.Items.count()
second = gridPage.hashSecondRequest(first, delivered, limit)
if not isValid(second) then return data
secondData = executeItemQuery(queryType, gridPage.hashRequestParams(params, second))
if not isValid(secondData) then return invalid
gridPage.appendSecondRange(data, secondData, second.counts)
return data
end function
' Routes an item query through Build*Request() + pool based on queryType.
' Used for a page's request, and for each of a "#" page's (executeHashPage).
' queryType values: "liveTV", "studios", "genres", "artists", "albumArtists", "usersItems"
'
' Returns the parsed reply, or invalid when the server did not answer usably, after logging
' why, since the view is told only that the load failed.
function executeItemQuery(queryType as string, params as object) as dynamic
if queryType = "liveTV"
req = GetApi().BuildGetLiveTVChannelsRequest(params)
else if queryType = "studios"
req = GetApi().BuildGetStudiosRequest(params)
else if queryType = "genres"
req = GetApi().BuildGetGenresRequest(params)
else if queryType = "artists"
req = GetApi().BuildGetArtistsRequest(params)
else if queryType = "albumArtists"
req = GetApi().BuildGetAlbumArtistsRequest(params)
else
queryType = "usersItems"
req = GetApi().BuildGetItemsByQueryRequest(params)
end if
requestId = "itemQuery_" + queryType
if not isValid(req)
m.log.warn("library query failed", requestId, "cause", "not sent")
return invalid
end if
' A page can take the server far longer than an ordinary request (timeouts.GRID_PAGE_MS).
req.timeoutMs = timeouts.GRID_PAGE_MS
res = fetchRes(req, requestId)
cause = apiResponse.jsonFailure(res)
if cause <> ""
m.log.warn("library query failed", requestId, "cause", cause)
return invalid
end if
return res.json
end function