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 } // getFirstReleaseMBID returns the MBID of the first release listed under a // release group. This is needed because beets --search-id requires a release // MBID, not a release group MBID. // Returns empty string on error so callers can fall back gracefully. func getFirstReleaseMBID(rgMBID string) string { var result struct { Releases []struct { ID string `json:"id"` } `json:"releases"` } path := fmt.Sprintf("/ws/2/release-group/%s?fmt=json&inc=releases", url.QueryEscape(rgMBID)) if err := mbGet(path, &result); err != nil || len(result.Releases) == 0 { return "" } return result.Releases[0].ID } // 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)) // Resolve a release MBID for this release group. beets --search-id // requires a release MBID; release group MBIDs are not accepted. time.Sleep(time.Second) // MusicBrainz rate limit releaseMBID := getFirstReleaseMBID(rg.ID) if releaseMBID == "" { logf(fmt.Sprintf(" ↳ warning: could not resolve release MBID for group %s, beets will search by name", rg.ID)) } folder, err := fetchRelease(artistName, rg.Title, releaseMBID, 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, 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) 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.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) }