package services // CUE sheet support: a `.cue` sidecar describing the chapter marks of a // single big audio file (the classic CD-rip shape: one flac/mp3 + one // cue). CUE is the LOWEST-priority chapter source — curated sidecar // chapters and embedded m4b chapters outrank it — and a purely optional // one: no cue, no chapters, no error. import ( "bufio" "os" "path/filepath" "strconv" "strings" ) // cueFramesPerSecond is the CD framing CUE INDEX timestamps use // (MM:SS:FF with 75 frames per second). const cueFramesPerSecond = 75.0 // cueTimeToSeconds converts a CUE INDEX timestamp ("03:25:12") to // seconds. Minutes may exceed 59 and hours are folded into the minutes // field per the spec (MM:SS:FF, MM unbounded). func cueTimeToSeconds(v string) float64 { parts := strings.Split(strings.TrimSpace(v), ":") if len(parts) != 3 { return 0 } minutes, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64) seconds, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) frames, err3 := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64) if err1 != nil || err2 != nil || err3 != nil { return 0 } return minutes*60 + seconds + frames/cueFramesPerSecond } // readCueChapters finds a CUE sheet describing the given audio file and // returns its tracks as chapters (global seconds, ends derived from the // next start; the last end stays 0 = unknown). A cue matches when its // FILE directive names the audio file (basename, case-insensitive), or // — for single-cue directories — when the directory holds exactly one. func readCueChapters(path string) []AudioChapter { dir := filepath.Dir(path) base := strings.ToLower(filepath.Base(path)) cuePaths, err := filepath.Glob(filepath.Join(dir, "*.cue")) if err != nil || len(cuePaths) == 0 { return nil } var matched string for _, cuePath := range cuePaths { if referencedByCue(cuePath, base) { matched = cuePath break } } if matched == "" && len(cuePaths) == 1 { // Single-cue directory: the cue almost certainly describes the // only audio file next to it. matched = cuePaths[0] } if matched == "" { return nil } return parseCueSheet(matched, base) } // referencedByCue reports whether the cue's FILE directive names the // given audio basename. func referencedByCue(cuePath, audioBase string) bool { f, err := os.Open(cuePath) if err != nil { return false } defer func() { _ = f.Close() }() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if len(line) >= 4 && strings.EqualFold(line[:4], "FILE") { rest := strings.TrimSpace(line[4:]) rest = strings.Trim(rest, "\"") // FILE "name" WAVE / FILE name MP3 — drop the type word. if idx := strings.LastIndexAny(rest, " \t"); idx != -1 { rest = strings.TrimSpace(rest[:idx]) } rest = strings.Trim(rest, "\"") return strings.EqualFold(filepath.Base(rest), audioBase) } } return false } // parseCueSheet extracts TRACK/TITLE/INDEX 01 entries as chapters. // INDEX timestamps are absolute from the file start per the spec; the // pregap (INDEX 00) is ignored. Multi-file cues contribute only the // TRACK blocks under the FILE directive naming our audio file (or all // blocks when the cue declares no FILE at all). func parseCueSheet(cuePath, audioBase string) []AudioChapter { f, err := os.Open(cuePath) if err != nil { return nil } defer func() { _ = f.Close() }() var chapters []AudioChapter var current *AudioChapter title := "" inOurFile := true flush := func() { if current != nil && current.Start >= 0 { current.Title = strings.TrimSpace(title) if current.Title == "" { current.Title = "Chapter " + strconv.Itoa(current.Index+1) } if n := len(chapters); n > 0 && current.Start >= chapters[n-1].Start { chapters[n-1].End = current.Start } chapters = append(chapters, *current) } current = nil title = "" } scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) upper := strings.ToUpper(line) switch { case strings.HasPrefix(upper, "FILE"): flush() rest := strings.Trim(strings.TrimSpace(line[4:]), "\"") if idx := strings.LastIndexAny(rest, " \t"); idx != -1 { rest = strings.TrimSpace(rest[:idx]) } rest = strings.Trim(rest, "\"") inOurFile = strings.EqualFold(filepath.Base(rest), audioBase) case strings.HasPrefix(upper, "TRACK"): flush() if !inOurFile { continue } current = &AudioChapter{Index: len(chapters)} if fields := strings.Fields(line); len(fields) >= 2 { if n, err := strconv.Atoi(fields[1]); err == nil && n > 0 { current.Index = n - 1 } } case strings.HasPrefix(upper, "TITLE") && current != nil: if q1 := strings.Index(line, "\""); q1 != -1 { if q2 := strings.LastIndex(line, "\""); q2 > q1 { title = line[q1+1 : q2] } } case strings.HasPrefix(upper, "INDEX"): if current == nil || !inOurFile { continue } if fields := strings.Fields(line); len(fields) >= 3 && fields[1] == "01" { current.Start = cueTimeToSeconds(fields[2]) } } } flush() if len(chapters) == 0 { return nil } return chapters }