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