music-importer/lrc.go
2025-11-22 20:27:12 -05:00

166 lines
3.9 KiB
Go

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