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