source_utils_webvtt.bs

import "pkg:/source/utils/misc.bs"

' WebVTT parsing for custom (app-drawn) subtitles. Pure string work with no node
' access, so it is safe on any thread; LoadCaptionTask calls it on its Task thread.
'
' Parse leniently: the input is not always server-written. Jellyfin returns a
' subtitle file unchanged when it is already in the requested format
' (`SubtitleEncoder.GetSubtitles()`), so an external `.vtt` on the user's disk
' (hand-edited, or an SRT renamed to `.vtt`) reaches this parser byte for byte.

' The timestamp grammar vttTimestampToMs() accepts: `[H:]MM:SS.mmm`. The hour field
' is optional in WebVTT. A comma before the milliseconds is not valid WebVTT, but it
' has reached the app in production (#584), and rejecting it would drop every cue in
' such a file rather than show them.
function vttTimestampPattern() as object
  return CreateObject("roRegex", "^(?:(\d{1,3}):)?(\d{2}):(\d{2})[.,](\d{3})$", "")
end function

' Milliseconds for one WebVTT timestamp, or -1 when it is not one.
'
' @param {string} text - the bare stamp: no `-->`, no cue settings, no padding
' @param {dynamic} [pattern=invalid] - a vttTimestampPattern(); pass one in when
'   converting many stamps, so the regex is compiled once per file rather than per stamp
' @return {integer} milliseconds, or -1
function vttTimestampToMs(text as string, pattern = invalid as dynamic) as integer
  if not isValid(pattern) then pattern = vttTimestampPattern()
  parts = pattern.match(text)
  if parts.count() < 5 then return -1

  hours = 0
  if isValid(parts[1]) and parts[1] <> "" then hours = parts[1].toInt()
  return 3600000 * hours + 60000 * parts[2].toInt() + 1000 * parts[3].toInt() + parts[4].toInt()
end function

' The timestamp at the start of one side of a timing line: the end side can carry cue
' settings after it (`00:28.876 align:start position:10%`), and a CRLF file leaves a
' trailing carriage return.
function vttTimingField(text as string) as string
  fields = text.trim().tokenize(" " + chr(9))
  if fields.count() = 0 then return ""
  return fields[0]
end function

' Parses a WebVTT file into caption lines.
'
' A WebVTT file is blocks separated by blank lines, and only a cue's text is shown:
' the lines after its timing line, up to the next blank line. Everything else (the
' WEBVTT header, NOTE / STYLE / REGION blocks, and a cue identifier, the optional
' line BEFORE a timing line) is skipped, because none of it follows a timing line
' within its block.
'
' A cue whose timing cannot be read is dropped whole, and its text with it: attaching
' that text to the previous cue's timing would show it at the wrong moment.
'
' @param {string} text - the file contents
' @return {object} { entries: [{ start, end, text }], skippedCues: integer, firstSkipped: string }
'   - start / end in milliseconds; one entry per text line
'   - skippedCues counts cues dropped for an unreadable timing line, firstSkipped
'     quotes the first of them
function parseVTT(text as string) as object
  stamp = vttTimestampPattern()
  result = { entries: [], skippedCues: 0, firstSkipped: "" }
  ' -1 whenever the current line is not cue text.
  curStart = -1
  curEnd = -1

  for each rawLine in text.split(chr(10))
    line = rawLine.trim()
    arrow = line.instr("-->")
    if line = ""
      curStart = -1
    else if arrow >= 0
      curStart = vttTimestampToMs(vttTimingField(line.left(arrow)), stamp)
      curEnd = vttTimestampToMs(vttTimingField(line.mid(arrow + 3)), stamp)
      if curStart = -1 or curEnd = -1
        if result.skippedCues = 0 then result.firstSkipped = line
        result.skippedCues++
        curStart = -1
      end if
    else if curStart <> -1
      result.entries.push({ "start": curStart, "end": curEnd, "text": line })
    end if
  end for
  return result
end function