components_video_LoadCaptionTask.bs

' LoadCaptionTask: fetch and parse ONE subtitle track, then publish it and exit.
'
' A new node per subtitle change (see CaptionRenderer.onUrlChange) rather than a
' relaunch: launching a node whose previous run has not returned is ignored every
' time, so a second track change while the first fetch was still running would
' silently never be fetched (ADR 0037).
'
' Everything here runs on the TASK thread. This component owns no timer, no
' fonts and no render-thread state: that half is CaptionRenderer. That
' separation is the point: when both halves lived in one component they shared one
' unsynchronized `m`, and the render thread's 100 ms caption timer racing this
' function is what produced the &hf3 crash on `m.tags.replaceAll()`.
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/webvtt.bs"

sub init()
  m.log = new log.Logger("LoadCaptionTask")
  m.top.functionName = "fetchCaption"
end sub

' Runs on the Task thread via functionName. HTTP requests require a Task thread.
'
' Uses raw roUrlTransfer + WaitMessage instead of rr_Requests(). rr_Requests_run()
' is a standalone function whose m resolves to the component's shared m AA, and its
' busy-polling loop reads m.top thousands of times per second. WaitMessage() blocks
' at the OS level without touching m. See ADR 0014: the layering reasons there
' still stand even though this component no longer shares m with a render thread.
sub fetchCaption()
  ' Cache m.top into a local so the rest of this function minimizes touches to the
  ' shared m AA, and so each field read is one crossing rather than many.
  top = m.top

  m.log.debug("Start fetchCaption()", top.url)

  ' Whether the server's fallback font actually made it to disk. The renderer
  ' asks this Task instead of checking itself: `CreateObject("roFileSystem")`
  ' returned `Invalid` in a `Group` component's `init()` and a valid object in a
  ' `Task` component's (threading.md#constructing-components).
  ' Written before `captionData` so the delivery handler can read it.
  ' It matters for non-Latin subtitles: without the downloaded font their glyphs
  ' have nothing to render with, and the setting being enabled does not prove the
  ' download succeeded.
  fs = CreateObject("roFileSystem")
  top.fontAvailable = isValid(fs) and fs.Exists("tmp:/font")

  ' Only `.vtt` is accepted, and that is safe only because `vtt` leads
  ' `getSubtitleProfiles()` in `source/utils/deviceCapabilities.bs`: Jellyfin converts
  ' an external text subtitle to the FIRST format the client advertises. Reorder that
  ' list and every external track arrives as another format and renders nothing,
  ' which is pinned by `deviceCapabilities-subtitleProfile.spec.bs`, which fails on the reorder.
  re = CreateObject("roRegex", "(http.*?\.vtt)", "s")
  matchResult = re.match(top.url)

  if not isValid(matchResult) or matchResult.count() = 0
    m.log.warn("No valid VTT URL found in:", top.url)
    top.captionData = { entries: [] }
    m.log.debug("End fetchCaption()")
    return
  end if

  url = matchResult[0]

  urlTransfer = CreateObject("roUrlTransfer")
  if url.Left(8) = "https://"
    urlTransfer.SetCertificatesFile("common:/certs/ca-bundle.crt")
  end if
  port = CreateObject("roMessagePort")
  urlTransfer.SetMessagePort(port)
  urlTransfer.SetUrl(url)
  urlTransfer.AddHeader("Cache-Control", "no-cache")

  if urlTransfer.AsyncGetToString()
    msg = port.WaitMessage(10000)
    if type(msg) = "roUrlEvent" and msg.GetResponseCode() = 200
      vttContent = msg.GetString()
      m.log.debug("VTT content received, length:", vttContent.len())
      parsed = parseVTT(vttContent)
      if parsed.skippedCues > 0
        m.log.warn("Skipped", parsed.skippedCues, "caption cues with an unreadable timestamp — first:", parsed.firstSkipped)
      end if
      m.log.info("Parsed", parsed.entries.count(), "caption entries")
      top.captionData = { entries: parsed.entries }
    else if type(msg) = "roUrlEvent"
      m.log.error("Caption fetch failed — URL:", url, "responseCode:", msg.GetResponseCode(), "reason:", msg.GetFailureReason())
      top.captionData = { entries: [] }
    else
      m.log.error("Caption fetch timed out — URL:", url)
      top.captionData = { entries: [] }
    end if
  else
    m.log.error("Failed to initiate caption download")
    top.captionData = { entries: [] }
  end if
  m.log.debug("End fetchCaption()")
end sub