components_home_LoadItemsTask.bs

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