mirror of
https://github.com/gabehf/music-importer.git
synced 2026-04-22 11:31:52 -07:00
mbz discover + auto import
This commit is contained in:
parent
986b0273be
commit
c7d6a088ed
11 changed files with 2036 additions and 266 deletions
402
discover.go
Normal file
402
discover.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue