first commit
This commit is contained in:
460
main.go
Normal file
460
main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user