source_home_latestRows.bs

import "pkg:/source/constants/timeouts.bs"
import "pkg:/source/utils/misc.bs"

' Decision layer for Home's latest-media rows: which of the orchestrator's delivered
' results the UI may act on, and where the drain cursor lands.
'
' It lives in source/ rather than in HomeRows.bs because a component codebehind's functions
' are scoped to that component: a Rooibos suite (which runs in the source scope) cannot call
' them without a callFunc seam on the XML. Extracting the judgment instead keeps the seam off
' the component and the rules under test. Same move as source/remotecontrol/remoteCommand.bs.
namespace latestRows

  ' The raw items one latest-media response lets the row show, or invalid when the row must be
  ' treated as "failed".
  '
  ' Only an ok response is authoritative. No answer and a not-ok response return invalid: neither
  ' says what the library holds, and an "ok" with an empty list removes the row
  ' (HomeRows' populateRowFromData). Not-ok includes a 200 whose body holds no list: the request
  ' is a list request (ApiClient.listReq), so the pool reports that body as not-ok and an ok
  ' one always carries an roArray (apiResponse.enforceShape). Books are dropped: the app can't
  ' present them (issue #525).
  '
  ' @param res - a pool response AA { ok, json, ... }, or invalid when the request never answered
  ' @return an roArray of BaseItemDto AAs (possibly empty), or invalid
  function itemsFromResponse(res as dynamic) as dynamic
    if not isValid(res) or not res.ok then return invalid

    kept = []
    for each item in res.json
      if item.Type <> "Book" then kept.push(item)
    end for
    return kept
  end function

  ' Walks LoadLatestRowsTask's result children from `startIndex` and returns the ones the UI
  ' should populate, plus the new cursor. One wake can cover several results (wake events
  ' coalesce, appended children don't), so the caller always drains to the end.
  '
  ' A "failed" child is never returned in `ready`. A timeout, a transport failure or an HTTP
  ' error says nothing about what the library holds, and populateRowFromData removes a row when
  ' handed an empty list, so acting on a failure would delete good content on a flaky refresh.
  ' The cursor still advances past it; the row is loaded again on the next Home.refresh(), which
  ' fires on every onScreenShown, or when the viewer presses OK on its failed tile.
  '
  ' Its libId comes back in `failed` instead, and the caller hands it to homeRowFailure, which
  ' changes a row only while it still shows its loading placeholder: that placeholder becomes
  ' the "couldn't load" tile IN PLACE, and a row that already shows items keeps them. The row is
  ' never removed. A latest row that has not been populated yet is not empty: it carries one
  ' placeholder child so the RowList renders it at the right slot size, and the next successful
  ' load fills it via populateRowFromData's "update children in place" branch: items appended,
  ' placeholder dropped, no insert. Removing it instead would send that load down the
  ' else-branch, which CreateObject+insertChild's a fresh row: the row visibly pops in and
  ' shifts every row below it. Measured on device on 2026-08-03 (8 libraries, HTTP 500 forced on five): the
  ' failed rows sat at their single placeholder child and nothing shifted. Keep it that way.
  '
  ' Also resets `silenceClock` whenever the drain moves past at least one child, a failed
  ' child included, since it is still the run reporting in. runIsStalled reads that clock, and
  ' the reset lives here rather than at the call site because this is the one call every
  ' delivered row goes through: a reset that could be dropped from the observer on its own
  ' would silently turn the backstop back into "time since the run started", and reclaim a
  ' long run that is still delivering.
  '
  ' @param task - the LoadLatestRowsTask node holding { libId, items, status } children
  ' @param startIndex - first unprocessed child index
  ' @param silenceClock - anything with a mark() method; HomeRows passes an roTimespan
  ' @return an AA { ready, failed, nextIndex } - the children to populate, the libIds of the
  '   ones that failed, and the new cursor
  function drainReady(task as object, startIndex as integer, silenceClock as object) as object
    ready = []
    failed = []
    index = startIndex
    childCount = task.getChildCount()

    while index < childCount
      child = task.getChild(index)
      index++
      if not isValid(child) then continue while
      if child.status = "ok"
        ready.push(child)
      else
        failed.push(child.libId)
      end if
    end while

    if index > startIndex then silenceClock.mark()
    return { ready: ready, failed: failed, nextIndex: index }
  end function

  ' May a row's removal be held back until the current latest-rows run ends?
  '
  ' Only for the rows THAT run delivers. A run coalesces one row-size recompute per structural
  ' change into one for the whole run, which is only safe while the row list and the three
  ' geometry arrays stay in step, so the removals have to wait alongside the recompute.
  '
  ' Every other Home section (Continue Watching, Next Up, On Now, Active Recordings) removes
  ' and recomputes immediately, mid-run or not. Two reasons, and the second is the one that matters:
  '
  '   - Those sections are fired by startParallelLoads, which RACES the orchestrator. Whether
  '     one lands inside a run depends on which HTTP response wins, so batching them would make
  '     a user-visible row collapse race-dependent: same server, two loads, two behaviors.
  '   - The queue is keyed by sectionId and holds its entry until the run ends. The orchestrator
  '     yields each library exactly once per run (see drainReady), so a queued latest_ row cannot
  '     be repopulated before the flush. A parallel section can be (onProgramsExpired and
  '     Home.refresh() both re-fire those tasks mid-run), and the flush would then delete a row
  '     that had been filled moments before.
  '
  ' @param sectionId - the row's sectionId
  ' @param batching - whether a latest-rows run is currently holding the recompute
  ' @return true when the caller may queue the removal instead of applying it now
  function removalIsDeferrable(sectionId as string, batching as boolean) as boolean
    return batching and sectionId.startsWith("latest_")
  end function

  ' Has a run been in flight long enough that its orchestrator thread must be gone?
  '
  ' HomeRows skips a refresh while a run is in flight, which is only safe if the in-flight
  ' state can never get stuck: a Task thread that crashes never delivers its last child, so
  ' the completion check never fires and the guard would freeze Home's latest rows for the
  ' life of that Home instance. This is the backstop, and epic #728 is precisely a history of
  ' Task threads dying.
  '
  ' Measured from the last row the run DELIVERED, not from its start. apiPipeline charges its
  ' budget only while waiting on the server, so a live run with many rows can legitimately
  ' outlast PIPELINE_RUN_MS end to end: every row it hands over is its own proof of life. What
  ' a live run cannot do is stay silent much longer than its whole budget, PIPELINE_RUN_MS: a
  ' wait never outlasts what is left of it. API_WAIT_MS on top is slack for the silent time that
  ' is not charged: the task thread starting, building its entries before the first row, and
  ' one row's transform and hand-off to this thread. Past the two combined it is not a slow run,
  ' it is a dead one.
  '
  ' @param msSinceLastRow - milliseconds since the run started or last delivered a row (the
  '   clock drainReady resets)
  ' @return true when the run has been silent longer than any live run can be
  function runIsStalled(msSinceLastRow as integer) as boolean
    return msSinceLastRow > timeouts.PIPELINE_RUN_MS + timeouts.API_WAIT_MS
  end function

  ' The libraries a latest-media run loads, in row order: every library that gets a latest row
  ' (collections, Live TV and programs do not), or, for a retry, only those among them whose
  ' ids are in `onlyLibIds`. A retry loads only the rows that failed, so it costs one request
  ' per failed row rather than a reload of every row.
  '
  ' @param filteredLatest - the libraries left after latestItemsExcludes, each with `id` and
  '   `collectionType`, or invalid
  ' @param onlyLibIds - an roArray of library ids to keep, or invalid for every library
  ' @return an roArray of { id } in row order (possibly empty)
  function runLibraries(filteredLatest as dynamic, onlyLibIds as dynamic) as object
    libs = []
    if not isValid(filteredLatest) then return libs

    only = invalid
    if isValid(onlyLibIds)
      only = {}
      for each libId in onlyLibIds
        only[libId] = true
      end for
    end if

    for each lib in filteredLatest
      if lib.collectionType <> "boxsets" and lib.collectionType <> "livetv" and lib.collectionType <> "Program"
        if not isValid(only) or only.doesExist(lib.id) then libs.push({ id: lib.id })
      end if
    end for
    return libs
  end function

end namespace