first commit

This commit is contained in:
2026-04-09 01:42:37 +02:00
commit 75ae131e2a
7 changed files with 1055 additions and 0 deletions

150
cache.go Normal file
View File

@@ -0,0 +1,150 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// NoLyricsCache tracks songs that previously had no results.
type NoLyricsCache struct {
Tracks map[string]time.Time
mutex sync.RWMutex
}
func loadCache(cacheFile string) (*NoLyricsCache, error) {
cache := &NoLyricsCache{Tracks: make(map[string]time.Time)}
if _, err := os.Stat(cacheFile); os.IsNotExist(err) {
return cache, nil
}
data, err := os.ReadFile(cacheFile)
if err != nil {
return cache, err
}
if len(data) == 0 {
return cache, nil
}
err = json.Unmarshal(data, &cache.Tracks)
if cache.Tracks == nil {
cache.Tracks = make(map[string]time.Time)
}
return cache, err
}
func (c *NoLyricsCache) SaveCache(cacheFile string, ttl time.Duration) error {
cacheSnapshot := c.prunedCopy(ttl)
data, err := json.MarshalIndent(cacheSnapshot, "", " ")
if err != nil {
return err
}
return writeFileAtomically(cacheFile, data, 0644)
}
func (c *NoLyricsCache) IsTrackedAsNoLyrics(trackID string, ttl time.Duration) bool {
c.mutex.RLock()
timestamp, exists := c.Tracks[trackID]
c.mutex.RUnlock()
if !exists {
return false
}
if isCacheEntryExpired(timestamp, ttl) {
c.mutex.Lock()
if ts, ok := c.Tracks[trackID]; ok && isCacheEntryExpired(ts, ttl) {
delete(c.Tracks, trackID)
}
c.mutex.Unlock()
return false
}
return true
}
func (c *NoLyricsCache) AddNoLyrics(trackID string) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.Tracks[trackID] = time.Now()
}
func (c *NoLyricsCache) prunedCopy(ttl time.Duration) map[string]time.Time {
c.mutex.Lock()
defer c.mutex.Unlock()
for trackID, timestamp := range c.Tracks {
if isCacheEntryExpired(timestamp, ttl) {
delete(c.Tracks, trackID)
}
}
cacheSnapshot := make(map[string]time.Time, len(c.Tracks))
for trackID, timestamp := range c.Tracks {
cacheSnapshot[trackID] = timestamp
}
return cacheSnapshot
}
func createTrackIdentifier(metadata Metadata) string {
return fmt.Sprintf("%s-%s-%s",
strings.ToLower(strings.TrimSpace(metadata.ArtistName)),
strings.ToLower(strings.TrimSpace(metadata.AlbumName)),
strings.ToLower(strings.TrimSpace(metadata.TrackName)))
}
func isCacheEntryExpired(timestamp time.Time, ttl time.Duration) bool {
if ttl <= 0 {
return false
}
return time.Since(timestamp) > ttl
}
func writeFileAtomically(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
base := filepath.Base(path)
tempFile, err := os.CreateTemp(dir, base+".tmp-*")
if err != nil {
return err
}
tempPath := tempFile.Name()
cleanup := func() {
tempFile.Close()
os.Remove(tempPath)
}
if _, err := tempFile.Write(data); err != nil {
cleanup()
return err
}
if err := tempFile.Chmod(perm); err != nil {
cleanup()
return err
}
if err := tempFile.Close(); err != nil {
os.Remove(tempPath)
return err
}
if err := os.Rename(tempPath, path); err != nil {
os.Remove(tempPath)
return err
}
return nil
}

195
embed.go Normal file
View File

@@ -0,0 +1,195 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ToolAvailability tracks optional embedding tools found on PATH.
type ToolAvailability struct {
Metaflac bool
ID3v2 bool
AtomicParsley bool
}
func detectEmbeddingTools() ToolAvailability {
tools := ToolAvailability{}
if _, err := exec.LookPath("metaflac"); err == nil {
tools.Metaflac = true
}
if _, err := exec.LookPath("id3v2"); err == nil {
tools.ID3v2 = true
}
if _, err := exec.LookPath("AtomicParsley"); err == nil {
tools.AtomicParsley = true
}
return tools
}
func warnMissingEmbeddingTools(tools ToolAvailability) {
if !tools.Metaflac {
fmt.Println("Warning: metaflac not found. Embedding will be skipped for FLAC files.")
}
if !tools.ID3v2 {
fmt.Println("Warning: id3v2 not found. Embedding will be skipped for MP3 files.")
}
if !tools.AtomicParsley {
fmt.Println("Warning: AtomicParsley not found. Embedding will be skipped for M4A files.")
}
}
func hasEmbeddedLyrics(file string, tools ToolAvailability) (bool, error) {
fileExt := strings.ToLower(filepath.Ext(file))
switch fileExt {
case ".flac":
if !tools.Metaflac {
return false, nil
}
cmd := exec.Command("metaflac", "--export-tags-to=-", file)
output, err := cmd.Output()
if err != nil {
return false, err
}
tagDump := strings.ToUpper(string(output))
for _, line := range strings.Split(tagDump, "\n") {
if strings.HasPrefix(line, "LYRICS=") || strings.HasPrefix(line, "UNSYNCEDLYRICS=") {
return true, nil
}
}
return false, nil
case ".mp3":
if !tools.ID3v2 {
return false, nil
}
cmd := exec.Command("id3v2", "--list", file)
output, err := cmd.Output()
if err != nil {
return false, err
}
return strings.Contains(string(output), "USLT"), nil
case ".m4a":
if !tools.AtomicParsley {
return false, nil
}
cmd := exec.Command("AtomicParsley", file, "-t")
output, err := cmd.Output()
if err != nil {
return false, err
}
return strings.Contains(string(output), "\u00a9lyr"), nil
default:
return false, fmt.Errorf("checking for embedded lyrics not supported for %s files", fileExt)
}
}
func embedLyrics(file, lyrics, fileExt string, tools ToolAvailability) error {
switch strings.ToLower(fileExt) {
case ".flac":
if !tools.Metaflac {
return fmt.Errorf("metaflac command not found")
}
tmpFile, err := os.CreateTemp("", "lyrics-*.txt")
if err != nil {
return fmt.Errorf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(lyrics); err != nil {
return fmt.Errorf("failed to write lyrics to temp file: %v", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %v", err)
}
removeCmd := exec.Command("metaflac", "--remove-tag=LYRICS", "--remove-tag=UNSYNCEDLYRICS", file)
if err := removeCmd.Run(); err != nil {
return fmt.Errorf("failed to remove existing lyrics: %v", err)
}
addCmd := exec.Command(
"metaflac",
"--set-tag-from-file=LYRICS="+tmpFile.Name(),
"--set-tag-from-file=UNSYNCEDLYRICS="+tmpFile.Name(),
file,
)
return addCmd.Run()
case ".mp3":
if !tools.ID3v2 {
return fmt.Errorf("id3v2 command not found")
}
tmpFile, err := os.CreateTemp("", "lyrics-*.txt")
if err != nil {
return fmt.Errorf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(lyrics); err != nil {
return fmt.Errorf("failed to write lyrics to temp file: %v", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %v", err)
}
cmd := exec.Command("id3v2", "--USLT", tmpFile.Name(), file)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to embed lyrics with id3v2: %v", err)
}
return nil
case ".m4a":
if !tools.AtomicParsley {
return fmt.Errorf("AtomicParsley command not found")
}
tmpFile, err := os.CreateTemp("", "lyrics-*.txt")
if err != nil {
return fmt.Errorf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(lyrics); err != nil {
return fmt.Errorf("failed to write lyrics to temp file: %v", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %v", err)
}
cmd := exec.Command("AtomicParsley", file, "--lyrics", tmpFile.Name(), "--overWrite")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to embed lyrics with AtomicParsley: %v", err)
}
return nil
default:
return fmt.Errorf("embedding not supported for %s files", fileExt)
}
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module Lyrics
go 1.26.1

141
lyrics_api.go Normal file
View File

@@ -0,0 +1,141 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// LyricsResponse represents the API response structure.
type LyricsResponse struct {
SyncedLyrics string `json:"syncedLyrics"`
PlainLyrics string `json:"plainLyrics"`
Code int `json:"code"`
}
var (
errLyricsNotFound = errors.New("lyrics not found")
errLyricsTemporary = errors.New("temporary lyrics fetch failure")
lyricsHTTPClient = &http.Client{Timeout: 15 * time.Second}
)
type temporaryLyricsError struct {
Reason string
RetryAfter time.Duration
}
func (e *temporaryLyricsError) Error() string {
if e.RetryAfter > 0 {
return fmt.Sprintf("%s: retry after %s", e.Reason, e.RetryAfter)
}
return e.Reason
}
func (e *temporaryLyricsError) Unwrap() error {
return errLyricsTemporary
}
func fetchLyrics(metadata Metadata) (LyricsResponse, error) {
for attempt := 1; attempt <= 3; attempt++ {
lyrics, err := fetchLyricsOnce(metadata)
if err == nil {
return lyrics, nil
}
if !errors.Is(err, errLyricsTemporary) || attempt == 3 {
return LyricsResponse{}, err
}
retryDelay := time.Duration(attempt) * time.Second
var temporaryErr *temporaryLyricsError
if errors.As(err, &temporaryErr) && temporaryErr.RetryAfter > 0 {
retryDelay = temporaryErr.RetryAfter
}
time.Sleep(retryDelay)
}
return LyricsResponse{}, fmt.Errorf("lyrics fetch retries exhausted")
}
func fetchLyricsOnce(metadata Metadata) (LyricsResponse, error) {
apiURL := fmt.Sprintf("https://lrclib.net/api/get?track_name=%s&artist_name=%s&album_name=%s&duration=%d",
url.QueryEscape(metadata.TrackName),
url.QueryEscape(metadata.ArtistName),
url.QueryEscape(metadata.AlbumName),
metadata.Duration)
resp, err := lyricsHTTPClient.Get(apiURL)
if err != nil {
return LyricsResponse{}, &temporaryLyricsError{Reason: fmt.Sprintf("request failed: %v", err)}
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return LyricsResponse{}, errLyricsNotFound
}
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError {
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
return LyricsResponse{}, &temporaryLyricsError{Reason: fmt.Sprintf("http status %d", resp.StatusCode), RetryAfter: retryAfter}
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return LyricsResponse{}, fmt.Errorf("lyrics API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return LyricsResponse{}, err
}
var lyricsResp LyricsResponse
err = json.Unmarshal(body, &lyricsResp)
if err != nil {
return LyricsResponse{}, err
}
if lyricsResp.Code == 404 {
return LyricsResponse{}, errLyricsNotFound
}
if lyricsResp.Code >= 500 {
return LyricsResponse{}, &temporaryLyricsError{Reason: fmt.Sprintf("api code %d", lyricsResp.Code)}
}
return lyricsResp, nil
}
func parseRetryAfter(value string) time.Duration {
if value == "" {
return 0
}
seconds, err := strconv.Atoi(value)
if err == nil {
if seconds < 0 {
return 0
}
return time.Duration(seconds) * time.Second
}
retryTime, err := time.Parse(time.RFC1123, value)
if err != nil {
retryTime, err = time.Parse(time.RFC1123Z, value)
if err != nil {
return 0
}
}
if retryTime.Before(time.Now()) {
return 0
}
return time.Until(retryTime)
}

460
main.go Normal file
View File

@@ -0,0 +1,460 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
)
// Config holds application configuration.
type Config struct {
SourceDir string
DebugMode bool
Recursion bool
ShowLyrics bool
Concurrency int
Embed bool
ReduceLrc bool
CacheFile string
CacheTTL time.Duration
Tools ToolAvailability
}
// Stats tracks processing statistics.
type Stats struct {
TotalFiles int
ProcessedFiles int
SkippedFiles int
NotFoundFiles int
FailedFiles int
EmbeddedFiles int
EmbeddingFailures int
CacheHits int
mutex sync.Mutex
}
// MediaInfoResponse represents the JSON structure returned by mediainfo.
type MediaInfoResponse struct {
Media struct {
Track []struct {
Type string `json:"@type"`
Title string `json:"Title,omitempty"`
Album string `json:"Album,omitempty"`
Performer string `json:"Performer,omitempty"`
Duration string `json:"Duration,omitempty"`
FileExtension string `json:"FileExtension,omitempty"`
Lyrics string `json:"Lyrics,omitempty"`
} `json:"track"`
} `json:"media"`
}
// Metadata holds track information.
type Metadata struct {
TrackName string
ArtistName string
AlbumName string
Duration int
HasLyrics bool
}
func (s *Stats) IncrementProcessed() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.ProcessedFiles++
}
func (s *Stats) IncrementSkipped() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.SkippedFiles++
}
func (s *Stats) IncrementNotFound() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.NotFoundFiles++
}
func (s *Stats) IncrementFailed() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.FailedFiles++
}
func (s *Stats) IncrementEmbedded() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.EmbeddedFiles++
}
func (s *Stats) IncrementEmbeddingFailure() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.EmbeddingFailures++
}
func (s *Stats) IncrementCacheHits() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.CacheHits++
}
func main() {
var debugMode bool
flag.BoolVar(&debugMode, "debug", false, "Enable debug mode")
flag.BoolVar(&debugMode, "d", false, "Enable debug mode (shorthand)")
var noRecurse bool
flag.BoolVar(&noRecurse, "no-recurse", false, "Disable recursion")
flag.BoolVar(&noRecurse, "n", false, "Disable recursion (shorthand)")
var hideLyrics bool
flag.BoolVar(&hideLyrics, "hide-lyrics", false, "Hide lyrics in console output")
flag.BoolVar(&hideLyrics, "h", false, "Hide lyrics in console output (shorthand)")
var concurrency int
flag.IntVar(&concurrency, "concurrency", 4, "Number of concurrent downloads")
flag.IntVar(&concurrency, "c", 4, "Number of concurrent downloads (shorthand)")
var embed bool
flag.BoolVar(&embed, "embed", false, "Embed lyrics into audio files")
flag.BoolVar(&embed, "e", false, "Embed lyrics into audio files (shorthand)")
var reduceLrc bool
flag.BoolVar(&reduceLrc, "reduce", false, "Remove LRC files after embedding")
flag.BoolVar(&reduceLrc, "r", false, "Remove LRC files after embedding (shorthand)")
var cacheFilePath string
flag.StringVar(&cacheFilePath, "cache", "no_lyrics_cache.json", "Path to cache file for tracks without lyrics")
flag.StringVar(&cacheFilePath, "f", "no_lyrics_cache.json", "Path to cache file for tracks without lyrics (shorthand)")
var cacheTTL time.Duration
flag.DurationVar(&cacheTTL, "cache-ttl", 30*24*time.Hour, "TTL for no-lyrics cache entries (e.g. 720h, 48h, 0 to disable expiration)")
flag.DurationVar(&cacheTTL, "t", 30*24*time.Hour, "TTL for no-lyrics cache entries (shorthand)")
flag.Parse()
args := flag.Args()
if len(args) < 1 {
fmt.Println("Usage: lyrics-fetcher [-d|--debug] [-n|--no-recurse] [-h|--hide-lyrics] [-c|--concurrency=N] [-e|--embed] [-r|--reduce] [-f|--cache=FILE] [-t|--cache-ttl=DURATION] <source_directory>")
os.Exit(1)
}
sourceDir := args[0]
if _, err := os.Stat(sourceDir); os.IsNotExist(err) {
fmt.Printf("Error: Directory %s does not exist.\n", sourceDir)
os.Exit(1)
}
if _, err := exec.LookPath("mediainfo"); err != nil {
fmt.Println("Error: mediainfo not found. Please install mediainfo package.")
os.Exit(1)
}
if concurrency < 1 {
fmt.Println("Error: concurrency must be greater than 0.")
os.Exit(1)
}
tools := detectEmbeddingTools()
if embed {
warnMissingEmbeddingTools(tools)
}
config := Config{
SourceDir: sourceDir,
DebugMode: debugMode,
Recursion: !noRecurse,
ShowLyrics: !hideLyrics,
Concurrency: concurrency,
Embed: embed,
ReduceLrc: reduceLrc,
CacheFile: cacheFilePath,
CacheTTL: cacheTTL,
Tools: tools,
}
if config.CacheTTL < 0 {
fmt.Println("Error: cache-ttl must be zero or a positive duration.")
os.Exit(1)
}
cache, err := loadCache(config.CacheFile)
if err != nil {
fmt.Printf("Warning: Failed to load cache: %v. Continuing without cache.\n", err)
cache = &NoLyricsCache{Tracks: make(map[string]time.Time)}
} else {
cache.prunedCopy(config.CacheTTL)
}
startTime := time.Now()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
musicFiles, err := findFiles(config)
if err != nil {
fmt.Printf("Error scanning source directory: %v\n", err)
os.Exit(1)
}
stats := Stats{TotalFiles: len(musicFiles)}
fmt.Printf("Total files to process: %d\n", stats.TotalFiles)
semaphore := make(chan struct{}, config.Concurrency)
var wg sync.WaitGroup
for _, file := range musicFiles {
lrcFile := strings.TrimSuffix(file, filepath.Ext(file)) + ".lrc"
if fileExists(lrcFile) {
fmt.Printf("Skipping %s: sidecar .lrc file already exists.\n", file)
stats.IncrementSkipped()
continue
}
wg.Add(1)
semaphore <- struct{}{}
go func(file string) {
defer wg.Done()
defer func() { <-semaphore }()
processFile(file, &stats, config, cache)
}(file)
}
go func() {
<-sigChan
fmt.Println("\nScript interrupted. Cleaning up...")
if err := cache.SaveCache(config.CacheFile, config.CacheTTL); err != nil {
fmt.Printf("Warning: Failed to save cache: %v\n", err)
}
printStats(startTime, &stats)
os.Exit(1)
}()
wg.Wait()
if err := cache.SaveCache(config.CacheFile, config.CacheTTL); err != nil {
fmt.Printf("Warning: Failed to save cache: %v\n", err)
}
printStats(startTime, &stats)
}
func processFile(file string, stats *Stats, config Config, cache *NoLyricsCache) {
fmt.Printf("Processing file: %s\n", file)
if !config.DebugMode {
hasLyrics, err := hasEmbeddedLyrics(file, config.Tools)
if err == nil && hasLyrics {
fmt.Printf("Skipping %s: file already has embedded lyrics.\n", file)
stats.IncrementSkipped()
return
}
}
metadata, err := getMetadata(file)
if err != nil {
fmt.Printf("Error getting metadata for %s: %s\n", file, err)
stats.IncrementFailed()
return
}
trackID := createTrackIdentifier(metadata)
if cache.IsTrackedAsNoLyrics(trackID, config.CacheTTL) {
fmt.Printf("Skipping %s: Previous lookup found no lyrics.\n", file)
stats.IncrementCacheHits()
stats.IncrementNotFound()
return
}
lyrics, err := fetchLyrics(metadata)
if err != nil {
fmt.Printf("Error fetching lyrics for %s: %s\n", file, err)
if errors.Is(err, errLyricsNotFound) {
stats.IncrementNotFound()
cache.AddNoLyrics(trackID)
} else {
stats.IncrementFailed()
}
return
}
basePath := strings.TrimSuffix(file, filepath.Ext(file))
lrcFile := basePath + ".lrc"
txtFile := basePath + ".txt"
fileExt := filepath.Ext(file)
if lyrics.SyncedLyrics != "" {
if config.DebugMode {
if !config.ShowLyrics {
fmt.Printf("Synced lyrics for '%s' by '%s' found.\n", metadata.TrackName, metadata.ArtistName)
} else {
fmt.Printf("Synced lyrics for '%s' by '%s':\n%s\n", metadata.TrackName, metadata.ArtistName, lyrics.SyncedLyrics)
}
} else {
err := os.WriteFile(lrcFile, []byte(lyrics.SyncedLyrics), 0644)
if err == nil {
fmt.Printf("Synced lyrics for '%s' saved to file.\n", metadata.TrackName)
if config.Embed {
if err := embedLyrics(file, lyrics.SyncedLyrics, fileExt, config.Tools); err == nil {
fmt.Printf("Embedded lyrics into '%s'.\n", file)
stats.IncrementEmbedded()
if config.ReduceLrc {
if err := os.Remove(lrcFile); err == nil {
fmt.Printf("Removed LRC file after embedding: %s\n", lrcFile)
} else {
fmt.Printf("Failed to remove LRC file: %s\n", err)
}
}
} else {
fmt.Printf("Failed to embed lyrics into '%s': %s\n", file, err)
stats.IncrementEmbeddingFailure()
if err := os.Rename(lrcFile, lrcFile+".failed"); err == nil {
fmt.Printf("Renamed LRC file to %s.failed\n", lrcFile)
}
}
}
} else {
fmt.Printf("Error writing lyrics to %s: %s\n", lrcFile, err)
stats.IncrementFailed()
return
}
}
stats.IncrementProcessed()
return
}
fmt.Printf("No synced lyrics found for '%s' by '%s'.\n", metadata.TrackName, metadata.ArtistName)
if lyrics.PlainLyrics != "" {
if !config.DebugMode {
if fileExists(txtFile) {
fmt.Printf("Skipping write for %s: .txt lyrics file already exists.\n", file)
} else {
err := os.WriteFile(txtFile, []byte(lyrics.PlainLyrics), 0644)
if err == nil {
fmt.Printf("Plain lyrics for '%s' saved to file.\n", metadata.TrackName)
} else {
fmt.Printf("Error writing lyrics to %s: %s\n", txtFile, err)
stats.IncrementFailed()
return
}
}
if config.Embed {
if err := embedLyrics(file, lyrics.PlainLyrics, fileExt, config.Tools); err == nil {
fmt.Printf("Embedded plain lyrics into '%s'.\n", file)
stats.IncrementEmbedded()
} else {
fmt.Printf("Failed to embed plain lyrics into '%s': %s\n", file, err)
stats.IncrementEmbeddingFailure()
}
}
}
stats.IncrementProcessed()
return
}
stats.IncrementNotFound()
cache.AddNoLyrics(trackID)
}
func getMetadata(file string) (Metadata, error) {
metadata := Metadata{}
cmd := exec.Command("mediainfo", "--Output=JSON", file)
output, err := cmd.Output()
if err != nil {
return metadata, fmt.Errorf("mediainfo execution error: %v", err)
}
var mediaInfo MediaInfoResponse
if err := json.Unmarshal(output, &mediaInfo); err != nil {
return metadata, fmt.Errorf("error parsing mediainfo JSON: %v", err)
}
for _, track := range mediaInfo.Media.Track {
if track.Type == "General" {
metadata.TrackName = track.Title
metadata.ArtistName = track.Performer
metadata.AlbumName = track.Album
if track.Lyrics != "" {
metadata.HasLyrics = true
}
if track.Duration != "" {
duration, err := parseFloat(track.Duration)
if err == nil {
metadata.Duration = int(duration)
}
}
}
}
if metadata.TrackName == "" {
metadata.TrackName = strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
}
if metadata.ArtistName == "" {
dirPath := filepath.Dir(file)
parentDir := filepath.Dir(dirPath)
metadata.ArtistName = filepath.Base(parentDir)
}
if metadata.AlbumName == "" {
metadata.AlbumName = filepath.Base(filepath.Dir(file))
}
metadata.TrackName = cleanMetadata(metadata.TrackName)
metadata.ArtistName = cleanMetadata(metadata.ArtistName)
metadata.AlbumName = cleanMetadata(metadata.AlbumName)
return metadata, nil
}
func parseFloat(s string) (float64, error) {
var result float64
_, err := fmt.Sscanf(s, "%f", &result)
return result, err
}
func cleanMetadata(s string) string {
s = strings.Join(strings.Fields(s), " ")
s = strings.TrimSuffix(s, ".")
return s
}
func fileExists(filename string) bool {
_, err := os.Stat(filename)
return !os.IsNotExist(err)
}
func printStats(startTime time.Time, stats *Stats) {
duration := time.Since(startTime)
fmt.Println("Processing complete.")
fmt.Printf("Processing duration: %.2f seconds.\n", duration.Seconds())
fmt.Printf("Total files to process: %d\n", stats.TotalFiles)
fmt.Printf("Files processed: %d\n", stats.ProcessedFiles)
fmt.Printf("Files skipped: %d\n", stats.SkippedFiles)
fmt.Printf("Files not found: %d\n", stats.NotFoundFiles)
fmt.Printf("Files failed: %d\n", stats.FailedFiles)
fmt.Printf("Cache hits: %d\n", stats.CacheHits)
if stats.EmbeddedFiles > 0 || stats.EmbeddingFailures > 0 {
fmt.Printf("Files with embedded lyrics: %d\n", stats.EmbeddedFiles)
fmt.Printf("Failed embedding attempts: %d\n", stats.EmbeddingFailures)
}
}

72
main_test.go Normal file
View File

@@ -0,0 +1,72 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestIsCacheEntryExpired(t *testing.T) {
if isCacheEntryExpired(time.Now().Add(-24*time.Hour), 0) {
t.Fatalf("expected ttl=0 to disable expiration")
}
if !isCacheEntryExpired(time.Now().Add(-2*time.Hour), time.Hour) {
t.Fatalf("expected entry to be expired")
}
if isCacheEntryExpired(time.Now().Add(-30*time.Minute), time.Hour) {
t.Fatalf("expected entry to be unexpired")
}
}
func TestIsTrackedAsNoLyricsHonorsTTLAndPrunes(t *testing.T) {
cache := &NoLyricsCache{Tracks: map[string]time.Time{
"old-track": time.Now().Add(-2 * time.Hour),
"recent-track": time.Now().Add(-5 * time.Minute),
}}
if cache.IsTrackedAsNoLyrics("old-track", time.Hour) {
t.Fatalf("expected old track to be treated as expired")
}
if _, exists := cache.Tracks["old-track"]; exists {
t.Fatalf("expected expired track to be pruned")
}
if !cache.IsTrackedAsNoLyrics("recent-track", time.Hour) {
t.Fatalf("expected recent track to remain cached")
}
}
func TestParseRetryAfter(t *testing.T) {
if got := parseRetryAfter("7"); got != 7*time.Second {
t.Fatalf("expected 7s, got %s", got)
}
future := time.Now().Add(3 * time.Second).UTC().Format(time.RFC1123)
got := parseRetryAfter(future)
if got <= 0 || got > 3*time.Second {
t.Fatalf("expected duration in (0s, 3s], got %s", got)
}
}
func TestWriteFileAtomically(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "cache.json")
content := []byte("{\"track\":\"value\"}")
if err := writeFileAtomically(target, content, 0644); err != nil {
t.Fatalf("writeFileAtomically failed: %v", err)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatalf("failed to read target file: %v", err)
}
if string(got) != string(content) {
t.Fatalf("unexpected content: %q", string(got))
}
}

34
scanner.go Normal file
View File

@@ -0,0 +1,34 @@
package main
import (
"os"
"path/filepath"
"strings"
)
func findFiles(config Config) ([]string, error) {
var musicFiles []string
walkFunc := func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() && path != config.SourceDir && !config.Recursion {
return filepath.SkipDir
}
ext := strings.ToLower(filepath.Ext(path))
if ext == ".mp3" || ext == ".m4a" || ext == ".flac" || ext == ".wav" {
musicFiles = append(musicFiles, path)
}
return nil
}
if err := filepath.Walk(config.SourceDir, walkFunc); err != nil {
return nil, err
}
return musicFiles, nil
}