components_search_SearchTask.bs

import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/data/JellyfinDataTransformer.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/misc.bs"

sub init()
  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 — fetchJson blocks waiting for ApiPool responses.
' Every result echoes the query it ANSWERS, so the caller can check that it got the one it
' asked for. This node is reused across keystrokes — each stops the run in flight and
' relaunches it — so `m.top.query` already holds the NEXT query by the time a delivery is
' handled, and without the echo a late one is indistinguishable from the live one. Taken
' from the local the search actually ran with, so the echo and the items cannot disagree.
'
' ⚠️ `m.top.results` is assigned ONCE, below, as this function's last act — and that is
' load-bearing, not incidental. It is what lets the caller's `control = "stop"` prevent a
' superseded query from delivering at all (measured: 12 keystrokes, 6 deliveries, every one
' current). Publishing partial results as each fetch lands would open exactly the race
' `SearchResults.loadResults` watches for.
function searchMedia(query as string)
  if query = "" then return { Items: [], TotalRecordCount: 0, query: query }

  transformer = JellyfinDataTransformer()
  allItems = []

  ' Regular library items — Person and MusicArtist are fetched via dedicated endpoints below.
  ' LiveTvProgram is excluded here because /Items EPG search is incomplete; /LiveTv/Programs is used instead.
  data = fetchJson(GetApi().BuildGetItemsByQueryRequest({
    "searchTerm": query,
    "IncludeItemTypes": "Movie,Series,Episode,Video,MusicVideo,Audio,MusicAlbum,Playlist,LiveTvChannel,PhotoAlbum,Photo,BoxSet",
    "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 = fetchJson(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 = fetchJson(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 Programs — /LiveTv/Programs searches the full EPG; /Items only covers recordings.
  programData = fetchJson(GetApi().BuildGetLiveTVProgramsRequest({
    "SearchTerm": query,
    "Limit": 20,
    "EnableTotalRecordCount": false
  }), "searchPrograms")
  if isValid(programData) and isValid(programData.Items)
    for each item in programData.Items
      allItems.push(transformer.transformBaseItem(item))
    end for
  end if

  result = {}
  result.Items = allItems
  result.TotalRecordCount = allItems.count()
  result.query = query
  return result
end function