music-importer/discover.go
2026-04-09 00:39:59 -04:00

428 lines
13 KiB
Go

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