This commit is contained in:
Gabe Farrell 2026-04-02 19:14:36 -04:00
parent a8a5459b06
commit 853f08221f
9 changed files with 440 additions and 388 deletions

View file

@ -2,8 +2,8 @@ name: Build and Push Docker Image
on: on:
push: push:
branches: tags:
- main - 'v*'
workflow_dispatch: workflow_dispatch:
@ -23,8 +23,13 @@ jobs:
- name: Build Docker image - name: Build Docker image
run: | 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 - name: Push Docker image
run: | run: |
docker push gabehf/music-importer:${{ github.ref_name }}
docker push gabehf/music-importer:latest docker push gabehf/music-importer:latest

View file

@ -14,8 +14,9 @@ RUN go mod download
# Copy source code # Copy source code
COPY . . COPY . .
# Build Go binary # Accept version from build arg and bake it into the binary
RUN CGO_ENABLED=0 GOOS=linux go build -o importer . 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 # Stage 2: Runtime on Ubuntu 24.04
FROM ubuntu:24.04 FROM ubuntu:24.04

40
audio.go Normal file
View file

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

14
cmd.go Normal file
View file

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

103
files.go Normal file
View file

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

119
importer.go Normal file
View file

@ -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 ===")
}

393
main.go
View file

@ -1,389 +1,18 @@
package main package main
import ( import (
"encoding/json"
"errors"
"fmt"
"log" "log"
"net/http" "net/http"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"sync" "sync"
"text/template" "text/template"
"github.com/gabehf/music-import/media"
) )
type MusicMetadata struct { // version is set at build time via -ldflags="-X main.version=..."
Artist string var version = "dev"
Album string
Title string
Year string
}
// 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 importerMu sync.Mutex
var importerRunning bool var importerRunning bool
var tmpl = template.Must(template.New("index").Parse(` var tmpl = template.Must(template.New("index").Parse(`
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -410,6 +39,13 @@ var tmpl = template.Must(template.New("index").Parse(`
background: #555; background: #555;
cursor: not-allowed; cursor: not-allowed;
} }
footer {
position: fixed;
bottom: 16px;
width: 100%;
font-size: 13px;
color: #999;
}
</style> </style>
</head> </head>
<body> <body>
@ -419,6 +55,7 @@ var tmpl = template.Must(template.New("index").Parse(`
{{if .Running}}Importer Running...{{else}}Run Importer{{end}} {{if .Running}}Importer Running...{{else}}Run Importer{{end}}
</button> </button>
</form> </form>
<footer>{{.Version}}</footer>
</body> </body>
</html> </html>
`)) `))
@ -428,7 +65,10 @@ func handleHome(w http.ResponseWriter, r *http.Request) {
running := importerRunning running := importerRunning
importerMu.Unlock() 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) { func handleRun(w http.ResponseWriter, r *http.Request) {
@ -446,16 +86,15 @@ func handleRun(w http.ResponseWriter, r *http.Request) {
return return
} }
// Run importer in a background goroutine
go RunImporter() go RunImporter()
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
func main() { func main() {
log.Printf("Music Importer %s starting on http://localhost:8080", version)
http.HandleFunc("/", handleHome) http.HandleFunc("/", handleHome)
http.HandleFunc("/run", handleRun) http.HandleFunc("/run", handleRun)
fmt.Println("Web server listening on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil)) log.Fatal(http.ListenAndServe(":8080", nil))
} }

View file

@ -1,4 +1,4 @@
package media package main
import ( import (
"bytes" "bytes"
@ -6,6 +6,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
id3v2 "github.com/bogem/id3v2" // optional alternative id3v2 "github.com/bogem/id3v2" // optional alternative
@ -14,6 +15,7 @@ import (
var coverNames = []string{ var coverNames = []string{
"cover.jpg", "cover.jpeg", "cover.png", "cover.jpg", "cover.jpeg", "cover.png",
"folder.jpg", "folder.jpeg", "folder.png", "folder.jpg", "folder.jpeg", "folder.png",
"album.jpg", "album.jpeg", "album.png",
} }
// EmbedAlbumArtIntoFolder scans one album folder and embeds cover art. // EmbedAlbumArtIntoFolder scans one album folder and embeds cover art.
@ -61,10 +63,8 @@ func FindCoverImage(dir string) (string, error) {
continue continue
} }
l := strings.ToLower(e.Name()) l := strings.ToLower(e.Name())
for _, name := range coverNames { if slices.Contains(coverNames, l) {
if l == name { return filepath.Join(dir, e.Name()), nil
return filepath.Join(dir, e.Name()), nil
}
} }
} }
return "", fmt.Errorf("no cover image found in %s", dir) return "", fmt.Errorf("no cover image found in %s", dir)

131
metadata.go Normal file
View file

@ -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 ""
}