package library import ( "database/sql" "os" "path/filepath" "strings" "git.noroutes.net/NoRoutes/vibetunes/internal/database" "git.noroutes.net/NoRoutes/vibetunes/internal/lyrics" ) // IndexLyrics discovers and stores every lyrics source available for a // scanned file: embedded plain-text lyrics from tags (already extracted // into metadata.Tags by the scanner), and a sidecar .lrc file next to // the media file if one exists — synced, and word-level cued if the // .lrc is an Enhanced LRC file. Both can be present at once and are // stored as separate lyrics entries; nothing here invents timing that // wasn't actually in the source. func IndexLyrics(db *sql.DB, songID, mediaAbsPath, embeddedPlainText string) error { if err := database.ClearSongLyrics(db, songID); err != nil { return err } if embeddedPlainText != "" { lines := make([]database.LyricsLineInput, 0) for _, l := range strings.Split(strings.ReplaceAll(embeddedPlainText, "\r\n", "\n"), "\n") { lines = append(lines, database.LyricsLineInput{Text: l}) } if _, err := database.InsertLyrics(db, songID, "embedded", "xxx", false, lines); err != nil { return err } } if lrcPath := findSidecarLRC(mediaAbsPath); lrcPath != "" { content, err := os.ReadFile(lrcPath) if err == nil { if err := indexParsedLRC(db, songID, content); err != nil { return err } } } return nil } func indexParsedLRC(db *sql.DB, songID string, content []byte) error { parsed := lyrics.Parse(string(content)) if len(parsed.Lines) == 0 { return nil } lang := parsed.Lang if lang == "" { lang = "xxx" } lines := make([]database.LyricsLineInput, 0, len(parsed.Lines)) for _, l := range parsed.Lines { start := l.StartMs line := database.LyricsLineInput{StartMs: &start, Text: l.Text} if len(l.Words) > 0 { line.Words = wordsWithByteOffsets(l.Words) } lines = append(lines, line) } _, err := database.InsertLyrics(db, songID, "external", lang, true, lines) return err } // wordsWithByteOffsets computes each word's zero-based inclusive UTF-8 // byte offsets into lineText, matching how internal/lyrics reconstructs // lineText (words joined by single spaces) so the offsets are exact. func wordsWithByteOffsets(words []lyrics.Word) []database.LyricsWordInput { out := make([]database.LyricsWordInput, 0, len(words)) pos := 0 for _, w := range words { start := pos end := start + len(w.Text) - 1 // inclusive, per the OpenSubsonic Cue schema if end < start { end = start } out = append(out, database.LyricsWordInput{ StartMs: w.StartMs, ByteStart: start, ByteEnd: end, Text: w.Text, }) pos = end + 2 // one past the inclusive end, plus the joining space } return out } // findSidecarLRC looks for a same-basename .lrc file next to a media // file — the standard convention lyric-fetching tools and taggers use. func findSidecarLRC(mediaAbsPath string) string { dir := filepath.Dir(mediaAbsPath) base := strings.TrimSuffix(filepath.Base(mediaAbsPath), filepath.Ext(mediaAbsPath)) candidate := filepath.Join(dir, base+".lrc") if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return candidate } return "" }