mirror of
https://github.com/gabehf/music-importer.git
synced 2026-04-22 11:31:52 -07:00
402 lines
12 KiB
Go
402 lines
12 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
|
|
}
|
|
|
|
// 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)
|
|
}
|