i love slop

This commit is contained in:
Gabe Farrell 2026-04-04 00:21:03 -04:00
parent 853f08221f
commit 1d69eeb573
9 changed files with 975 additions and 85 deletions

View file

@ -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!!
``` ```

View file

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

View file

@ -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
View 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 &mdash; {{.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">&#9888; warnings</span>
{{else}}
<span class="badge badge-ok">&#10003; ok</span>
{{end}}
{{else}}
<span class="badge badge-fatal">&#10007; failed at {{.FatalStep}}</span>
{{end}}
</div>
{{with .Metadata}}
<div class="metadata">
<span class="metadata-title">{{.Artist}} &mdash; {{.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)}} &middot; {{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
View file

@ -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
View file

@ -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
View file

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

View file

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

Binary file not shown.