components_ItemGrid_LoadItemsTask2.bs

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