mirror of
https://github.com/gabehf/music-importer.git
synced 2026-04-22 11:31:52 -07:00
i love slop
This commit is contained in:
parent
853f08221f
commit
1d69eeb573
9 changed files with 975 additions and 85 deletions
|
|
@ -1,5 +1,9 @@
|
||||||
# music-importer
|
# 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
|
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
|
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:
|
environment:
|
||||||
IMPORT_DIR: /import
|
IMPORT_DIR: /import
|
||||||
LIBRARY_DIR: /library
|
LIBRARY_DIR: /library
|
||||||
|
COPYMODE: true # copies files instead of moving. NOT NON-DESTRUCTIVE!!
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
77
files.go
77
files.go
|
|
@ -2,22 +2,35 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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 {
|
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 {
|
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
dst := filepath.Join(targetDir, filepath.Base(srcPath))
|
dst := filepath.Join(targetDir, filepath.Base(srcPath))
|
||||||
fmt.Println("→ Moving:", srcPath, "→", dst)
|
fmt.Println("→ Moving:", srcPath, "→", dst)
|
||||||
|
if strings.ToLower(os.Getenv("COPYMODE")) == "true" {
|
||||||
|
return copy(srcPath, dst)
|
||||||
|
} else {
|
||||||
return os.Rename(srcPath, dst)
|
return os.Rename(srcPath, dst)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cluster moves all top-level audio files in dir into subdirectories named
|
// 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)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
163
importer.go
163
importer.go
|
|
@ -5,8 +5,116 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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() {
|
func RunImporter() {
|
||||||
importDir := os.Getenv("IMPORT_DIR")
|
importDir := os.Getenv("IMPORT_DIR")
|
||||||
libraryDir := os.Getenv("LIBRARY_DIR")
|
libraryDir := os.Getenv("LIBRARY_DIR")
|
||||||
|
|
@ -29,6 +137,12 @@ func RunImporter() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session := &ImportSession{StartedAt: time.Now()}
|
||||||
|
defer func() {
|
||||||
|
session.FinishedAt = time.Now()
|
||||||
|
lastSession = session
|
||||||
|
}()
|
||||||
|
|
||||||
fmt.Println("=== Starting Import ===")
|
fmt.Println("=== Starting Import ===")
|
||||||
|
|
||||||
if err := cluster(importDir); err != nil {
|
if err := cluster(importDir); err != nil {
|
||||||
|
|
@ -60,32 +174,62 @@ func RunImporter() {
|
||||||
|
|
||||||
fmt.Println("\n===== Album:", e.Name(), "=====")
|
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:")
|
fmt.Println("→ Cleaning album tags:")
|
||||||
if err = cleanAlbumTags(albumPath); err != nil {
|
result.CleanTags.Err = cleanAlbumTags(albumPath)
|
||||||
fmt.Println("Cleaning album tags failed:", err)
|
if result.CleanTags.Failed() {
|
||||||
|
fmt.Println("Cleaning album tags failed:", result.CleanTags.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("→ Tagging album metadata:")
|
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 {
|
if err != nil {
|
||||||
fmt.Println("Metadata failed, skipping album:", err)
|
fmt.Println("Metadata failed, skipping album:", err)
|
||||||
|
result.skippedAt("TagMetadata")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
result.Metadata = md
|
||||||
|
|
||||||
fmt.Println("→ Fetching synced lyrics from LRCLIB:")
|
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("Failed to download synced lyrics.")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("→ Applying ReplayGain to album:", albumPath)
|
fmt.Println("→ Applying ReplayGain to album:", albumPath)
|
||||||
if err := applyReplayGain(albumPath); err != nil {
|
result.ReplayGain.Err = applyReplayGain(albumPath)
|
||||||
fmt.Println("ReplayGain failed, skipping album:", err)
|
if result.ReplayGain.Failed() {
|
||||||
|
fmt.Println("ReplayGain failed, skipping album:", result.ReplayGain.Err)
|
||||||
|
result.skippedAt("ReplayGain")
|
||||||
continue
|
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)
|
fmt.Println("→ Embedding cover art for album:", albumPath)
|
||||||
if err := EmbedAlbumArtIntoFolder(albumPath); err != nil {
|
result.CoverArt.Err = EmbedAlbumArtIntoFolder(albumPath)
|
||||||
fmt.Println("Cover embed failed, skipping album:", err)
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +237,7 @@ func RunImporter() {
|
||||||
for _, track := range tracks {
|
for _, track := range tracks {
|
||||||
if err := moveToLibrary(libraryDir, md, track); err != nil {
|
if err := moveToLibrary(libraryDir, md, track); err != nil {
|
||||||
fmt.Println("Failed to move track:", track, err)
|
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 {
|
for _, file := range lyrics {
|
||||||
if err := moveToLibrary(libraryDir, md, file); err != nil {
|
if err := moveToLibrary(libraryDir, md, file); err != nil {
|
||||||
fmt.Println("Failed to move lyrics:", file, err)
|
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 coverImg, err := FindCoverImage(albumPath); err == nil {
|
||||||
if err := moveToLibrary(libraryDir, md, coverImg); err != nil {
|
if err := moveToLibrary(libraryDir, md, coverImg); err != nil {
|
||||||
fmt.Println("Failed to cover image:", coverImg, err)
|
fmt.Println("Failed to cover image:", coverImg, err)
|
||||||
|
result.Move.Err = err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
297
index.html.tmpl
Normal file
297
index.html.tmpl
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Music Importer</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
background: #111;
|
||||||
|
color: #eee;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 24px 80px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { margin-bottom: 32px; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-size: 32px;
|
||||||
|
padding: 20px 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
background: #555;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Last run summary ── */
|
||||||
|
.session {
|
||||||
|
margin: 48px auto 0;
|
||||||
|
max-width: 820px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.session-header h2 { margin: 0; font-size: 18px; color: #ccc; }
|
||||||
|
.session-header .duration { font-size: 13px; color: #666; }
|
||||||
|
|
||||||
|
.album {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.album-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.album-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge-ok { background: #1e4d2b; color: #4CAF50; }
|
||||||
|
.badge-warn { background: #4d3a00; color: #f0a500; }
|
||||||
|
.badge-fatal { background: #4d1a1a; color: #e05050; }
|
||||||
|
|
||||||
|
/* ── Metadata row ── */
|
||||||
|
.metadata {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.metadata-title {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.metadata-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: #222;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.pill-label { color: #555; }
|
||||||
|
.pill-beets { color: #7ec8e3; }
|
||||||
|
.pill-musicbrainz { color: #c084fc; }
|
||||||
|
.pill-file_tags { color: #f0a500; }
|
||||||
|
.pill-unknown { color: #888; }
|
||||||
|
|
||||||
|
/* ── Rich info grid ── */
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.info-card {
|
||||||
|
background: #222;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.info-card-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.info-card-value {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.info-card-sub {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.info-ok { color: #4CAF50; }
|
||||||
|
.info-warn { color: #f0a500; }
|
||||||
|
.info-dim { color: #555; }
|
||||||
|
|
||||||
|
/* ── Pipeline steps ── */
|
||||||
|
.steps-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #444;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #222;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.step-label { color: #888; }
|
||||||
|
.step-ok { color: #4CAF50; }
|
||||||
|
.step-warn { color: #f0a500; }
|
||||||
|
.step-fatal { color: #e05050; }
|
||||||
|
.step-err { font-size: 11px; color: #c0392b; margin-top: 2px; word-break: break-word; }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Music Importer</h1>
|
||||||
|
|
||||||
|
<form action="/run" method="POST">
|
||||||
|
<button type="submit" {{if .Running}}disabled{{end}}>
|
||||||
|
{{if .Running}}Importer Running...{{else}}Run Importer{{end}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{with .Session}}
|
||||||
|
<div class="session">
|
||||||
|
<div class="session-header">
|
||||||
|
<h2>Last Run — {{.StartedAt.Format "Jan 2, 2006 15:04:05"}}</h2>
|
||||||
|
<span class="duration">{{duration .StartedAt .FinishedAt}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{range .Albums}}{{$album := .}}
|
||||||
|
<div class="album">
|
||||||
|
<div class="album-header">
|
||||||
|
<span class="album-name" title="{{.Path}}">{{.Name}}</span>
|
||||||
|
{{if .Succeeded}}
|
||||||
|
{{if .HasWarnings}}
|
||||||
|
<span class="badge badge-warn">⚠ warnings</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge badge-ok">✓ ok</span>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<span class="badge badge-fatal">✗ failed at {{.FatalStep}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{with .Metadata}}
|
||||||
|
<div class="metadata">
|
||||||
|
<span class="metadata-title">{{.Artist}} — {{.Album}}{{if .Year}} ({{.Year}}){{end}}</span>
|
||||||
|
{{if $album.MetadataSource}}
|
||||||
|
<span class="metadata-pill">
|
||||||
|
<span class="pill-label">via</span>
|
||||||
|
{{if eq (print $album.MetadataSource) "beets"}}
|
||||||
|
<span class="pill-beets">beets</span>
|
||||||
|
{{else if eq (print $album.MetadataSource) "musicbrainz"}}
|
||||||
|
<span class="pill-musicbrainz">MusicBrainz</span>
|
||||||
|
{{else if eq (print $album.MetadataSource) "file_tags"}}
|
||||||
|
<span class="pill-file_tags">file tags</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="pill-unknown">unknown</span>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* ── Rich info cards ── */}}
|
||||||
|
<div class="info-grid">
|
||||||
|
{{/* Tracks */}}
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-card-label">Tracks</div>
|
||||||
|
<div class="info-card-value">{{.TrackCount}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* Lyrics */}}
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-card-label">Lyrics</div>
|
||||||
|
{{if eq .LyricsStats.Total 0}}
|
||||||
|
<div class="info-card-value info-dim">n/a</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="info-card-value {{if gt .LyricsStats.Downloaded 0}}info-ok{{else}}info-dim{{end}}">
|
||||||
|
{{.LyricsStats.Downloaded}} / {{.LyricsStats.Total}}
|
||||||
|
</div>
|
||||||
|
<div class="info-card-sub">
|
||||||
|
{{if gt .LyricsStats.Synced 0}}<span class="info-ok">{{.LyricsStats.Synced}} synced</span>{{end}}
|
||||||
|
{{if and (gt .LyricsStats.Synced 0) (gt .LyricsStats.Plain 0)}} · {{end}}
|
||||||
|
{{if gt .LyricsStats.Plain 0}}<span class="info-warn">{{.LyricsStats.Plain}} plain</span>{{end}}
|
||||||
|
{{if gt .LyricsStats.AlreadyHad 0}}<span class="info-dim"> {{.LyricsStats.AlreadyHad}} existing</span>{{end}}
|
||||||
|
{{if gt .LyricsStats.NotFound 0}}<span class="info-dim"> {{.LyricsStats.NotFound}} missing</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* Cover art */}}
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-card-label">Cover Art</div>
|
||||||
|
{{if .CoverArtStats.Found}}
|
||||||
|
{{if .CoverArtStats.Embedded}}
|
||||||
|
<div class="info-card-value info-ok">Embedded</div>
|
||||||
|
<div class="info-card-sub info-dim">{{.CoverArtStats.Source}}</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="info-card-value info-warn">Found, not embedded</div>
|
||||||
|
<div class="info-card-sub info-dim">{{.CoverArtStats.Source}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<div class="info-card-value info-dim">Not found</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps-label">Pipeline</div>
|
||||||
|
<div class="steps">
|
||||||
|
{{stepCell "Clean Tags" .CleanTags ""}}
|
||||||
|
{{stepCell "Metadata" .TagMetadata .FatalStep}}
|
||||||
|
{{stepCell "Lyrics" .Lyrics ""}}
|
||||||
|
{{stepCell "ReplayGain" .ReplayGain .FatalStep}}
|
||||||
|
{{stepCell "Cover Art" .CoverArt .FatalStep}}
|
||||||
|
{{stepCell "Move" .Move ""}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<footer>{{.Version}}</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
lrc.go
33
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.
|
// DownloadAlbumLyrics downloads synced lyrics (LRC format) for each track in the album directory.
|
||||||
// Assumes metadata is already final (tags complete).
|
// 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 {
|
err := filepath.Walk(albumDir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -64,10 +65,12 @@ func DownloadAlbumLyrics(albumDir string) error {
|
||||||
if ext != ".mp3" && ext != ".flac" {
|
if ext != ".mp3" && ext != ".flac" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
stats.Total++
|
||||||
|
|
||||||
// Skip if LRC already exists next to the file
|
// Skip if LRC already exists next to the file
|
||||||
lrcPath := strings.TrimSuffix(path, ext) + ".lrc"
|
lrcPath := strings.TrimSuffix(path, ext) + ".lrc"
|
||||||
if _, err := os.Stat(lrcPath); err == nil {
|
if _, err := os.Stat(lrcPath); err == nil {
|
||||||
|
stats.AlreadyHad++
|
||||||
fmt.Println("→ Skipping (already has lyrics):", filepath.Base(path))
|
fmt.Println("→ Skipping (already has lyrics):", filepath.Base(path))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -75,18 +78,21 @@ func DownloadAlbumLyrics(albumDir string) error {
|
||||||
// Read metadata
|
// Read metadata
|
||||||
md, err := readTags(path)
|
md, err := readTags(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
stats.NotFound++
|
||||||
fmt.Println("Skipping (unable to read tags):", path, "error:", err)
|
fmt.Println("Skipping (unable to read tags):", path, "error:", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if md.Title == "" || md.Artist == "" || md.Album == "" {
|
if md.Title == "" || md.Artist == "" || md.Album == "" {
|
||||||
|
stats.NotFound++
|
||||||
fmt.Println("Skipping (missing metadata):", path)
|
fmt.Println("Skipping (missing metadata):", path)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
duration, _ := TrackDuration(path)
|
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 {
|
if err != nil {
|
||||||
|
stats.NotFound++
|
||||||
fmt.Println("No lyrics found:", md.Artist, "-", md.Title)
|
fmt.Println("No lyrics found:", md.Artist, "-", md.Title)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -96,15 +102,20 @@ func DownloadAlbumLyrics(albumDir string) error {
|
||||||
return fmt.Errorf("writing lrc file for %s: %w", path, err)
|
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))
|
fmt.Println("→ Downloaded lyrics:", filepath.Base(lrcPath))
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
return err
|
return stats, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchLRCLibLyrics calls the LRCLIB API and returns synced lyrics if available.
|
// 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(
|
url := fmt.Sprintf(
|
||||||
"https://lrclib.net/api/get?artist_name=%s&track_name=%s&album_name=%s&duration=%d",
|
"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)
|
resp, err := http.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("lrclib fetch error: %w", err)
|
return "", false, fmt.Errorf("lrclib fetch error: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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)
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("reading lrclib response: %w", err)
|
return "", false, fmt.Errorf("reading lrclib response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out LRCLibResponse
|
var out LRCLibResponse
|
||||||
if err := json.Unmarshal(bodyBytes, &out); err != nil {
|
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 != "" {
|
if out.SyncedLyrics != "" {
|
||||||
return out.SyncedLyrics, nil
|
return out.SyncedLyrics, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no syncedLyrics, fallback to plain
|
// If no syncedLyrics, fallback to plain
|
||||||
if out.PlainLyrics != "" {
|
if out.PlainLyrics != "" {
|
||||||
// Convert plain text to a fake LRC wrapper
|
// 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
|
// URL escape helper
|
||||||
|
|
|
||||||
121
main.go
121
main.go
|
|
@ -1,10 +1,13 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"text/template"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version is set at build time via -ldflags="-X main.version=..."
|
// version is set at build time via -ldflags="-X main.version=..."
|
||||||
|
|
@ -13,62 +16,86 @@ var version = "dev"
|
||||||
var importerMu sync.Mutex
|
var importerMu sync.Mutex
|
||||||
var importerRunning bool
|
var importerRunning bool
|
||||||
|
|
||||||
var tmpl = template.Must(template.New("index").Parse(`
|
//go:embed index.html.tmpl
|
||||||
<!DOCTYPE html>
|
var tmplFS embed.FS
|
||||||
<html>
|
var tmpl = template.Must(
|
||||||
<head>
|
template.New("index.html.tmpl").
|
||||||
<title>Music Importer</title>
|
Funcs(template.FuncMap{
|
||||||
<style>
|
// duration formats the elapsed time between two timestamps.
|
||||||
body {
|
"duration": func(start, end time.Time) string {
|
||||||
font-family: sans-serif;
|
if end.IsZero() {
|
||||||
background: #111;
|
return ""
|
||||||
color: #eee;
|
|
||||||
text-align: center;
|
|
||||||
padding-top: 80px;
|
|
||||||
}
|
}
|
||||||
button {
|
d := end.Sub(start).Round(time.Second)
|
||||||
font-size: 32px;
|
if d < time.Minute {
|
||||||
padding: 20px 40px;
|
return d.String()
|
||||||
border-radius: 10px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #4CAF50;
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
button:disabled {
|
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||||
background: #555;
|
},
|
||||||
cursor: not-allowed;
|
// 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 = `<span class="step-err">` + template.HTMLEscapeString(s.Err.Error()) + `</span>`
|
||||||
|
case s.Err != nil:
|
||||||
|
statusClass = "step-warn"
|
||||||
|
statusText = "⚠ error"
|
||||||
|
errHTML = `<span class="step-err">` + template.HTMLEscapeString(s.Err.Error()) + `</span>`
|
||||||
|
case s.Skipped:
|
||||||
|
statusClass = "step-warn"
|
||||||
|
statusText = "– skipped"
|
||||||
|
default:
|
||||||
|
statusClass = "step-ok"
|
||||||
|
statusText = "✓ ok"
|
||||||
}
|
}
|
||||||
footer {
|
return template.HTML(`<div class="step">` +
|
||||||
position: fixed;
|
`<span class="step-label">` + template.HTMLEscapeString(label) + `</span>` +
|
||||||
bottom: 16px;
|
`<span class="` + statusClass + `">` + statusText + `</span>` +
|
||||||
width: 100%;
|
errHTML +
|
||||||
font-size: 13px;
|
`</div>`)
|
||||||
color: #999;
|
},
|
||||||
|
}).
|
||||||
|
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
|
||||||
}
|
}
|
||||||
</style>
|
}
|
||||||
</head>
|
|
||||||
<body>
|
type templateData struct {
|
||||||
<h1>Music Importer</h1>
|
Running bool
|
||||||
<form action="/run" method="POST">
|
Version string
|
||||||
<button type="submit" {{if .Running}}disabled{{end}}>
|
Session *ImportSession
|
||||||
{{if .Running}}Importer Running...{{else}}Run Importer{{end}}
|
}
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<footer>{{.Version}}</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`))
|
|
||||||
|
|
||||||
func handleHome(w http.ResponseWriter, r *http.Request) {
|
func handleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
importerMu.Lock()
|
importerMu.Lock()
|
||||||
running := importerRunning
|
running := importerRunning
|
||||||
importerMu.Unlock()
|
importerMu.Unlock()
|
||||||
|
|
||||||
tmpl.Execute(w, struct {
|
if err := tmpl.Execute(w, templateData{
|
||||||
Running bool
|
Running: running,
|
||||||
Version string
|
Version: version,
|
||||||
}{Running: running, Version: version})
|
Session: lastSession,
|
||||||
|
}); err != nil {
|
||||||
|
log.Println("Template error:", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRun(w http.ResponseWriter, r *http.Request) {
|
func handleRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
101
media.go
101
media.go
|
|
@ -2,7 +2,11 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -53,6 +57,103 @@ func EmbedAlbumArtIntoFolder(albumDir string) error {
|
||||||
return err
|
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
|
// Find cover image
|
||||||
// -------------------------
|
// -------------------------
|
||||||
|
|
|
||||||
247
metadata.go
247
metadata.go
|
|
@ -1,11 +1,15 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -13,7 +17,9 @@ type MusicMetadata struct {
|
||||||
Artist string
|
Artist string
|
||||||
Album string
|
Album string
|
||||||
Title string
|
Title string
|
||||||
Year 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.
|
// Read embedded tags from an audio file using ffprobe.
|
||||||
|
|
@ -39,18 +45,224 @@ func readTags(path string) (*MusicMetadata, error) {
|
||||||
return &MusicMetadata{}, nil
|
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{
|
return &MusicMetadata{
|
||||||
Artist: firstNonEmpty(t["artist"], t["ARTIST"]),
|
Artist: firstNonEmpty(t["artist"], t["ARTIST"]),
|
||||||
Album: firstNonEmpty(t["album"], t["ALBUM"]),
|
Album: firstNonEmpty(t["album"], t["ALBUM"]),
|
||||||
Title: firstNonEmpty(t["title"], t["TITLE"]),
|
Title: firstNonEmpty(t["title"], t["TITLE"]),
|
||||||
Year: firstNonEmpty(t["year"], t["YEAR"], t["ORIGINALYEAR"]),
|
Year: year,
|
||||||
|
Date: date,
|
||||||
}, nil
|
}, 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.
|
// 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 {
|
func tagWithBeets(path string) error {
|
||||||
fmt.Println("→ Tagging with beets:", path)
|
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: "<action> <path>"
|
||||||
|
// 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
|
||||||
|
// <action> <path>
|
||||||
|
// ...
|
||||||
|
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.
|
// 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
|
// getAlbumMetadata attempts beets tagging on the album directory, reads tags
|
||||||
// back from the first track, and falls back to MusicBrainz if tags are missing.
|
// 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)
|
fmt.Println("→ Tagging track with beets:", trackPath)
|
||||||
|
|
||||||
if err := tagWithBeets(albumPath); err != nil {
|
beetsErr := tagWithBeets(albumPath)
|
||||||
fmt.Println("Beets tagging failed; fallback to manual MusicBrainz lookup:", err)
|
if beetsErr != nil {
|
||||||
|
fmt.Println("Beets tagging failed; fallback to manual MusicBrainz lookup:", beetsErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
md, err := readTags(trackPath)
|
md, err := readTags(trackPath)
|
||||||
if err == nil && md.Artist != "" && md.Album != "" {
|
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...")
|
fmt.Println("→ Missing tags, attempting MusicBrainz manual lookup...")
|
||||||
|
|
||||||
md, err = fetchMusicBrainzInfo(trackPath)
|
md, err = fetchMusicBrainzInfo(trackPath)
|
||||||
if err != nil {
|
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 {
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
|
|
||||||
BIN
music-import
Executable file
BIN
music-import
Executable file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue