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