diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 303b17b..59af26e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,8 +2,8 @@ name: Build and Push Docker Image on: push: - branches: - - main + tags: + - 'v*' workflow_dispatch: @@ -23,8 +23,13 @@ jobs: - name: Build Docker image run: | - docker build -t gabehf/music-importer:latest . + docker build \ + --build-arg VERSION=${{ github.ref_name }} \ + -t gabehf/music-importer:${{ github.ref_name }} \ + -t gabehf/music-importer:latest \ + . - name: Push Docker image run: | - docker push gabehf/music-importer:latest \ No newline at end of file + docker push gabehf/music-importer:${{ github.ref_name }} + docker push gabehf/music-importer:latest diff --git a/Dockerfile b/Dockerfile index 863cc2e..854d2fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,9 @@ RUN go mod download # Copy source code COPY . . -# Build Go binary -RUN CGO_ENABLED=0 GOOS=linux go build -o importer . +# Accept version from build arg and bake it into the binary +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-X main.version=${VERSION}" -o importer . # Stage 2: Runtime on Ubuntu 24.04 FROM ubuntu:24.04 diff --git a/audio.go b/audio.go new file mode 100644 index 0000000..5faedf2 --- /dev/null +++ b/audio.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// applyReplayGain runs rsgain in "easy" mode on a directory. +func applyReplayGain(path string) error { + fmt.Println("→ Applying ReplayGain:", path) + return runCmd("rsgain", "easy", path) +} + +// cleanAlbumTags strips COMMENT and DESCRIPTION tags from all files in dir. +func cleanAlbumTags(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir() { + continue + } + if err := rmDescAndCommentTags(filepath.Join(dir, e.Name())); err != nil { + fmt.Println("Failed to clean comment and description tags:", err) + } + } + return nil +} + +// rmDescAndCommentTags removes COMMENT and DESCRIPTION tags from a single file. +// Currently only handles FLAC; other formats are silently skipped. +func rmDescAndCommentTags(trackpath string) error { + if strings.HasSuffix(strings.ToLower(trackpath), ".flac") { + return runCmd("metaflac", "--remove-tag=COMMENT", "--remove-tag=DESCRIPTION", trackpath) + } + return nil +} diff --git a/cmd.go b/cmd.go new file mode 100644 index 0000000..bc114b8 --- /dev/null +++ b/cmd.go @@ -0,0 +1,14 @@ +package main + +import ( + "os" + "os/exec" +) + +// runCmd executes a shell command, forwarding stdout and stderr to the process output. +func runCmd(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/files.go b/files.go new file mode 100644 index 0000000..5155997 --- /dev/null +++ b/files.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" +) + +// moveToLibrary moves a file to {libDir}/{artist}/[{year}] {album}/filename. +func moveToLibrary(libDir string, md *MusicMetadata, srcPath string) error { + targetDir := filepath.Join(libDir, sanitize(md.Artist), sanitize(fmt.Sprintf("[%s] %s", md.Year, md.Album))) + if err := os.MkdirAll(targetDir, 0755); err != nil { + return err + } + + dst := filepath.Join(targetDir, filepath.Base(srcPath)) + fmt.Println("→ Moving:", srcPath, "→", dst) + return os.Rename(srcPath, dst) +} + +// cluster moves all top-level audio files in dir into subdirectories named +// after their embedded album tag. +func cluster(dir string) error { + files, err := getAudioFiles(dir) + if err != nil { + return err + } + + for _, f := range files { + tags, err := readTags(f) + if err != nil { + return err + } + albumDir := path.Join(dir, sanitize(tags.Album)) + if err = os.MkdirAll(albumDir, 0755); err != nil { + return err + } + if err = os.Rename(f, path.Join(albumDir, path.Base(f))); err != nil { + return err + } + } + + return nil +} + +// getAudioFiles returns all .flac and .mp3 files directly inside dir. +func getAudioFiles(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var tracks []string + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext == ".flac" || ext == ".mp3" { + tracks = append(tracks, filepath.Join(dir, e.Name())) + } + } + + return tracks, nil +} + +// getLyricFiles returns all .lrc files directly inside dir. +func getLyricFiles(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var lyrics []string + for _, e := range entries { + if e.IsDir() { + continue + } + if strings.ToLower(filepath.Ext(e.Name())) == ".lrc" { + lyrics = append(lyrics, filepath.Join(dir, e.Name())) + } + } + + return lyrics, nil +} + +// sanitize removes or replaces characters that are unsafe in file system paths. +func sanitize(s string) string { + r := strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "-", + "?", "", + "*", "", + "\"", "", + "<", "", + ">", "", + "|", "", + ) + return r.Replace(s) +} diff --git a/importer.go b/importer.go new file mode 100644 index 0000000..ac449b5 --- /dev/null +++ b/importer.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" +) + +func RunImporter() { + importDir := os.Getenv("IMPORT_DIR") + libraryDir := os.Getenv("LIBRARY_DIR") + + if importerRunning { + return + } + + importerMu.Lock() + importerRunning = true + importerMu.Unlock() + defer func() { + importerMu.Lock() + importerRunning = false + importerMu.Unlock() + }() + + if importDir == "" || libraryDir == "" { + log.Println("IMPORT_DIR and LIBRARY_DIR must be set") + return + } + + fmt.Println("=== Starting Import ===") + + if err := cluster(importDir); err != nil { + log.Println("Failed to cluster top-level audio files:", err) + return + } + + entries, err := os.ReadDir(importDir) + if err != nil { + log.Println("Failed to read import dir:", err) + return + } + + for _, e := range entries { + if !e.IsDir() { + continue + } + + albumPath := filepath.Join(importDir, e.Name()) + + tracks, err := getAudioFiles(albumPath) + if err != nil { + fmt.Println("Skipping (error scanning):", albumPath, err) + continue + } + if len(tracks) == 0 { + continue + } + + fmt.Println("\n===== Album:", e.Name(), "=====") + + fmt.Println("→ Cleaning album tags:") + if err = cleanAlbumTags(albumPath); err != nil { + fmt.Println("Cleaning album tags failed:", err) + } + + fmt.Println("→ Tagging album metadata:") + md, err := getAlbumMetadata(albumPath, tracks[0]) + if err != nil { + fmt.Println("Metadata failed, skipping album:", err) + continue + } + + fmt.Println("→ Fetching synced lyrics from LRCLIB:") + if err := DownloadAlbumLyrics(albumPath); err != nil { + fmt.Println("Failed to download synced lyrics.") + } + + fmt.Println("→ Applying ReplayGain to album:", albumPath) + if err := applyReplayGain(albumPath); err != nil { + fmt.Println("ReplayGain failed, skipping album:", err) + continue + } + + fmt.Println("→ Embedding cover art for album:", albumPath) + if err := EmbedAlbumArtIntoFolder(albumPath); err != nil { + fmt.Println("Cover embed failed, skipping album:", err) + continue + } + + fmt.Println("→ Moving tracks into library for album:", albumPath) + for _, track := range tracks { + if err := moveToLibrary(libraryDir, md, track); err != nil { + fmt.Println("Failed to move track:", track, err) + } + } + + lyrics, _ := getLyricFiles(albumPath) + + fmt.Println("→ Moving lyrics into library for album:", albumPath) + for _, file := range lyrics { + if err := moveToLibrary(libraryDir, md, file); err != nil { + fmt.Println("Failed to move lyrics:", file, err) + } + } + + fmt.Println("→ Moving album cover into library for album:", albumPath) + if coverImg, err := FindCoverImage(albumPath); err == nil { + if err := moveToLibrary(libraryDir, md, coverImg); err != nil { + fmt.Println("Failed to cover image:", coverImg, err) + } + } + + os.Remove(albumPath) + } + + fmt.Println("\n=== Import Complete ===") +} diff --git a/main.go b/main.go index 9b717e1..a8f2cb5 100644 --- a/main.go +++ b/main.go @@ -1,389 +1,18 @@ package main import ( - "encoding/json" - "errors" - "fmt" "log" "net/http" - "os" - "os/exec" - "path" - "path/filepath" - "strings" "sync" "text/template" - - "github.com/gabehf/music-import/media" ) -type MusicMetadata struct { - Artist string - Album string - Title string - Year string -} +// version is set at build time via -ldflags="-X main.version=..." +var version = "dev" -// Run a shell command and return combined stdout/stderr. -func runCmd(name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// Use beets to fetch metadata and tag the file. -// The -A flag is "autotag" with no import", -W is "write tags". -func tagWithBeets(path string) error { - fmt.Println("→ Tagging with beets:", path) - return runCmd("beet", "import", "-Cq", path) -} - -// Fallback: query MusicBrainz API manually if beets fails. -// (very basic lookup using "track by name" search) -func fetchMusicBrainzInfo(filename string) (*MusicMetadata, error) { - fmt.Println("→ Fallback: querying MusicBrainz:", filename) - - query := fmt.Sprintf("recording:%q", strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))) - url := "https://musicbrainz.org/ws/2/recording/?query=" + query + "&fmt=json" - - resp, err := exec.Command("curl", "-s", url).Output() - if err != nil { - return nil, err - } - - var data struct { - Recordings []struct { - Title string `json:"title"` - Releases []struct { - Title string `json:"title"` - ArtistCredit []struct { - Name string `json:"name"` - } `json:"artist-credit"` - } `json:"releases"` - FirstReleaseDate string `json:"first-release-date"` - } `json:"recordings"` - } - - if err := json.Unmarshal(resp, &data); err != nil { - return nil, err - } - - if len(data.Recordings) == 0 || len(data.Recordings[0].Releases) == 0 { - return nil, errors.New("no MusicBrainz match") - } - - r := data.Recordings[0] - rel := r.Releases[0] - - artist := rel.ArtistCredit[0].Name - album := rel.Title - title := r.Title - year := strings.Split(r.FirstReleaseDate, "-")[0] - - return &MusicMetadata{Artist: artist, Album: album, Title: title, Year: year}, nil -} - -// Apply ReplayGain using rsgain in "easy" mode. -func applyReplayGain(path string) error { - fmt.Println("→ Applying ReplayGain:", path) - return runCmd("rsgain", "easy", path) -} - -// Move file to {LIBRARY_DIR}/{artist}/{album}/filename -func moveToLibrary(libDir string, md *MusicMetadata, srcPath string) error { - targetDir := filepath.Join(libDir, sanitize(md.Artist), sanitize(fmt.Sprintf("[%s] %s", md.Year, md.Album))) - if err := os.MkdirAll(targetDir, 0755); err != nil { - return err - } - - dst := filepath.Join(targetDir, filepath.Base(srcPath)) - fmt.Println("→ Moving:", srcPath, "→", dst) - return os.Rename(srcPath, dst) -} - -// Remove filesystem-unsafe chars -func sanitize(s string) string { - r := strings.NewReplacer("/", "_", "\\", "_", ":", "-", "?", "", "*", "", "\"", "", "<", "", ">", "", "|", "") - return r.Replace(s) -} - -// Read embedded tags using ffprobe (works for most formats). -func readTags(path string) (*MusicMetadata, error) { - out, err := exec.Command( - "ffprobe", "-v", "quiet", "-print_format", "json", - "-show_format", path, - ).Output() - if err != nil { - return nil, err - } - - var data struct { - Format struct { - Tags map[string]string `json:"tags"` - } `json:"format"` - } - - json.Unmarshal(out, &data) - - t := data.Format.Tags - if t == nil { - return &MusicMetadata{}, nil - } - - return &MusicMetadata{ - Artist: firstNonEmpty(t["artist"], t["ARTIST"]), - Album: firstNonEmpty(t["album"], t["ALBUM"]), - Title: firstNonEmpty(t["title"], t["TITLE"]), - Year: firstNonEmpty(t["year"], t["YEAR"], t["ORIGINALYEAR"]), - }, nil -} - -func firstNonEmpty(vals ...string) string { - for _, v := range vals { - if v != "" { - return v - } - } - return "" -} - -func RunImporter() { - importDir := os.Getenv("IMPORT_DIR") - libraryDir := os.Getenv("LIBRARY_DIR") - - if importerRunning { - return - } - - importerMu.Lock() - importerRunning = true - importerMu.Unlock() - defer func() { - importerMu.Lock() - importerRunning = false - importerMu.Unlock() - }() - - if importDir == "" || libraryDir == "" { - log.Println("IMPORT_DIR and LIBRARY_DIR must be set") - return - } - - fmt.Println("=== Starting Import ===") - - if err := cluster(importDir); err != nil { - log.Println("Failed to cluster top-level audio files:", err) - return - } - - entries, err := os.ReadDir(importDir) - if err != nil { - log.Println("Failed to read import dir:", err) - return - } - - for _, e := range entries { - if !e.IsDir() { - continue // skip files - } - - albumPath := filepath.Join(importDir, e.Name()) - - // Check if the folder contains audio files - tracks, err := getAudioFiles(albumPath) - if err != nil { - fmt.Println("Skipping (error scanning):", albumPath, err) - continue - } - if len(tracks) == 0 { - continue // no valid audio files → not an album folder - } - - fmt.Println("\n===== Album:", e.Name(), "=====") - - // Get metadata for this album (using first track) - fmt.Println("→ Cleaning album tags:") - err = cleanAlbumTags(albumPath) - if err != nil { - fmt.Println("Cleaning album tags failed:", err) - } - - // Get metadata for this album (using first track) - fmt.Println("→ Tagging album metadata:") - md, err := getAlbumMetadata(albumPath, tracks[0]) - if err != nil { - fmt.Println("Metadata failed, skipping album:", err) - continue - } - - fmt.Println("→ Fetching synced lyrics from LRCLIB:") - if err := DownloadAlbumLyrics(albumPath); err != nil { - fmt.Println("Failed to download synced lyrics.") - } - - // Apply album-wide ReplayGain - fmt.Println("→ Applying ReplayGain to album:", albumPath) - if err := applyReplayGain(albumPath); err != nil { - fmt.Println("ReplayGain failed, skipping album:", err) - continue - } - - // embed cover img if available - fmt.Println("→ Embedding cover art for album:", albumPath) - if err := media.EmbedAlbumArtIntoFolder(albumPath); err != nil { - fmt.Println("Cover embed failed, skipping album:", err) - continue - } - - // Move files to library - fmt.Println("→ Moving tracks into library for album:", albumPath) - for _, track := range tracks { - if err := moveToLibrary(libraryDir, md, track); err != nil { - fmt.Println("Failed to move track:", track, err) - } - } - - lyrics, _ := getLyricFiles(albumPath) - - fmt.Println("→ Moving lyrics into library for album:", albumPath) - for _, file := range lyrics { - if err := moveToLibrary(libraryDir, md, file); err != nil { - fmt.Println("Failed to move lyrics:", file, err) - } - } - - // Move album cover image - fmt.Println("→ Moving album cover into library for album:", albumPath) - if coverImg, err := media.FindCoverImage(albumPath); err == nil { - if err := moveToLibrary(libraryDir, md, coverImg); err != nil { - fmt.Println("Failed to cover image:", coverImg, err) - } - } - - // Remove empty album directory after moving files - os.Remove(albumPath) - } - - fmt.Println("\n=== Import Complete ===") -} - -// moves all top-level audio files into folders defined by their album tags -func cluster(dir string) error { - files, err := getAudioFiles(dir) - if err != nil { - return err - } - - for _, f := range files { - tags, err := readTags(f) - if err != nil { - return err - } - err = os.MkdirAll(path.Join(dir, fmt.Sprintf("%s", sanitize(tags.Album))), 0755) - if err != nil { - return err - } - err = os.Rename(f, path.Join(dir, fmt.Sprintf("%s", sanitize(tags.Album)), path.Base(f))) - if err != nil { - return err - } - } - - return nil -} - -func getAudioFiles(dir string) ([]string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - - var tracks []string - for _, e := range entries { - if e.IsDir() { - continue - } - ext := strings.ToLower(filepath.Ext(e.Name())) - if ext == ".flac" || ext == ".mp3" { - tracks = append(tracks, filepath.Join(dir, e.Name())) - } - } - - return tracks, nil -} - -func getLyricFiles(dir string) ([]string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - - var lyrics []string - for _, e := range entries { - if e.IsDir() { - continue - } - ext := strings.ToLower(filepath.Ext(e.Name())) - if ext == ".lrc" { - lyrics = append(lyrics, filepath.Join(dir, e.Name())) - } - } - - return lyrics, nil -} - -func cleanAlbumTags(dir string) error { - entries, err := os.ReadDir(dir) - if err != nil { - return err - } - for _, e := range entries { - if e.IsDir() { - continue - } - if err := rmDescAndCommentTags(filepath.Join(dir, e.Name())); err != nil { - fmt.Println("Failed to clean comment and description tags:", err) - } - } - return nil -} - -func rmDescAndCommentTags(trackpath string) error { - lower := strings.ToLower(trackpath) - switch { - case strings.HasSuffix(lower, ".flac"): - return runCmd("metaflac", "--remove-tag=COMMENT", "--remove-tag=DESCRIPTION", trackpath) - default: - return nil - } -} - -func getAlbumMetadata(albumPath, trackPath string) (*MusicMetadata, error) { - fmt.Println("→ Tagging track with beets:", trackPath) - - if err := tagWithBeets(albumPath); err != nil { - fmt.Println("Beets tagging failed; fallback to manual MusicBrainz lookup:", err) - } - - md, err := readTags(trackPath) - if err == nil && md.Artist != "" && md.Album != "" { - return md, nil - } - - fmt.Println("→ Missing tags, attempting MusicBrainz manual lookup...") - - md, err = fetchMusicBrainzInfo(trackPath) - if err != nil { - return nil, fmt.Errorf("metadata lookup failed: %w", err) - } - - return md, nil -} - -// --- WEB SERVER --- // var importerMu sync.Mutex var importerRunning bool + var tmpl = template.Must(template.New("index").Parse(` @@ -410,6 +39,13 @@ var tmpl = template.Must(template.New("index").Parse(` background: #555; cursor: not-allowed; } + footer { + position: fixed; + bottom: 16px; + width: 100%; + font-size: 13px; + color: #999; + } @@ -419,6 +55,7 @@ var tmpl = template.Must(template.New("index").Parse(` {{if .Running}}Importer Running...{{else}}Run Importer{{end}} + `)) @@ -428,7 +65,10 @@ func handleHome(w http.ResponseWriter, r *http.Request) { running := importerRunning importerMu.Unlock() - tmpl.Execute(w, struct{ Running bool }{Running: running}) + tmpl.Execute(w, struct { + Running bool + Version string + }{Running: running, Version: version}) } func handleRun(w http.ResponseWriter, r *http.Request) { @@ -446,16 +86,15 @@ func handleRun(w http.ResponseWriter, r *http.Request) { return } - // Run importer in a background goroutine go RunImporter() http.Redirect(w, r, "/", http.StatusSeeOther) } func main() { + log.Printf("Music Importer %s starting on http://localhost:8080", version) http.HandleFunc("/", handleHome) http.HandleFunc("/run", handleRun) - fmt.Println("Web server listening on http://localhost:8080") log.Fatal(http.ListenAndServe(":8080", nil)) } diff --git a/media/media.go b/media.go similarity index 96% rename from media/media.go rename to media.go index f429a6f..60711a2 100644 --- a/media/media.go +++ b/media.go @@ -1,4 +1,4 @@ -package media +package main import ( "bytes" @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" id3v2 "github.com/bogem/id3v2" // optional alternative @@ -14,6 +15,7 @@ import ( var coverNames = []string{ "cover.jpg", "cover.jpeg", "cover.png", "folder.jpg", "folder.jpeg", "folder.png", + "album.jpg", "album.jpeg", "album.png", } // EmbedAlbumArtIntoFolder scans one album folder and embeds cover art. @@ -61,10 +63,8 @@ func FindCoverImage(dir string) (string, error) { continue } l := strings.ToLower(e.Name()) - for _, name := range coverNames { - if l == name { - return filepath.Join(dir, e.Name()), nil - } + if slices.Contains(coverNames, l) { + return filepath.Join(dir, e.Name()), nil } } return "", fmt.Errorf("no cover image found in %s", dir) diff --git a/metadata.go b/metadata.go new file mode 100644 index 0000000..0861567 --- /dev/null +++ b/metadata.go @@ -0,0 +1,131 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +type MusicMetadata struct { + Artist string + Album string + Title string + Year string +} + +// Read embedded tags from an audio file using ffprobe. +func readTags(path string) (*MusicMetadata, error) { + out, err := exec.Command( + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", path, + ).Output() + if err != nil { + return nil, err + } + + var data struct { + Format struct { + Tags map[string]string `json:"tags"` + } `json:"format"` + } + + json.Unmarshal(out, &data) + + t := data.Format.Tags + if t == nil { + return &MusicMetadata{}, nil + } + + return &MusicMetadata{ + Artist: firstNonEmpty(t["artist"], t["ARTIST"]), + Album: firstNonEmpty(t["album"], t["ALBUM"]), + Title: firstNonEmpty(t["title"], t["TITLE"]), + Year: firstNonEmpty(t["year"], t["YEAR"], t["ORIGINALYEAR"]), + }, nil +} + +// Use beets to fetch metadata and tag all files in a directory. +func tagWithBeets(path string) error { + fmt.Println("→ Tagging with beets:", path) + return runCmd("beet", "import", "-Cq", path) +} + +// Fallback: query MusicBrainz API manually if beets fails. +func fetchMusicBrainzInfo(filename string) (*MusicMetadata, error) { + fmt.Println("→ Fallback: querying MusicBrainz:", filename) + + query := fmt.Sprintf("recording:%q", strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))) + url := "https://musicbrainz.org/ws/2/recording/?query=" + query + "&fmt=json" + + resp, err := exec.Command("curl", "-s", url).Output() + if err != nil { + return nil, err + } + + var data struct { + Recordings []struct { + Title string `json:"title"` + Releases []struct { + Title string `json:"title"` + ArtistCredit []struct { + Name string `json:"name"` + } `json:"artist-credit"` + } `json:"releases"` + FirstReleaseDate string `json:"first-release-date"` + } `json:"recordings"` + } + + if err := json.Unmarshal(resp, &data); err != nil { + return nil, err + } + + if len(data.Recordings) == 0 || len(data.Recordings[0].Releases) == 0 { + return nil, errors.New("no MusicBrainz match") + } + + r := data.Recordings[0] + rel := r.Releases[0] + + return &MusicMetadata{ + Artist: rel.ArtistCredit[0].Name, + Album: rel.Title, + Title: r.Title, + Year: strings.Split(r.FirstReleaseDate, "-")[0], + }, nil +} + +// getAlbumMetadata attempts beets tagging on the album directory, reads tags +// back from the first track, and falls back to MusicBrainz if tags are missing. +func getAlbumMetadata(albumPath, trackPath string) (*MusicMetadata, error) { + fmt.Println("→ Tagging track with beets:", trackPath) + + if err := tagWithBeets(albumPath); err != nil { + fmt.Println("Beets tagging failed; fallback to manual MusicBrainz lookup:", err) + } + + md, err := readTags(trackPath) + if err == nil && md.Artist != "" && md.Album != "" { + return md, nil + } + + fmt.Println("→ Missing tags, attempting MusicBrainz manual lookup...") + + md, err = fetchMusicBrainzInfo(trackPath) + if err != nil { + return nil, fmt.Errorf("metadata lookup failed: %w", err) + } + + return md, nil +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +}