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) }