commit 75ae131e2a5b857019308316572955bcf396875a Author: Joren Date: Thu Apr 9 01:42:37 2026 +0200 first commit diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..dbe4caa --- /dev/null +++ b/cache.go @@ -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 +} diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..4a2c8f5 --- /dev/null +++ b/embed.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..64038d7 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module Lyrics + +go 1.26.1 diff --git a/lyrics_api.go b/lyrics_api.go new file mode 100644 index 0000000..89693a9 --- /dev/null +++ b/lyrics_api.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a25e1a0 --- /dev/null +++ b/main.go @@ -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] ") + 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) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..6c99671 --- /dev/null +++ b/main_test.go @@ -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)) + } +} diff --git a/scanner.go b/scanner.go new file mode 100644 index 0000000..734dfad --- /dev/null +++ b/scanner.go @@ -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 +}