diff --git a/README.md b/README.md
index c203fb1..52ac42a 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,9 @@
# music-importer
+Goes through a folder with a bunch of loose .flac/.mp3 files, or with album folders containing music files, then
+fetches metadata with beets/musicbrainz, downloads lyrics via LRClib, embeds discovered cover art, and moves them
+into the library with the format {Artist}/[{Year}] {Title} [{Format-Quality}]
+
this thing is like 95% AI code. use at your own risk
i didn't feel like spending the time to do it all right and i figured its simple enough that chatgpt couldn't possible screw it up *that* bad
@@ -21,6 +25,7 @@ services:
environment:
IMPORT_DIR: /import
LIBRARY_DIR: /library
+ COPYMODE: true # copies files instead of moving. NOT NON-DESTRUCTIVE!!
```
diff --git a/files.go b/files.go
index 5155997..943aebd 100644
--- a/files.go
+++ b/files.go
@@ -2,22 +2,35 @@ package main
import (
"fmt"
+ "io"
"os"
"path"
"path/filepath"
"strings"
)
-// moveToLibrary moves a file to {libDir}/{artist}/[{year}] {album}/filename.
+// moveToLibrary moves a file to {libDir}/{artist}/[{date}] {album} [{quality}]/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)))
+ date := md.Date
+ if date == "" {
+ date = md.Year
+ }
+ albumDir := fmt.Sprintf("[%s] %s", date, md.Album)
+ if md.Quality != "" {
+ albumDir += fmt.Sprintf(" [%s]", md.Quality)
+ }
+ targetDir := filepath.Join(libDir, sanitize(md.Artist), sanitize(albumDir))
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)
+ if strings.ToLower(os.Getenv("COPYMODE")) == "true" {
+ return copy(srcPath, dst)
+ } else {
+ return os.Rename(srcPath, dst)
+ }
}
// cluster moves all top-level audio files in dir into subdirectories named
@@ -101,3 +114,63 @@ func sanitize(s string) string {
)
return r.Replace(s)
}
+
+// CopyFile copies a file from src to dst. If src and dst files exist, and are
+// the same, then return success. Otherise, attempt to create a hard link
+// between the two files. If that fail, copy the file contents from src to dst.
+func copy(src, dst string) (err error) {
+ sfi, err := os.Stat(src)
+ if err != nil {
+ return
+ }
+ if !sfi.Mode().IsRegular() {
+ // cannot copy non-regular files (e.g., directories,
+ // symlinks, devices, etc.)
+ return fmt.Errorf("CopyFile: non-regular source file %s (%q)", sfi.Name(), sfi.Mode().String())
+ }
+ dfi, err := os.Stat(dst)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ return
+ }
+ } else {
+ if !(dfi.Mode().IsRegular()) {
+ return fmt.Errorf("CopyFile: non-regular destination file %s (%q)", dfi.Name(), dfi.Mode().String())
+ }
+ if os.SameFile(sfi, dfi) {
+ return
+ }
+ }
+ if err = os.Link(src, dst); err == nil {
+ return
+ }
+ err = copyFileContents(src, dst)
+ return
+}
+
+// copyFileContents copies the contents of the file named src to the file named
+// by dst. The file will be created if it does not already exist. If the
+// destination file exists, all it's contents will be replaced by the contents
+// of the source file.
+func copyFileContents(src, dst string) (err error) {
+ in, err := os.Open(src)
+ if err != nil {
+ return
+ }
+ defer in.Close()
+ out, err := os.Create(dst)
+ if err != nil {
+ return
+ }
+ defer func() {
+ cerr := out.Close()
+ if err == nil {
+ err = cerr
+ }
+ }()
+ if _, err = io.Copy(out, in); err != nil {
+ return
+ }
+ err = out.Sync()
+ return
+}
diff --git a/importer.go b/importer.go
index ac449b5..8559fcb 100644
--- a/importer.go
+++ b/importer.go
@@ -5,8 +5,116 @@ import (
"log"
"os"
"path/filepath"
+ "time"
)
+// StepStatus records the outcome of a single pipeline step for an album.
+type StepStatus struct {
+ Skipped bool
+ Err error
+}
+
+func (s StepStatus) Failed() bool { return s.Err != nil }
+
+// MetadataSource identifies which backend resolved the album metadata.
+type MetadataSource string
+
+const (
+ MetadataSourceBeets MetadataSource = "beets"
+ MetadataSourceMusicBrainz MetadataSource = "musicbrainz"
+ MetadataSourceFileTags MetadataSource = "file_tags"
+ MetadataSourceUnknown MetadataSource = ""
+)
+
+// LyricsStats summarises per-track lyric discovery for an album.
+type LyricsStats struct {
+ Total int // total audio tracks examined
+ Synced int // tracks with synced (timestamped) LRC lyrics downloaded
+ Plain int // tracks with plain (un-timestamped) lyrics downloaded
+ AlreadyHad int // tracks that already had an .lrc file, skipped
+ NotFound int // tracks for which no lyrics could be found
+}
+
+func (l LyricsStats) Downloaded() int { return l.Synced + l.Plain }
+
+// CoverArtStats records what happened with cover art for an album.
+type CoverArtStats struct {
+ Found bool // a cover image file was found in the folder
+ Embedded bool // cover was successfully embedded into tracks
+ Source string // filename of the cover image, e.g. "cover.jpg"
+}
+
+// AlbumResult holds the outcome of every pipeline step for one imported album.
+type AlbumResult struct {
+ Name string
+ Path string
+ Metadata *MusicMetadata
+
+ MetadataSource MetadataSource
+ LyricsStats LyricsStats
+ CoverArtStats CoverArtStats
+ TrackCount int
+
+ CleanTags StepStatus
+ TagMetadata StepStatus
+ Lyrics StepStatus
+ ReplayGain StepStatus
+ CoverArt StepStatus
+ Move StepStatus
+
+ // FatalStep is the name of the step that caused the album to be skipped
+ // entirely, or empty if the album completed the full pipeline.
+ FatalStep string
+}
+
+func (a *AlbumResult) skippedAt(step string) {
+ a.FatalStep = step
+}
+
+func (a *AlbumResult) Succeeded() bool { return a.FatalStep == "" }
+func (a *AlbumResult) HasWarnings() bool {
+ if a.CleanTags.Failed() ||
+ a.TagMetadata.Failed() ||
+ a.Lyrics.Failed() ||
+ a.ReplayGain.Failed() ||
+ a.CoverArt.Failed() ||
+ a.Move.Failed() {
+ return true
+ } else {
+ return false
+ }
+}
+
+// ImportSession holds the results of a single importer run.
+type ImportSession struct {
+ StartedAt time.Time
+ FinishedAt time.Time
+ Albums []*AlbumResult
+}
+
+func (s *ImportSession) Failed() []*AlbumResult {
+ var out []*AlbumResult
+ for _, a := range s.Albums {
+ if !a.Succeeded() {
+ out = append(out, a)
+ }
+ }
+ return out
+}
+
+func (s *ImportSession) WithWarnings() []*AlbumResult {
+ var out []*AlbumResult
+ for _, a := range s.Albums {
+ if a.Succeeded() && a.HasWarnings() {
+ out = append(out, a)
+ }
+ }
+ return out
+}
+
+// lastSession is populated at the end of each RunImporter call.
+var lastSession *ImportSession
+
func RunImporter() {
importDir := os.Getenv("IMPORT_DIR")
libraryDir := os.Getenv("LIBRARY_DIR")
@@ -29,6 +137,12 @@ func RunImporter() {
return
}
+ session := &ImportSession{StartedAt: time.Now()}
+ defer func() {
+ session.FinishedAt = time.Now()
+ lastSession = session
+ }()
+
fmt.Println("=== Starting Import ===")
if err := cluster(importDir); err != nil {
@@ -60,32 +174,62 @@ func RunImporter() {
fmt.Println("\n===== Album:", e.Name(), "=====")
+ result := &AlbumResult{Name: e.Name(), Path: albumPath}
+ session.Albums = append(session.Albums, result)
+ result.TrackCount = len(tracks)
+
fmt.Println("→ Cleaning album tags:")
- if err = cleanAlbumTags(albumPath); err != nil {
- fmt.Println("Cleaning album tags failed:", err)
+ result.CleanTags.Err = cleanAlbumTags(albumPath)
+ if result.CleanTags.Failed() {
+ fmt.Println("Cleaning album tags failed:", result.CleanTags.Err)
}
fmt.Println("→ Tagging album metadata:")
- md, err := getAlbumMetadata(albumPath, tracks[0])
+ md, src, err := getAlbumMetadata(albumPath, tracks[0])
+ result.TagMetadata.Err = err
+ result.MetadataSource = src
if err != nil {
fmt.Println("Metadata failed, skipping album:", err)
+ result.skippedAt("TagMetadata")
continue
}
+ result.Metadata = md
fmt.Println("→ Fetching synced lyrics from LRCLIB:")
- if err := DownloadAlbumLyrics(albumPath); err != nil {
+ lyricsStats, err := DownloadAlbumLyrics(albumPath)
+ result.Lyrics.Err = err
+ result.LyricsStats = lyricsStats
+ if result.Lyrics.Failed() {
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)
+ result.ReplayGain.Err = applyReplayGain(albumPath)
+ if result.ReplayGain.Failed() {
+ fmt.Println("ReplayGain failed, skipping album:", result.ReplayGain.Err)
+ result.skippedAt("ReplayGain")
continue
}
+ fmt.Println("→ Downloading cover art for album:", albumPath)
+ if _, err := FindCoverImage(albumPath); err != nil {
+ if err := DownloadCoverArt(albumPath, md); err != nil {
+ fmt.Println("Cover art download failed:", err)
+ }
+ }
+
fmt.Println("→ Embedding cover art for album:", albumPath)
- if err := EmbedAlbumArtIntoFolder(albumPath); err != nil {
- fmt.Println("Cover embed failed, skipping album:", err)
+ result.CoverArt.Err = EmbedAlbumArtIntoFolder(albumPath)
+ if coverImg, err := FindCoverImage(albumPath); err == nil {
+ result.CoverArtStats.Found = true
+ result.CoverArtStats.Source = filepath.Base(coverImg)
+ if result.CoverArt.Err == nil {
+ result.CoverArtStats.Embedded = true
+ }
+ }
+ if result.CoverArt.Failed() {
+ fmt.Println("Cover embed failed, skipping album:", result.CoverArt.Err)
+ result.skippedAt("CoverArt")
continue
}
@@ -93,6 +237,7 @@ func RunImporter() {
for _, track := range tracks {
if err := moveToLibrary(libraryDir, md, track); err != nil {
fmt.Println("Failed to move track:", track, err)
+ result.Move.Err = err // retains last error; all attempts are still made
}
}
@@ -102,6 +247,7 @@ func RunImporter() {
for _, file := range lyrics {
if err := moveToLibrary(libraryDir, md, file); err != nil {
fmt.Println("Failed to move lyrics:", file, err)
+ result.Move.Err = err
}
}
@@ -109,6 +255,7 @@ func RunImporter() {
if coverImg, err := FindCoverImage(albumPath); err == nil {
if err := moveToLibrary(libraryDir, md, coverImg); err != nil {
fmt.Println("Failed to cover image:", coverImg, err)
+ result.Move.Err = err
}
}
diff --git a/index.html.tmpl b/index.html.tmpl
new file mode 100644
index 0000000..711a298
--- /dev/null
+++ b/index.html.tmpl
@@ -0,0 +1,297 @@
+
+
+
+ Music Importer
+
+
+
+ Music Importer
+
+
+
+ {{with .Session}}
+
+
+
+ {{range .Albums}}{{$album := .}}
+
+
+
+ {{with .Metadata}}
+
+ {{.Artist}} — {{.Album}}{{if .Year}} ({{.Year}}){{end}}
+ {{if $album.MetadataSource}}
+
+ via
+ {{if eq (print $album.MetadataSource) "beets"}}
+ beets
+ {{else if eq (print $album.MetadataSource) "musicbrainz"}}
+ MusicBrainz
+ {{else if eq (print $album.MetadataSource) "file_tags"}}
+ file tags
+ {{else}}
+ unknown
+ {{end}}
+
+ {{end}}
+
+ {{end}}
+
+ {{/* ── Rich info cards ── */}}
+
+ {{/* Tracks */}}
+
+
Tracks
+
{{.TrackCount}}
+
+
+ {{/* Lyrics */}}
+
+
Lyrics
+ {{if eq .LyricsStats.Total 0}}
+
n/a
+ {{else}}
+
+ {{.LyricsStats.Downloaded}} / {{.LyricsStats.Total}}
+
+
+ {{if gt .LyricsStats.Synced 0}}{{.LyricsStats.Synced}} synced{{end}}
+ {{if and (gt .LyricsStats.Synced 0) (gt .LyricsStats.Plain 0)}} · {{end}}
+ {{if gt .LyricsStats.Plain 0}}{{.LyricsStats.Plain}} plain{{end}}
+ {{if gt .LyricsStats.AlreadyHad 0}} {{.LyricsStats.AlreadyHad}} existing{{end}}
+ {{if gt .LyricsStats.NotFound 0}} {{.LyricsStats.NotFound}} missing{{end}}
+
+ {{end}}
+
+
+ {{/* Cover art */}}
+
+
Cover Art
+ {{if .CoverArtStats.Found}}
+ {{if .CoverArtStats.Embedded}}
+
Embedded
+
{{.CoverArtStats.Source}}
+ {{else}}
+
Found, not embedded
+
{{.CoverArtStats.Source}}
+ {{end}}
+ {{else}}
+
Not found
+ {{end}}
+
+
+
+
Pipeline
+
+ {{stepCell "Clean Tags" .CleanTags ""}}
+ {{stepCell "Metadata" .TagMetadata .FatalStep}}
+ {{stepCell "Lyrics" .Lyrics ""}}
+ {{stepCell "ReplayGain" .ReplayGain .FatalStep}}
+ {{stepCell "Cover Art" .CoverArt .FatalStep}}
+ {{stepCell "Move" .Move ""}}
+
+
+ {{end}}
+
+ {{end}}
+
+
+
+
diff --git a/lrc.go b/lrc.go
index 94b1233..bdfae86 100644
--- a/lrc.go
+++ b/lrc.go
@@ -51,7 +51,8 @@ func TrackDuration(path string) (int, error) {
// DownloadAlbumLyrics downloads synced lyrics (LRC format) for each track in the album directory.
// Assumes metadata is already final (tags complete).
-func DownloadAlbumLyrics(albumDir string) error {
+func DownloadAlbumLyrics(albumDir string) (LyricsStats, error) {
+ var stats LyricsStats
err := filepath.Walk(albumDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
@@ -64,10 +65,12 @@ func DownloadAlbumLyrics(albumDir string) error {
if ext != ".mp3" && ext != ".flac" {
return nil
}
+ stats.Total++
// Skip if LRC already exists next to the file
lrcPath := strings.TrimSuffix(path, ext) + ".lrc"
if _, err := os.Stat(lrcPath); err == nil {
+ stats.AlreadyHad++
fmt.Println("→ Skipping (already has lyrics):", filepath.Base(path))
return nil
}
@@ -75,18 +78,21 @@ func DownloadAlbumLyrics(albumDir string) error {
// Read metadata
md, err := readTags(path)
if err != nil {
+ stats.NotFound++
fmt.Println("Skipping (unable to read tags):", path, "error:", err)
return nil
}
if md.Title == "" || md.Artist == "" || md.Album == "" {
+ stats.NotFound++
fmt.Println("Skipping (missing metadata):", path)
return nil
}
duration, _ := TrackDuration(path)
- lyrics, err := fetchLRCLibLyrics(md.Artist, md.Title, md.Album, duration)
+ lyrics, synced, err := fetchLRCLibLyrics(md.Artist, md.Title, md.Album, duration)
if err != nil {
+ stats.NotFound++
fmt.Println("No lyrics found:", md.Artist, "-", md.Title)
return nil
}
@@ -96,15 +102,20 @@ func DownloadAlbumLyrics(albumDir string) error {
return fmt.Errorf("writing lrc file for %s: %w", path, err)
}
+ if synced {
+ stats.Synced++
+ } else {
+ stats.Plain++
+ }
fmt.Println("→ Downloaded lyrics:", filepath.Base(lrcPath))
return nil
})
- return err
+ return stats, err
}
// fetchLRCLibLyrics calls the LRCLIB API and returns synced lyrics if available.
-func fetchLRCLibLyrics(artist, title, album string, duration int) (string, error) {
+func fetchLRCLibLyrics(artist, title, album string, duration int) (string, bool, error) {
url := fmt.Sprintf(
"https://lrclib.net/api/get?artist_name=%s&track_name=%s&album_name=%s&duration=%d",
@@ -113,35 +124,35 @@ func fetchLRCLibLyrics(artist, title, album string, duration int) (string, error
resp, err := http.Get(url)
if err != nil {
- return "", fmt.Errorf("lrclib fetch error: %w", err)
+ return "", false, fmt.Errorf("lrclib fetch error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("lrclib returned status %d", resp.StatusCode)
+ return "", false, fmt.Errorf("lrclib returned status %d", resp.StatusCode)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
- return "", fmt.Errorf("reading lrclib response: %w", err)
+ return "", false, fmt.Errorf("reading lrclib response: %w", err)
}
var out LRCLibResponse
if err := json.Unmarshal(bodyBytes, &out); err != nil {
- return "", fmt.Errorf("parsing lrclib json: %w", err)
+ return "", false, fmt.Errorf("parsing lrclib json: %w", err)
}
if out.SyncedLyrics != "" {
- return out.SyncedLyrics, nil
+ return out.SyncedLyrics, true, nil
}
// If no syncedLyrics, fallback to plain
if out.PlainLyrics != "" {
// Convert plain text to a fake LRC wrapper
- return plainToLRC(out.PlainLyrics), nil
+ return plainToLRC(out.PlainLyrics), false, nil
}
- return "", fmt.Errorf("no lyrics found")
+ return "", false, fmt.Errorf("no lyrics found")
}
// URL escape helper
diff --git a/main.go b/main.go
index a8f2cb5..725dd33 100644
--- a/main.go
+++ b/main.go
@@ -1,10 +1,13 @@
package main
import (
+ "embed"
+ "fmt"
+ "html/template"
"log"
"net/http"
"sync"
- "text/template"
+ "time"
)
// version is set at build time via -ldflags="-X main.version=..."
@@ -13,62 +16,86 @@ var version = "dev"
var importerMu sync.Mutex
var importerRunning bool
-var tmpl = template.Must(template.New("index").Parse(`
-
-
-
- Music Importer
-
-
-
- Music Importer
-
-
-
-
-`))
+//go:embed index.html.tmpl
+var tmplFS embed.FS
+var tmpl = template.Must(
+ template.New("index.html.tmpl").
+ Funcs(template.FuncMap{
+ // duration formats the elapsed time between two timestamps.
+ "duration": func(start, end time.Time) string {
+ if end.IsZero() {
+ return ""
+ }
+ d := end.Sub(start).Round(time.Second)
+ if d < time.Minute {
+ return d.String()
+ }
+ return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
+ },
+ // not is needed because Go templates have no built-in boolean negation.
+ "not": func(b bool) bool { return !b },
+ // stepCell renders a uniform step status cell.
+ // fatalStep is AlbumResult.FatalStep; when it matches the step's key
+ // the cell is marked fatal rather than a warning.
+ "stepCell": func(label string, s StepStatus, fatalStep string) template.HTML {
+ var statusClass, statusText, errHTML string
+ switch {
+ case s.Err != nil && fatalStep != "" && stepKey(label) == fatalStep:
+ statusClass = "step-fatal"
+ statusText = "✗ fatal"
+ errHTML = `` + template.HTMLEscapeString(s.Err.Error()) + ``
+ case s.Err != nil:
+ statusClass = "step-warn"
+ statusText = "⚠ error"
+ errHTML = `` + template.HTMLEscapeString(s.Err.Error()) + ``
+ case s.Skipped:
+ statusClass = "step-warn"
+ statusText = "– skipped"
+ default:
+ statusClass = "step-ok"
+ statusText = "✓ ok"
+ }
+ return template.HTML(`` +
+ `` + template.HTMLEscapeString(label) + `` +
+ `` + statusText + `` +
+ errHTML +
+ `
`)
+ },
+ }).
+ ParseFS(tmplFS, "index.html.tmpl"),
+)
+
+// stepKey maps a human-readable step label to the FatalStep identifier used in
+// AlbumResult so the template can highlight the step that caused the abort.
+func stepKey(label string) string {
+ switch label {
+ case "Metadata":
+ return "TagMetadata"
+ case "Cover Art":
+ return "CoverArt"
+ default:
+ return label
+ }
+}
+
+type templateData struct {
+ Running bool
+ Version string
+ Session *ImportSession
+}
func handleHome(w http.ResponseWriter, r *http.Request) {
importerMu.Lock()
running := importerRunning
importerMu.Unlock()
- tmpl.Execute(w, struct {
- Running bool
- Version string
- }{Running: running, Version: version})
+ if err := tmpl.Execute(w, templateData{
+ Running: running,
+ Version: version,
+ Session: lastSession,
+ }); err != nil {
+ log.Println("Template error:", err)
+ }
}
func handleRun(w http.ResponseWriter, r *http.Request) {
diff --git a/media.go b/media.go
index 60711a2..555b8e7 100644
--- a/media.go
+++ b/media.go
@@ -2,7 +2,11 @@ package main
import (
"bytes"
+ "encoding/json"
"fmt"
+ "io"
+ "net/http"
+ "net/url"
"os"
"os/exec"
"path/filepath"
@@ -53,6 +57,103 @@ func EmbedAlbumArtIntoFolder(albumDir string) error {
return err
}
+// DownloadCoverArt searches MusicBrainz for a release matching md's artist and
+// album, then downloads the front cover from the Cover Art Archive and saves it
+// as cover.jpg inside albumDir. Returns an error if no cover could be found or
+// downloaded.
+func DownloadCoverArt(albumDir string, md *MusicMetadata) error {
+ mbid, err := searchMusicBrainzRelease(md.Artist, md.Album)
+ if err != nil {
+ return fmt.Errorf("MusicBrainz release search failed: %w", err)
+ }
+
+ data, ext, err := fetchCoverArtArchiveFront(mbid)
+ if err != nil {
+ return fmt.Errorf("Cover Art Archive fetch failed: %w", err)
+ }
+
+ dest := filepath.Join(albumDir, "cover."+ext)
+ if err := os.WriteFile(dest, data, 0644); err != nil {
+ return fmt.Errorf("writing cover image: %w", err)
+ }
+
+ fmt.Println("→ Downloaded cover art:", filepath.Base(dest))
+ return nil
+}
+
+// searchMusicBrainzRelease queries the MusicBrainz API for a release matching
+// the given artist and album and returns its MBID.
+func searchMusicBrainzRelease(artist, album string) (string, error) {
+ q := fmt.Sprintf(`release:"%s" AND artist:"%s"`,
+ strings.ReplaceAll(album, `"`, `\"`),
+ strings.ReplaceAll(artist, `"`, `\"`),
+ )
+ apiURL := "https://musicbrainz.org/ws/2/release/?query=" + url.QueryEscape(q) + "&fmt=json&limit=1"
+
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("User-Agent", "music-importer/1.0 (https://github.com/example/music-importer)")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("MusicBrainz returned status %d", resp.StatusCode)
+ }
+
+ var result struct {
+ Releases []struct {
+ ID string `json:"id"`
+ } `json:"releases"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", err
+ }
+ if len(result.Releases) == 0 {
+ return "", fmt.Errorf("no MusicBrainz release found for %q by %q", album, artist)
+ }
+ return result.Releases[0].ID, nil
+}
+
+// fetchCoverArtArchiveFront fetches the front cover image for the given
+// MusicBrainz release MBID from coverartarchive.org. It follows the 307
+// redirect to the actual image and returns the raw bytes plus the file
+// extension (e.g. "jpg" or "png").
+func fetchCoverArtArchiveFront(mbid string) ([]byte, string, error) {
+ apiURL := "https://coverartarchive.org/release/" + mbid + "/front"
+
+ resp, err := http.Get(apiURL)
+ if err != nil {
+ return nil, "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, "", fmt.Errorf("Cover Art Archive returned status %d for MBID %s", resp.StatusCode, mbid)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, "", err
+ }
+
+ // Derive the extension from the final URL after redirect, falling back to
+ // sniffing the magic bytes.
+ ext := "jpg"
+ if finalURL := resp.Request.URL.String(); strings.HasSuffix(strings.ToLower(finalURL), ".png") {
+ ext = "png"
+ } else if bytes.HasPrefix(data, []byte{0x89, 0x50, 0x4E, 0x47}) {
+ ext = "png"
+ }
+
+ return data, ext, nil
+}
+
// -------------------------
// Find cover image
// -------------------------
diff --git a/metadata.go b/metadata.go
index 0861567..48332d4 100644
--- a/metadata.go
+++ b/metadata.go
@@ -1,19 +1,25 @@
package main
import (
+ "bufio"
"encoding/json"
"errors"
"fmt"
+ "math"
+ "os"
"os/exec"
"path/filepath"
+ "strconv"
"strings"
)
type MusicMetadata struct {
- Artist string
- Album string
- Title string
- Year string
+ Artist string
+ Album string
+ Title string
+ Year string // four-digit year, kept for backward compat
+ Date string // normalised as YYYY.MM.DD (or YYYY.MM or YYYY)
+ Quality string // e.g. "FLAC-24bit-96kHz" or "MP3-320kbps"
}
// Read embedded tags from an audio file using ffprobe.
@@ -39,18 +45,224 @@ func readTags(path string) (*MusicMetadata, error) {
return &MusicMetadata{}, nil
}
+ rawDate := firstNonEmpty(t["date"], t["DATE"], t["year"], t["YEAR"], t["ORIGINALYEAR"])
+ date := parseDate(rawDate)
+ year := ""
+ if len(date) >= 4 {
+ year = date[:4]
+ }
+
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"]),
+ Year: year,
+ Date: date,
}, nil
}
+// parseDate normalises a raw DATE/date tag value into YYYY.MM.DD (or YYYY.MM
+// or YYYY) dot-separated format, or returns the input unchanged if it cannot
+// be recognised.
+//
+// Supported input formats:
+// - YYYY
+// - YYYY-MM
+// - YYYY-MM-DD
+// - YYYYMMDD
+func parseDate(raw string) string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return ""
+ }
+
+ // YYYYMMDD (exactly 8 digits, no separators)
+ if len(raw) == 8 && isAllDigits(raw) {
+ return raw[0:4] + "." + raw[4:6] + "." + raw[6:8]
+ }
+
+ // YYYY-MM-DD, YYYY-MM, or YYYY (with dashes)
+ parts := strings.Split(raw, "-")
+ switch len(parts) {
+ case 1:
+ if len(parts[0]) == 4 && isAllDigits(parts[0]) {
+ return parts[0]
+ }
+ case 2:
+ if len(parts[0]) == 4 && isAllDigits(parts[0]) && len(parts[1]) == 2 && isAllDigits(parts[1]) {
+ return parts[0] + "." + parts[1]
+ }
+ case 3:
+ if len(parts[0]) == 4 && isAllDigits(parts[0]) &&
+ len(parts[1]) == 2 && isAllDigits(parts[1]) &&
+ len(parts[2]) == 2 && isAllDigits(parts[2]) {
+ return parts[0] + "." + parts[1] + "." + parts[2]
+ }
+ }
+
+ // Unrecognised — return as-is so we don't silently drop it.
+ return raw
+}
+
+func isAllDigits(s string) bool {
+ for _, c := range s {
+ if c < '0' || c > '9' {
+ return false
+ }
+ }
+ return len(s) > 0
+}
+
+// readAudioQuality probes the first audio stream of path and returns a
+// quality label such as "FLAC-24bit-96kHz" or "MP3-320kbps".
+func readAudioQuality(path string) (string, error) {
+ out, err := exec.Command(
+ "ffprobe", "-v", "quiet", "-print_format", "json",
+ "-show_streams", "-select_streams", "a:0",
+ path,
+ ).Output()
+ if err != nil {
+ return "", err
+ }
+
+ var data struct {
+ Streams []struct {
+ CodecName string `json:"codec_name"`
+ SampleRate string `json:"sample_rate"`
+ BitRate string `json:"bit_rate"`
+ BitsPerRawSample string `json:"bits_per_raw_sample"`
+ } `json:"streams"`
+ }
+
+ if err := json.Unmarshal(out, &data); err != nil {
+ return "", err
+ }
+ if len(data.Streams) == 0 {
+ return "", fmt.Errorf("no audio streams found in %s", path)
+ }
+
+ s := data.Streams[0]
+ codec := strings.ToUpper(s.CodecName) // e.g. "FLAC", "MP3"
+
+ switch strings.ToLower(s.CodecName) {
+ case "flac":
+ bits := s.BitsPerRawSample
+ if bits == "" || bits == "0" {
+ bits = "16" // safe fallback
+ }
+ khz := sampleRateToKHz(s.SampleRate)
+ return fmt.Sprintf("%s-%sbit-%s", codec, bits, khz), nil
+
+ case "mp3":
+ kbps := snapMP3Bitrate(s.BitRate)
+ return fmt.Sprintf("%s-%dkbps", codec, kbps), nil
+
+ default:
+ // Generic fallback: codec + bitrate if available.
+ if s.BitRate != "" && s.BitRate != "0" {
+ kbps := snapMP3Bitrate(s.BitRate)
+ return fmt.Sprintf("%s-%dkbps", codec, kbps), nil
+ }
+ return codec, nil
+ }
+}
+
+// sampleRateToKHz converts a sample-rate string in Hz (e.g. "44100") to a
+// human-friendly kHz string (e.g. "44.1kHz").
+func sampleRateToKHz(hz string) string {
+ n, err := strconv.Atoi(strings.TrimSpace(hz))
+ if err != nil || n == 0 {
+ return "?kHz"
+ }
+ if n%1000 == 0 {
+ return fmt.Sprintf("%dkHz", n/1000)
+ }
+ return fmt.Sprintf("%.1fkHz", float64(n)/1000.0)
+}
+
+// commonMP3Bitrates lists the standard MPEG audio bitrates in kbps.
+var commonMP3Bitrates = []int{32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320}
+
+// snapMP3Bitrate rounds a raw bitrate string (in bps) to the nearest standard
+// MP3 bitrate (in kbps). For example "318731" → 320.
+func snapMP3Bitrate(bpsStr string) int {
+ bps, err := strconv.Atoi(strings.TrimSpace(bpsStr))
+ if err != nil || bps <= 0 {
+ return 128 // safe fallback
+ }
+ kbps := float64(bps) / 1000.0
+ best := commonMP3Bitrates[0]
+ bestDiff := math.Abs(kbps - float64(best))
+ for _, candidate := range commonMP3Bitrates[1:] {
+ if d := math.Abs(kbps - float64(candidate)); d < bestDiff {
+ bestDiff = d
+ best = candidate
+ }
+ }
+ return best
+}
+
// Use beets to fetch metadata and tag all files in a directory.
+// A temp log file is passed to beets via -l so that skipped albums
+// (which exit 0 but produce a "skip" log entry) are detected and
+// returned as errors, triggering the MusicBrainz fallback.
func tagWithBeets(path string) error {
fmt.Println("→ Tagging with beets:", path)
- return runCmd("beet", "import", "-Cq", path)
+
+ logFile, err := os.CreateTemp("", "beets-log-*.txt")
+ if err != nil {
+ return fmt.Errorf("beets: could not create temp log file: %w", err)
+ }
+ logPath := logFile.Name()
+ logFile.Close()
+ defer os.Remove(logPath)
+
+ if err := runCmd("beet", "import", "-Cq", "-l", logPath, path); err != nil {
+ return err
+ }
+
+ // Even on exit 0, beets may have skipped the album in quiet mode.
+ // The log format is one entry per line: " "
+ // We treat any "skip" line as a failure so the caller falls through
+ // to the MusicBrainz lookup.
+ skipped, err := beetsLogHasSkip(logPath)
+ if err != nil {
+ // If we can't read the log, assume beets succeeded.
+ fmt.Println("beets: could not read log file:", err)
+ return nil
+ }
+ if skipped {
+ return errors.New("beets skipped album (no confident match found)")
+ }
+ return nil
+}
+
+// beetsLogHasSkip reads a beets import log file and reports whether any
+// entry has the action "skip". The log format is:
+//
+// # beets import log
+//
+// ...
+func beetsLogHasSkip(logPath string) (bool, error) {
+ f, err := os.Open(logPath)
+ if err != nil {
+ return false, err
+ }
+ defer f.Close()
+
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ // Skip blank lines and the header comment.
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ action, _, found := strings.Cut(line, " ")
+ if found && strings.EqualFold(action, "skip") {
+ return true, nil
+ }
+ }
+ return false, scanner.Err()
}
// Fallback: query MusicBrainz API manually if beets fails.
@@ -99,26 +311,43 @@ func fetchMusicBrainzInfo(filename string) (*MusicMetadata, error) {
// 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) {
+func getAlbumMetadata(albumPath, trackPath string) (*MusicMetadata, MetadataSource, 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)
+ beetsErr := tagWithBeets(albumPath)
+ if beetsErr != nil {
+ fmt.Println("Beets tagging failed; fallback to manual MusicBrainz lookup:", beetsErr)
}
md, err := readTags(trackPath)
if err == nil && md.Artist != "" && md.Album != "" {
- return md, nil
+ attachQuality(md, trackPath)
+ if beetsErr == nil {
+ return md, MetadataSourceBeets, nil
+ }
+ return md, MetadataSourceFileTags, 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 nil, MetadataSourceUnknown, fmt.Errorf("metadata lookup failed: %w", err)
}
- return md, nil
+ attachQuality(md, trackPath)
+ return md, MetadataSourceMusicBrainz, nil
+}
+
+// attachQuality probes trackPath for audio quality and sets md.Quality.
+// Errors are logged but not returned — a missing quality label is non-fatal.
+func attachQuality(md *MusicMetadata, trackPath string) {
+ q, err := readAudioQuality(trackPath)
+ if err != nil {
+ fmt.Println("Could not determine audio quality:", err)
+ return
+ }
+ md.Quality = q
}
func firstNonEmpty(vals ...string) string {
diff --git a/music-import b/music-import
new file mode 100755
index 0000000..ed81a22
Binary files /dev/null and b/music-import differ