components_liveTv_LoadChannelsTask.bs
import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/api/image.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/translate.bs"
sub init()
m.log = new log.Logger("LoadChannelsTask")
m.top.functionName = "loadChannels"
end sub
' Orchestrator Task: submits to ApiTask pool and waits off the render thread.
sub loadChannels()
if m.top.sortAscending = true
sortOrder = "Ascending"
else
sortOrder = "Descending"
end if
searchTerm = m.top.searchTerm.trim()
hasSearch = searchTerm <> ""
hasAlphaFilter = m.top.NameStartsWith <> ""
if hasAlphaFilter
' /LiveTv/Channels doesn't support NameStartsWith — fall back to /Items for alpha filtering
params = {
includeItemTypes: "LiveTvChannel",
SortBy: m.top.sortField,
SortOrder: sortOrder,
recursive: m.top.isRecursive,
UserId: m.global.user.id,
EnableImageTypes: "Primary",
ImageTypeLimit: 1,
startIndex: m.top.startIndex
}
if m.top.limit > 0 then params.limit = m.top.limit
' Handle special case when getting names starting with numeral
if m.top.NameStartsWith = "#"
params.searchterm = "A"
else
params.searchterm = m.top.nameStartsWith
end if
if m.top.filter = "Favorites"
params.append({ isFavorite: true })
end if
m.log.info("loadChannels: fetching via /Items (alpha filter)")
timer = CreateObject("roTimespan")
res = fetchRes(GetApi().BuildGetItemsByQueryRequest(params), "liveTvChannels")
else
' Dedicated endpoint — much faster, no unnecessary metadata (Chapters, Trickplay, Backdrop, etc.)
' OSD loads its own metadata via LoadVideoContentTask when a channel is selected.
params = {
SortBy: m.top.sortField,
SortOrder: sortOrder,
UserId: m.global.user.id,
addCurrentProgram: false,
enableImages: true,
enableImageTypes: "Primary",
imageTypeLimit: 1,
enableUserData: true
}
if hasSearch
' Fetch all channels for client-side name filtering (no pagination)
m.log.info("loadChannels: fetching all via /LiveTv/Channels (voice search)", "searchTerm", searchTerm)
else
' Normal paginated load
params.startIndex = m.top.startIndex
if m.top.limit > 0 then params.limit = m.top.limit
m.log.info("loadChannels: fetching via /LiveTv/Channels")
end if
if m.top.filter = "Favorites"
params.append({ isFavorite: true })
end if
timer = CreateObject("roTimespan")
res = fetchRes(GetApi().BuildGetLiveTVChannelsRequest(params), "liveTvChannels")
end if
elapsed = timer.TotalMilliseconds()
m.log.info("loadChannels: fetch completed", "elapsed_ms", elapsed, "ok", isValid(res) and res.ok)
results = []
if isValid(res) and res.ok and isValid(res.json)
if not isValid(res.json.TotalRecordCount)
m.log.info("loadChannels: no TotalRecordCount in response")
m.top.totalRecordCount = 0
m.top.channels = results
return
end if
m.top.totalRecordCount = res.json.TotalRecordCount
transformer = JellyfinDataTransformer()
for each item in res.json.Items
node = transformer.transformBaseItem(item)
' Format title with channel number for TimeGrid channel gutter display
if isValidAndNotEmpty(node.channelNumber)
node.title = `${translate(translationKeys.LabelCh)} ${node.channelNumber} ${node.name}`
end if
' Set hdSmallIconUrl for TimeGrid channel icon (ContentNode built-in read by Roku TimeGrid)
if isValidAndNotEmpty(node.primaryImageTag)
node.hdsmalliconurl = ImageURL(node.id, "Primary", { maxHeight: 60, tag: node.primaryImageTag })
end if
' Favorites appear at the top of the channel list
if node.isFavorite
results.Unshift(node)
else
results.push(node)
end if
end for
end if
' Voice search: client-side filter by channel name/number + program name search
if hasSearch and results.count() > 0
results = filterByVoiceSearch(results, searchTerm, timer)
end if
m.log.info("loadChannels: result", "channelCount", results.Count())
m.top.channels = results
end sub
' Filters channels by voice search term using two strategies:
' 1) Client-side match on channel name and channel number
' 2) Server-side /LiveTv/Programs search to find channels airing matching content
' Results are merged and deduplicated.
' @param channels - Array of transformed channel nodes
' @param searchTerm - Trimmed, non-empty voice search text
' @param timer - Running roTimespan for elapsed logging
' @returns Filtered array of channel nodes
function filterByVoiceSearch(channels as object, searchTerm as string, timer as object) as object
lowerSearch = LCase(searchTerm)
' Build a lookup of all channels by ID for merging program results
channelById = {}
for each node in channels
channelById[node.id] = node
end for
' 1) Filter channels by name/number substring match
filtered = []
matchedIds = {}
for each node in channels
nameMatch = LCase(node.name).Instr(lowerSearch) >= 0
numberMatch = isValidAndNotEmpty(node.channelNumber) and node.channelNumber.Instr(searchTerm) >= 0
if nameMatch or numberMatch
filtered.push(node)
matchedIds[node.id] = true
end if
end for
m.log.info("filterByVoiceSearch: channel name/number matches", "count", filtered.count())
' 2) Search programs to find channels airing matching content
programParams = {
SearchTerm: searchTerm,
Limit: 100,
UserId: m.global.user.id
}
programRes = fetchRes(GetApi().BuildGetLiveTVProgramsRequest(programParams), "liveTvPrograms")
programElapsed = timer.TotalMilliseconds()
m.log.info("filterByVoiceSearch: program search completed", "elapsed_ms", programElapsed, "ok", isValid(programRes) and programRes.ok)
if isValid(programRes) and programRes.ok and isValid(programRes.json) and isValid(programRes.json.Items)
for each program in programRes.json.Items
channelId = program.ChannelId ?? ""
if channelId <> "" and not matchedIds.DoesExist(channelId) and channelById.DoesExist(channelId)
filtered.push(channelById[channelId])
matchedIds[channelId] = true
end if
end for
m.log.info("filterByVoiceSearch: total after program merge", "count", filtered.count())
end if
m.top.totalRecordCount = filtered.count()
return filtered
end function