From 66e52469db87098e8b0acede59a92a2085a5033a Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sat, 22 Nov 2025 20:27:12 -0500 Subject: [PATCH] add lyric fetching --- lrc.go | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 33 +++++++++++ 2 files changed, 199 insertions(+) create mode 100644 lrc.go diff --git a/lrc.go b/lrc.go new file mode 100644 index 0000000..94b1233 --- /dev/null +++ b/lrc.go @@ -0,0 +1,166 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +type LRCLibResponse struct { + SyncedLyrics string `json:"syncedLyrics"` + PlainLyrics string `json:"plainLyrics"` +} + +func TrackDuration(path string) (int, error) { + cmd := exec.Command( + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + path, + ) + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return 0, fmt.Errorf("ffprobe error: %w (%s)", err, stderr.String()) + } + + raw := strings.TrimSpace(out.String()) + if raw == "" { + return 0, fmt.Errorf("empty duration output from ffprobe") + } + + flt, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, fmt.Errorf("parse duration: %w (raw=%q)", err, raw) + } + + return int(flt + 0.5), nil // round to nearest second +} + +// DownloadAlbumLyrics downloads synced lyrics (LRC format) for each track in the album directory. +// Assumes metadata is already final (tags complete). +func DownloadAlbumLyrics(albumDir string) error { + err := filepath.Walk(albumDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + ext := strings.ToLower(filepath.Ext(info.Name())) + if ext != ".mp3" && ext != ".flac" { + return nil + } + + // Skip if LRC already exists next to the file + lrcPath := strings.TrimSuffix(path, ext) + ".lrc" + if _, err := os.Stat(lrcPath); err == nil { + fmt.Println("→ Skipping (already has lyrics):", filepath.Base(path)) + return nil + } + + // Read metadata + md, err := readTags(path) + if err != nil { + fmt.Println("Skipping (unable to read tags):", path, "error:", err) + return nil + } + if md.Title == "" || md.Artist == "" || md.Album == "" { + fmt.Println("Skipping (missing metadata):", path) + return nil + } + + duration, _ := TrackDuration(path) + + lyrics, err := fetchLRCLibLyrics(md.Artist, md.Title, md.Album, duration) + if err != nil { + fmt.Println("No lyrics found:", md.Artist, "-", md.Title) + return nil + } + + // Write .lrc file + if err := os.WriteFile(lrcPath, []byte(lyrics), 0644); err != nil { + return fmt.Errorf("writing lrc file for %s: %w", path, err) + } + + fmt.Println("→ Downloaded lyrics:", filepath.Base(lrcPath)) + return nil + }) + + return err +} + +// fetchLRCLibLyrics calls the LRCLIB API and returns synced lyrics if available. +func fetchLRCLibLyrics(artist, title, album string, duration int) (string, error) { + + url := fmt.Sprintf( + "https://lrclib.net/api/get?artist_name=%s&track_name=%s&album_name=%s&duration=%d", + urlEncode(artist), urlEncode(title), urlEncode(album), duration, + ) + + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("lrclib fetch error: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("lrclib returned status %d", resp.StatusCode) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading lrclib response: %w", err) + } + + var out LRCLibResponse + if err := json.Unmarshal(bodyBytes, &out); err != nil { + return "", fmt.Errorf("parsing lrclib json: %w", err) + } + + if out.SyncedLyrics != "" { + return out.SyncedLyrics, nil + } + + // If no syncedLyrics, fallback to plain + if out.PlainLyrics != "" { + // Convert plain text to a fake LRC wrapper + return plainToLRC(out.PlainLyrics), nil + } + + return "", fmt.Errorf("no lyrics found") +} + +// URL escape helper +func urlEncode(s string) string { + r := strings.ReplaceAll(s, " ", "+") + return r +} + +// Convert plaintext lyrics to a basic unsynced LRC (fallback) +func plainToLRC(plain string) string { + lines := strings.Split(plain, "\n") + var out strings.Builder + + for _, line := range lines { + // LRC format with [00:00.00] prefix when no timing exists + out.WriteString("[00:00.00] ") + out.WriteString(strings.TrimSpace(line)) + out.WriteByte('\n') + } + + return out.String() +} diff --git a/main.go b/main.go index dff2e4a..174fb0e 100644 --- a/main.go +++ b/main.go @@ -186,6 +186,11 @@ func RunImporter() { continue } + fmt.Println("→ Fetching synced lyrics from LRCLIB:") + if err := DownloadAlbumLyrics(albumPath); err != nil { + fmt.Println("Failed to download synced lyrics.") + } + // Apply album-wide ReplayGain fmt.Println("→ Applying ReplayGain to album:", albumPath) if err := applyReplayGain(albumPath); err != nil { @@ -207,6 +212,14 @@ func RunImporter() { } } + lyrics, _ := getLyricFiles(albumPath) + + for _, file := range lyrics { + if err := moveToLibrary(libraryDir, md, file); err != nil { + fmt.Println("Failed to move lyrics:", file, err) + } + } + // Move album cover image if coverImg, err := media.FindCoverImage(albumPath); err == nil { if err := moveToLibrary(libraryDir, md, coverImg); err != nil { @@ -241,6 +254,26 @@ func getAudioFiles(dir string) ([]string, error) { return tracks, nil } +func getLyricFiles(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var lyrics []string + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext == ".lrc" { + lyrics = append(lyrics, filepath.Join(dir, e.Name())) + } + } + + return lyrics, nil +} + func getAlbumMetadata(albumPath, trackPath string) (*MusicMetadata, error) { fmt.Println("→ Tagging track with beets:", trackPath)