components_search_SearchTask.bs

import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/apiRead.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/searchOutcome.bs"

sub init()
  m.log = new log.Logger("SearchTask")
  m.top.functionName = "search"
end sub

sub search()
  if isValid(m.top.query) and m.top.query <> ""
    m.top.results = searchMedia(m.top.query)
  end if
end sub

' Searches multiple Jellyfin endpoints for media matching the query.
' Runs on SearchTask thread: each read blocks waiting for its ApiPool response.
' Every result echoes the query it ANSWERS. The caller gives each keystroke a new node and
' drops a delivery from any node it has since replaced (`SearchResults.loadResults`); the
' echo is what that drop logs, so a superseded delivery names the query it answered. Taken
' from the local the search actually ran with, so the echo and the items cannot disagree.
'
' ⚠️ `m.top.results` is assigned ONCE, by `search()` with this function's return value, as the
' Task's last act, and that is essential, not incidental. It is what lets the caller's `control = "stop"` prevent a
' superseded query from delivering at all (measured 2026-08-14: 12 keystrokes, 6 deliveries, every one
' current). Publishing partial results as each fetch lands would widen the window of
' superseded deliveries that `SearchResults.loadResults` has to drop.
'
' Every result also carries a `status` (searchOutcome.statusOf): a read that did not answer
' says nothing about what the server holds, so the screen never shows a search whose reads
' failed as one that found nothing.
function searchMedia(query as string)
  if query = "" then return { Items: [], TotalRecordCount: 0, query: query, status: searchOutcome.STATUS_OK }

  transformer = JellyfinDataTransformer()
  allItems = []
  ' One per search, so one search's failed reads never count against the next.
  reader = new apiRead.Reader(m.log, "search")

  ' Regular library items. Person and MusicArtist are fetched via dedicated endpoints below.
  ' LiveTvProgram is read separately below, with its own limit, so guide programs cannot
  ' crowd the library out of this read.
  data = reader.json(GetApi().BuildGetItemsByQueryRequest({
    "searchTerm": query,
    "IncludeItemTypes": "Movie,Series,Episode,Video,MusicVideo,Audio,MusicAlbum,Playlist,LiveTvChannel,PhotoAlbum,Photo,BoxSet",
    ' 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.
    "Fields": "ChildCount",
    "EnableTotalRecordCount": false,
    "Recursive": true,
    "limit": 100
  }), "searchItems")
  if isValid(data) and isValid(data.Items)
    for each item in data.Items
      ' 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
      allItems.push(transformer.transformBaseItem(item))
    end for
  end if

  ' People: /Items does not return Person type; /Persons endpoint required.
  personData = reader.json(GetApi().BuildGetPersonsRequest({
    "searchTerm": query,
    "Limit": 20,
    "EnableTotalRecordCount": false
  }), "searchPersons")
  if isValid(personData) and isValid(personData.Items)
    for each item in personData.Items
      allItems.push(transformer.transformBaseItem(item))
    end for
  end if

  ' Artists: /Items does not return MusicArtist type; /Artists endpoint required.
  artistData = reader.json(GetApi().BuildGetArtistsRequest({
    "searchTerm": query,
    "Limit": 20,
    "EnableTotalRecordCount": false
  }), "searchArtists")
  if isValid(artistData) and isValid(artistData.Items)
    for each item in artistData.Items
      allItems.push(transformer.transformBaseItem(item))
    end for
  end if

  ' Live TV guide programs, through /Items with LiveTvProgram, as Jellyfin's web client searches
  ' them. /LiveTv/Programs takes no search term (none in the API from 10.7 to 10.11, and 12.1
  ' ignores one), so it answered every search with the first guide programs, whatever was typed.
  ' Not asked for a user without Live TV access (searchOutcome.canSearchLiveTv). The policy is
  ' read once, as this runs on a Task thread, where each m.global read crosses to the render
  ' thread.
  if searchOutcome.canSearchLiveTv(m.global.user.policy)
    programData = reader.json(GetApi().BuildGetItemsByQueryRequest({
      "searchTerm": query,
      "IncludeItemTypes": "LiveTvProgram",
      "Recursive": true,
      "EnableTotalRecordCount": false,
      "limit": 20
    }), "searchPrograms")
    if isValid(programData) and isValid(programData.Items)
      for each item in programData.Items
        allItems.push(transformer.transformBaseItem(item))
      end for
    end if
  end if

  result = {}
  result.Items = allItems
  result.TotalRecordCount = allItems.count()
  result.query = query
  result.status = searchOutcome.statusOf(reader.sentCount, reader.failedCount)
  return result
end function