diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e7be5d2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Build +go build -o importer . + +# Build with version baked in +go build -ldflags="-X main.version=v1.0.0" -o importer . + +# Run locally (requires IMPORT_DIR and LIBRARY_DIR env vars) +IMPORT_DIR=/path/to/import LIBRARY_DIR=/path/to/library ./importer + +# Build Docker image +docker build -t music-importer . + +# Build Docker image with version +docker build --build-arg VERSION=v1.0.0 -t music-importer . +``` + +There are no tests in this codebase. + +## Architecture + +This is a single-package Go web app (`package main`) that runs as a web server on port 8080. Users trigger an import via the web UI, which runs the import pipeline in a background goroutine. + +**Pipeline flow** (`importer.go: RunImporter`): +1. **Cluster** — loose audio files at the top of `IMPORT_DIR` are grouped into subdirectories by album tag (`files.go: cluster`) +2. For each album directory: + - **Clean tags** — removes COMMENT/DESCRIPTION tags via `metaflac` (`audio.go`) + - **Tag metadata** — tries `beets` first; falls back to reading existing file tags, then MusicBrainz API (`metadata.go: getAlbumMetadata`) + - **Lyrics** — fetches synced LRC lyrics from LRClib API; falls back to plain lyrics formatted as LRC (`lrc.go`) + - **ReplayGain** — runs `rsgain easy` on the directory (`audio.go`) + - **Cover art** — looks for existing image files, downloads from Cover Art Archive via MusicBrainz if missing, then embeds into tracks (`media.go`) + - **Move** — moves tracks, .lrc files, and cover image into `LIBRARY_DIR/{Artist}/[{Date}] {Album} [{Quality}]/` (`files.go: moveToLibrary`) + +**Key types** (`importer.go`): +- `AlbumResult` — tracks per-step success/failure/skip for one album +- `ImportSession` — holds all `AlbumResult`s for one run; stored in `lastSession` global +- `MusicMetadata` — artist/album/title/date/quality used throughout the pipeline + +**Web layer** (`main.go`): +- `GET /` — renders `index.html.tmpl` with the last session's results +- `POST /run` — starts `RunImporter()` in a goroutine; prevents concurrent runs via `importerMu` mutex + +**External tool dependencies** (must be present in PATH at runtime): +- `ffprobe` — reads audio tags and stream info +- `beet` — metadata tagging via MusicBrainz (primary metadata source) +- `rsgain` — ReplayGain calculation +- `metaflac` — FLAC tag manipulation and cover embedding +- `curl` — MusicBrainz API fallback queries + +**Environment variables**: +- `IMPORT_DIR` — source directory scanned for albums +- `LIBRARY_DIR` — destination library root +- `COPYMODE=true` — copies files instead of moving (still destructive on the destination) +- `SLSKD_URL` — base URL of the slskd instance (e.g. `http://localhost:5030`) +- `SLSKD_API_KEY` — slskd API key (sent as `X-API-Key` header) + +**Releases**: Docker image `gabehf/music-importer` is built and pushed to Docker Hub via GitHub Actions on `v*` tags. diff --git a/discover.go b/discover.go new file mode 100644 index 0000000..c6f9ee9 --- /dev/null +++ b/discover.go @@ -0,0 +1,402 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// ── MusicBrainz types ───────────────────────────────────────────────────────── + +type mbArtistCredit struct { + Name string `json:"name"` + Artist struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"artist"` +} + +type mbRelease struct { + ID string `json:"id"` + Title string `json:"title"` + Date string `json:"date"` + ArtistCredit []mbArtistCredit `json:"artist-credit"` + ReleaseGroup struct { + PrimaryType string `json:"primary-type"` + } `json:"release-group"` +} + +type mbArtist struct { + ID string `json:"id"` + Name string `json:"name"` + Country string `json:"country"` + Disambiguation string `json:"disambiguation"` +} + +type mbReleaseGroup struct { + ID string `json:"id"` + Title string `json:"title"` + PrimaryType string `json:"primary-type"` + FirstReleaseDate string `json:"first-release-date"` +} + +func mbGet(path string, out interface{}) error { + req, err := http.NewRequest("GET", "https://musicbrainz.org"+path, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "music-importer/1.0 (https://github.com/gabehf/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 %d", resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func searchMBReleases(query string) ([]mbRelease, error) { + var result struct { + Releases []mbRelease `json:"releases"` + } + err := mbGet("/ws/2/release/?query="+url.QueryEscape(query)+"&fmt=json&limit=20", &result) + return result.Releases, err +} + +func searchMBArtists(query string) ([]mbArtist, error) { + var result struct { + Artists []mbArtist `json:"artists"` + } + err := mbGet("/ws/2/artist/?query="+url.QueryEscape(query)+"&fmt=json&limit=20", &result) + return result.Artists, err +} + +// getMBArtistReleaseGroups returns all Album and EP release groups for an artist, +// paginating through the MusicBrainz browse API with the required 1 req/s rate limit. +func getMBArtistReleaseGroups(artistMBID string) ([]mbReleaseGroup, error) { + const limit = 100 + var all []mbReleaseGroup + + for offset := 0; ; offset += limit { + path := fmt.Sprintf( + "/ws/2/release-group?artist=%s&type=album%%7Cep&fmt=json&limit=%d&offset=%d", + url.QueryEscape(artistMBID), limit, offset, + ) + + var result struct { + ReleaseGroups []mbReleaseGroup `json:"release-groups"` + Count int `json:"release-group-count"` + } + if err := mbGet(path, &result); err != nil { + return all, err + } + + for _, rg := range result.ReleaseGroups { + t := strings.ToLower(rg.PrimaryType) + if t == "album" || t == "ep" { + all = append(all, rg) + } + } + + if offset+limit >= result.Count { + break + } + time.Sleep(time.Second) // MusicBrainz rate limit + } + + return all, nil +} + +// fetchArtist fetches every Album and EP release group for an artist by running +// fetchRelease for each one sequentially, then registers each for monitoring. +func fetchArtist(artistMBID, artistName string, logf func(string)) error { + log.Printf("[discover] artist fetch started: %s (%s)", artistName, artistMBID) + logf(fmt.Sprintf("Looking up discography for %s on MusicBrainz…", artistName)) + + groups, err := getMBArtistReleaseGroups(artistMBID) + if err != nil { + return fmt.Errorf("MusicBrainz discography lookup failed: %w", err) + } + if len(groups) == 0 { + return fmt.Errorf("no albums or EPs found for %s on MusicBrainz", artistName) + } + + log.Printf("[discover] found %d release groups for %s", len(groups), artistName) + logf(fmt.Sprintf("Found %d albums/EPs", len(groups))) + + failed := 0 + for i, rg := range groups { + logf(fmt.Sprintf("[%d/%d] %s", i+1, len(groups), rg.Title)) + folder, err := fetchRelease(artistName, rg.Title, rg.ID, logf) + if err != nil { + log.Printf("[discover] fetch failed for %q by %s: %v", rg.Title, artistName, err) + logf(fmt.Sprintf(" ↳ failed: %v", err)) + failed++ + continue + } + registerDownload(rg.ID, artistName, rg.Title, folder, nil) + logf(fmt.Sprintf(" ↳ registered for import (mbid: %s)", rg.ID)) + } + + if failed > 0 { + logf(fmt.Sprintf("Done — %d/%d queued, %d failed", len(groups)-failed, len(groups), failed)) + } else { + logf(fmt.Sprintf("Done — all %d downloads queued, monitoring for import", len(groups))) + } + log.Printf("[discover] artist fetch complete: %s (%d/%d succeeded)", artistName, len(groups)-failed, len(groups)) + return nil +} + +// ── Fetch state ─────────────────────────────────────────────────────────────── + +type fetchEntry struct { + mu sync.Mutex + ID string `json:"id"` + Artist string `json:"artist"` + Album string `json:"album"` + Log []string `json:"log"` + Done bool `json:"done"` + Success bool `json:"success"` + ErrMsg string `json:"error,omitempty"` +} + +var ( + fetchesMu sync.Mutex + fetchMap = make(map[string]*fetchEntry) +) + +func newFetchEntry(id, artist, album string) *fetchEntry { + e := &fetchEntry{ID: id, Artist: artist, Album: album} + fetchesMu.Lock() + fetchMap[id] = e + fetchesMu.Unlock() + return e +} + +func (e *fetchEntry) appendLog(msg string) { + e.mu.Lock() + e.Log = append(e.Log, msg) + e.mu.Unlock() +} + +func (e *fetchEntry) finish(err error) { + e.mu.Lock() + e.Done = true + if err != nil { + e.ErrMsg = err.Error() + } else { + e.Success = true + } + e.mu.Unlock() +} + +func (e *fetchEntry) snapshot() fetchEntry { + e.mu.Lock() + defer e.mu.Unlock() + cp := *e + cp.Log = append([]string(nil), e.Log...) + return cp +} + +// ── HTTP handlers ───────────────────────────────────────────────────────────── + +// handleDiscoverSearch handles GET /discover/search?q=...&type=release|artist +func handleDiscoverSearch(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + if q == "" { + http.Error(w, "missing q", http.StatusBadRequest) + return + } + searchType := r.URL.Query().Get("type") + if searchType == "" { + searchType = "release" + } + log.Printf("[discover] search: type=%s q=%q", searchType, q) + + w.Header().Set("Content-Type", "application/json") + + switch searchType { + case "artist": + artists, err := searchMBArtists(q) + if err != nil { + log.Printf("[discover] artist search error: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("[discover] artist search returned %d results", len(artists)) + json.NewEncoder(w).Encode(artists) + + default: // "release" + releases, err := searchMBReleases(q) + if err != nil { + log.Printf("[discover] release search error: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Printf("[discover] release search returned %d results", len(releases)) + json.NewEncoder(w).Encode(releases) + } +} + +// handleDiscoverFetch handles POST /discover/fetch +// Body: {"id":"mbid","artist":"...","album":"..."} +func handleDiscoverFetch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + ID string `json:"id"` + Artist string `json:"artist"` + Album string `json:"album"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == "" || body.Artist == "" || body.Album == "" { + http.Error(w, "id, artist and album are required", http.StatusBadRequest) + return + } + + // If a fetch for this ID is already in progress, return its ID without starting a new one. + fetchesMu.Lock() + existing := fetchMap[body.ID] + fetchesMu.Unlock() + if existing != nil { + existing.mu.Lock() + done := existing.Done + existing.mu.Unlock() + if !done { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"id": body.ID}) + return + } + } + + log.Printf("[discover] starting fetch: %q by %s (mbid: %s)", body.Album, body.Artist, body.ID) + entry := newFetchEntry(body.ID, body.Artist, body.Album) + go func() { + folder, err := fetchRelease(body.Artist, body.Album, body.ID, entry.appendLog) + if err != nil { + log.Printf("[discover] fetch failed for %q by %s: %v", body.Album, body.Artist, err) + entry.finish(err) + return + } + log.Printf("[discover] fetch complete for %q by %s, registering for import", body.Album, body.Artist) + registerDownload(body.ID, body.Artist, body.Album, folder, entry) + // entry.finish is called by the monitor when import completes + }() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"id": body.ID}) +} + +// handleDiscoverFetchArtist handles POST /discover/fetch/artist +// Body: {"id":"artist-mbid","name":"Artist Name"} +func handleDiscoverFetchArtist(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + + var body struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == "" || body.Name == "" { + http.Error(w, "id and name are required", http.StatusBadRequest) + return + } + + fetchesMu.Lock() + existing := fetchMap[body.ID] + fetchesMu.Unlock() + if existing != nil { + existing.mu.Lock() + done := existing.Done + existing.mu.Unlock() + if !done { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"id": body.ID}) + return + } + } + + log.Printf("[discover] starting artist fetch: %s (%s)", body.Name, body.ID) + entry := newFetchEntry(body.ID, body.Name, "") + go func() { + err := fetchArtist(body.ID, body.Name, entry.appendLog) + if err != nil { + log.Printf("[discover] artist fetch failed for %s: %v", body.Name, err) + } else { + log.Printf("[discover] artist fetch complete for %s", body.Name) + } + entry.finish(err) + }() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"id": body.ID}) +} + +// handleDiscoverFetchStatus handles GET /discover/fetch/status?id=... +func handleDiscoverFetchStatus(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "missing id", http.StatusBadRequest) + return + } + + fetchesMu.Lock() + entry := fetchMap[id] + fetchesMu.Unlock() + + if entry == nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + snap := entry.snapshot() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(snap) +} + +// fetchListItem is a summary of a fetch entry for the list endpoint. +type fetchListItem struct { + ID string `json:"id"` + Title string `json:"title"` + Done bool `json:"done"` + Success bool `json:"success"` +} + +// handleDiscoverFetchList handles GET /discover/fetch/list +// Returns a summary of all known fetch entries so the frontend can discover +// entries created server-side (e.g. per-album entries from an artist fetch). +func handleDiscoverFetchList(w http.ResponseWriter, r *http.Request) { + fetchesMu.Lock() + items := make([]fetchListItem, 0, len(fetchMap)) + for _, e := range fetchMap { + e.mu.Lock() + title := e.Artist + if e.Album != "" { + title = e.Artist + " \u2014 " + e.Album + } + items = append(items, fetchListItem{ + ID: e.ID, + Title: title, + Done: e.Done, + Success: e.Success, + }) + e.mu.Unlock() + } + fetchesMu.Unlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(items) +} diff --git a/importer.go b/importer.go index 8559fcb..6a27f9c 100644 --- a/importer.go +++ b/importer.go @@ -185,7 +185,7 @@ func RunImporter() { } fmt.Println("→ Tagging album metadata:") - md, src, err := getAlbumMetadata(albumPath, tracks[0]) + md, src, err := getAlbumMetadata(albumPath, tracks[0], "") result.TagMetadata.Err = err result.MetadataSource = src if err != nil { diff --git a/index.html.tmpl b/index.html.tmpl index 711a298..f89241c 100644 --- a/index.html.tmpl +++ b/index.html.tmpl @@ -1,223 +1,53 @@ - +
+ +Searching MusicBrainz\u2026
'; + + fetch(`/discover/search?q=${encodeURIComponent(q)}&type=${searchType}`) + .then(r => { + if (!r.ok) return r.text().then(t => { throw new Error(t || r.statusText); }); + return r.json(); + }) + .then(data => renderResults(data)) + .catch(err => { + resultsEl.innerHTML = `Error: ${esc(err.message)}
`; + }) + .finally(() => { + btn.disabled = false; + btn.textContent = 'Search'; + }); +} + +// ── Results rendering ────────────────────────────────────────────────────────── + +function renderResults(data) { + const el = document.getElementById('search-results'); + if (!data || data.length === 0) { + el.innerHTML = 'No results found.
'; + return; + } + const renderer = searchType === 'artist' ? renderArtist : renderRelease; + el.innerHTML = data.map(renderer).join(''); +} + +function renderRelease(r) { + const credits = r['artist-credit'] ?? []; + const artist = credits.map(c => c.name || c.artist?.name || '').join('') || 'Unknown Artist'; + const year = r.date?.substring(0, 4) ?? ''; + const type = r['release-group']?.['primary-type'] ?? ''; + const meta = [year, type].filter(Boolean).join(' \u00b7 '); + + return ` +