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

Last Run — {{.StartedAt.Format "Jan 2, 2006 15:04:05"}}

+ {{duration .StartedAt .FinishedAt}} +
+ + {{range .Albums}}{{$album := .}} +
+
+ {{.Name}} + {{if .Succeeded}} + {{if .HasWarnings}} + ⚠ warnings + {{else}} + ✓ ok + {{end}} + {{else}} + ✗ failed at {{.FatalStep}} + {{end}} +
+ + {{with .Metadata}} + + {{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