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