first commit
This commit is contained in:
150
cache.go
Normal file
150
cache.go
Normal 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
195
embed.go
Normal 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)
|
||||
}
|
||||
}
|
||||
141
lyrics_api.go
Normal file
141
lyrics_api.go
Normal 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
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)
|
||||
}
|
||||
}
|
||||
72
main_test.go
Normal file
72
main_test.go
Normal 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
34
scanner.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user