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..ddd38e6 --- /dev/null +++ b/discover.go @@ -0,0 +1,547 @@ +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 mbMedia struct { + Format string `json:"format"` + TrackCount int `json:"track-count"` +} + +type mbRelease struct { + ID string `json:"id"` + Title string `json:"title"` + Date string `json:"date"` + Country string `json:"country"` + Disambiguation string `json:"disambiguation"` + TextRepresentation struct { + Language string `json:"language"` + } `json:"text-representation"` + Media []mbMedia `json:"media"` + 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"` +} + +// releaseTrackCount returns the total number of tracks across all media in a release. +func releaseTrackCount(r mbRelease) int { + total := 0 + for _, m := range r.Media { + total += m.TrackCount + } + return total +} + +// getMBRelease fetches a single release by MBID (with media/track-count included). +func getMBRelease(mbid string) (*mbRelease, error) { + var r mbRelease + err := mbGet(fmt.Sprintf("/ws/2/release/%s?fmt=json&inc=media", url.QueryEscape(mbid)), &r) + return &r, err +} + +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&inc=media", &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 +} + +// releaseFormatScore returns a preference score for a release's media format. +// Higher is better. CD=2, Digital Media=1, anything else=0. +func releaseFormatScore(r mbRelease) int { + for _, m := range r.Media { + switch m.Format { + case "Digital Media": + return 2 + case "CD": + return 1 + } + } + return 0 +} + +// releaseCountryScore returns a preference score for a release's country. +// Higher is better. KR=3, JP=2, XW=1, anything else=0. +func releaseCountryScore(r mbRelease) int { + switch r.Country { + case "XW": + return 2 + case "KR": + return 1 + } + return 0 +} + +// returns true if strings formatted 'YYYY-MM-DD" ts1 is before ts2 +func timeStringIsBefore(ts1, ts2 string) (bool, error) { + datefmt := "2006-02-01" + t1, err := time.Parse(datefmt, ts1) + if err != nil { + return false, err + } + t2, err := time.Parse(datefmt, ts2) + if err != nil { + return false, err + } + return t1.Unix() <= t2.Unix(), nil +} + +// pickBestRelease selects the preferred release from a list. +// No disambiguation (canonical release) is the primary sort key; +// format (CD > Digital Media > *) is secondary; country (KR > XW > *) breaks ties. +func pickBestRelease(releases []mbRelease) *mbRelease { + if len(releases) == 0 { + return nil + } + best := &releases[0] + for i := 1; i < len(releases); i++ { + r := &releases[i] + + rNoDisamb := r.Disambiguation == "" + bestNoDisamb := best.Disambiguation == "" + + // Prefer releases with no disambiguation — they are the canonical default. + if rNoDisamb && !bestNoDisamb { + best = r + continue + } + if !rNoDisamb && bestNoDisamb { + continue + } + + // Both have the same disambiguation status; use date/format/country. + if before, err := timeStringIsBefore(r.Date, best.Date); before && err == nil { + rf, bf := releaseFormatScore(*r), releaseFormatScore(*best) + if rf > bf || (rf == bf && releaseCountryScore(*r) > releaseCountryScore(*best)) { + best = r + } + } + } + return best +} + +// pickBestReleaseForGroup fetches all releases for a release group via the +// MusicBrainz browse API (with media info) and returns the preferred release. +// Returns nil on error or when the group has no releases. +func pickBestReleaseForGroup(rgMBID string) *mbRelease { + var result struct { + Releases []mbRelease `json:"releases"` + } + path := fmt.Sprintf("/ws/2/release?release-group=%s&fmt=json&inc=media&limit=100", url.QueryEscape(rgMBID)) + if err := mbGet(path, &result); err != nil || len(result.Releases) == 0 { + return nil + } + return pickBestRelease(result.Releases) +} + +// 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)) + // Pick the best release for this group. beets --search-id requires a + // release MBID; release group MBIDs are not accepted. + time.Sleep(time.Second) // MusicBrainz rate limit + rel := pickBestReleaseForGroup(rg.ID) + releaseMBID := "" + trackCount := 0 + if rel == nil { + logf(fmt.Sprintf(" ↳ warning: could not resolve release for group %s, beets will search by name", rg.ID)) + } else { + releaseMBID = rel.ID + trackCount = releaseTrackCount(*rel) + format := "" + if len(rel.Media) > 0 { + format = rel.Media[0].Format + } + logf(fmt.Sprintf(" ↳ selected release: %s [%s / %s / %d tracks]", releaseMBID, format, rel.Country, trackCount)) + } + + folder, err := fetchRelease(artistName, rg.Title, releaseMBID, trackCount, 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 + } + // Key the pending download by release group ID for dedup; beets uses releaseMBID. + registerDownload(rg.ID, releaseMBID, artistName, rg.Title, trackCount, folder, nil) + logf(fmt.Sprintf(" ↳ registered for import (release mbid: %s)", releaseMBID)) + } + + 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) + + // Look up the expected track count from MusicBrainz so the folder-selection + // logic can prefer results that match the release we intend to import. + trackCount := 0 + if rel, err := getMBRelease(body.ID); err == nil { + trackCount = releaseTrackCount(*rel) + log.Printf("[discover] release %s has %d tracks", body.ID, trackCount) + } else { + log.Printf("[discover] could not fetch release track count for %s: %v", body.ID, err) + } + + go func() { + folder, err := fetchRelease(body.Artist, body.Album, body.ID, trackCount, 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.ID, body.Artist, body.Album, trackCount, 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/files.go b/files.go index 943aebd..75a388c 100644 --- a/files.go +++ b/files.go @@ -9,8 +9,9 @@ import ( "strings" ) -// moveToLibrary moves a file to {libDir}/{artist}/[{date}] {album} [{quality}]/filename. -func moveToLibrary(libDir string, md *MusicMetadata, srcPath string) error { +// albumTargetDir returns the destination directory for an album without +// creating it. Use this to check for an existing import before moving files. +func albumTargetDir(libDir string, md *MusicMetadata) string { date := md.Date if date == "" { date = md.Year @@ -19,7 +20,12 @@ func moveToLibrary(libDir string, md *MusicMetadata, srcPath string) error { if md.Quality != "" { albumDir += fmt.Sprintf(" [%s]", md.Quality) } - targetDir := filepath.Join(libDir, sanitize(md.Artist), sanitize(albumDir)) + return filepath.Join(libDir, sanitize(md.Artist), sanitize(albumDir)) +} + +// moveToLibrary moves a file to {libDir}/{artist}/[{date}] {album} [{quality}]/filename. +func moveToLibrary(libDir string, md *MusicMetadata, srcPath string) error { + targetDir := albumTargetDir(libDir, md) if err := os.MkdirAll(targetDir, 0755); err != nil { return err } diff --git a/importer.go b/importer.go index 8559fcb..d108f16 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 { @@ -213,11 +213,15 @@ func RunImporter() { fmt.Println("→ Downloading cover art for album:", albumPath) if _, err := FindCoverImage(albumPath); err != nil { - if err := DownloadCoverArt(albumPath, md); err != nil { + if err := DownloadCoverArt(albumPath, md, ""); err != nil { fmt.Println("Cover art download failed:", err) } } + if err := NormalizeCoverArt(albumPath); err != nil { + fmt.Println("Cover art normalization warning:", err) + } + fmt.Println("→ Embedding cover art for album:", albumPath) result.CoverArt.Err = EmbedAlbumArtIntoFolder(albumPath) if coverImg, err := FindCoverImage(albumPath); err == nil { @@ -233,33 +237,39 @@ func RunImporter() { continue } - fmt.Println("→ Moving tracks into library for album:", albumPath) - for _, track := range tracks { - if err := moveToLibrary(libraryDir, md, track); err != nil { - fmt.Println("Failed to move track:", track, err) - result.Move.Err = err // retains last error; all attempts are still made + targetDir := albumTargetDir(libraryDir, md) + if _, err := os.Stat(targetDir); err == nil { + fmt.Println("→ Album already exists in library, skipping move:", targetDir) + result.Move.Skipped = true + } else { + fmt.Println("→ Moving tracks into library for album:", albumPath) + for _, track := range tracks { + if err := moveToLibrary(libraryDir, md, track); err != nil { + fmt.Println("Failed to move track:", track, err) + result.Move.Err = err // retains last error; all attempts are still made + } } - } - lyrics, _ := getLyricFiles(albumPath) + lyrics, _ := getLyricFiles(albumPath) - fmt.Println("→ Moving lyrics into library for album:", albumPath) - for _, file := range lyrics { - if err := moveToLibrary(libraryDir, md, file); err != nil { - fmt.Println("Failed to move lyrics:", file, err) - result.Move.Err = err + fmt.Println("→ Moving lyrics into library for album:", albumPath) + for _, file := range lyrics { + if err := moveToLibrary(libraryDir, md, file); err != nil { + fmt.Println("Failed to move lyrics:", file, err) + result.Move.Err = err + } } - } - fmt.Println("→ Moving album cover into library for album:", albumPath) - if coverImg, err := FindCoverImage(albumPath); err == nil { - if err := moveToLibrary(libraryDir, md, coverImg); err != nil { - fmt.Println("Failed to cover image:", coverImg, err) - result.Move.Err = err + fmt.Println("→ Moving album cover into library for album:", albumPath) + if coverImg, err := FindCoverImage(albumPath); err == nil { + if err := moveToLibrary(libraryDir, md, coverImg); err != nil { + fmt.Println("Failed to cover image:", coverImg, err) + result.Move.Err = err + } } - } - os.Remove(albumPath) + os.Remove(albumPath) + } } fmt.Println("\n=== Import Complete ===") 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 country = r.country ?? ""; + const formats = [ + ...new Set((r.media ?? []).map((m) => m.format).filter(Boolean)), + ].join("+"); + const lang = r["text-representation"]?.language ?? ""; + const meta = [year, type, formats, country, lang] + .filter(Boolean) + .join(" \u00b7 "); + const dis = r.disambiguation ? ` (${esc(r.disambiguation)})` : ""; + const coverUrl = `https://coverartarchive.org/release/${r.id}/front-250`; + + return ` +