139 lines
4.0 KiB
Go
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)
|
|
}
|