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