Files
2026-08-09 08:25:50 +12:00

139 lines
4.0 KiB
Go

package subsync
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// ErrNoCues means nothing in the file looked like a subtitle.
var ErrNoCues = errors.New("subsync: no subtitle cues found")
// Parse reads SRT or WebVTT.
//
// One parser for both because the only difference that matters here is a comma or a full
// stop between the seconds and the milliseconds, and Emby will hand back either depending
// on the route asked and the codec underneath. Everything a format carries that this
// package does not need — cue identifiers, WebVTT positioning, styling blocks, the byte
// order mark a Windows editor leaves behind — is skipped rather than rejected, because a
// subtitle somebody is trying to fix is by definition one that is already not perfect.
func Parse(data []byte) ([]Cue, error) {
text := strings.ReplaceAll(string(data), "\r\n", "\n")
text = strings.TrimPrefix(text, "\ufeff")
var cues []Cue
lines := strings.Split(text, "\n")
for i := 0; i < len(lines); i++ {
start, end, ok := parseTimingLine(lines[i])
if !ok {
continue
}
body := []string{}
for i++; i < len(lines) && strings.TrimSpace(lines[i]) != ""; i++ {
// A timing line with no blank line before it ends the previous cue: some
// files in the wild are written that way, and reading the next cue's timing
// as this one's text would put the whole file one cue out.
if _, _, isTiming := parseTimingLine(lines[i]); isTiming {
i--
break
}
body = append(body, lines[i])
}
cues = append(cues, Cue{Start: start, End: end, Text: strings.Join(body, "\n")})
}
if len(cues) == 0 {
return nil, ErrNoCues
}
return Normalise(cues), nil
}
func parseTimingLine(line string) (time.Duration, time.Duration, bool) {
before, after, ok := strings.Cut(line, "-->")
if !ok {
return 0, 0, false
}
start, ok := parseTimestamp(before)
if !ok {
return 0, 0, false
}
// WebVTT puts cue settings after the end timestamp ("align:start position:50%"), so
// only the first field of what follows is a time.
end, ok := parseTimestamp(strings.Fields(after)[0])
if !ok {
return 0, 0, false
}
return start, end, true
}
// parseTimestamp reads HH:MM:SS,mmm and every variation of it that turns up: a full stop
// instead of the comma, the hours omitted as WebVTT allows, and fewer than three digits
// after the separator.
func parseTimestamp(field string) (time.Duration, bool) {
text := strings.TrimSpace(field)
if text == "" {
return 0, false
}
seconds, fraction, _ := strings.Cut(strings.ReplaceAll(text, ",", "."), ".")
parts := strings.Split(seconds, ":")
if len(parts) < 2 || len(parts) > 3 {
return 0, false
}
var total time.Duration
units := []time.Duration{time.Hour, time.Minute, time.Second}
units = units[len(units)-len(parts):]
for i, part := range parts {
value, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || value < 0 {
return 0, false
}
total += time.Duration(value) * units[i]
}
if fraction != "" {
digits := fraction
if len(digits) > 3 {
digits = digits[:3]
}
value, err := strconv.Atoi(digits)
if err != nil {
return 0, false
}
for len(digits) < 3 {
value *= 10
digits += "0"
}
total += time.Duration(value) * time.Millisecond
}
return total, true
}
// FormatSRT writes cues back out as SubRip.
//
// SRT rather than the format that came in, because it is the one every player reads and
// the one the stored-subtitle table already declares. Renumbered from one: the indices in
// a file being repaired are frequently wrong already, and they carry no meaning worth
// preserving.
func FormatSRT(cues []Cue) []byte {
var out strings.Builder
for i, cue := range cues {
fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n",
i+1, formatTimestamp(cue.Start), formatTimestamp(cue.End), cue.Text)
}
return []byte(out.String())
}
func formatTimestamp(d time.Duration) string {
if d < 0 {
d = 0
}
milliseconds := d.Milliseconds()
return fmt.Sprintf("%02d:%02d:%02d,%03d",
milliseconds/3_600_000,
milliseconds/60_000%60,
milliseconds/1000%60,
milliseconds%1000)
}