Release v0.2.34
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user