mirror of
https://github.com/gabehf/music-importer.git
synced 2026-04-22 11:31:52 -07:00
499 lines
14 KiB
Go
499 lines
14 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 mbMedia struct {
|
|
Format string `json:"format"`
|
|
}
|
|
|
|
type mbRelease struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Date string `json:"date"`
|
|
Country string `json:"country"`
|
|
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"`
|
|
}
|
|
|
|
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.
|
|
// Format (CD > Digital Media > *) is the primary sort key;
|
|
// country (KR > JP > 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]
|
|
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 := ""
|
|
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
|
|
format := ""
|
|
if len(rel.Media) > 0 {
|
|
format = rel.Media[0].Format
|
|
}
|
|
logf(fmt.Sprintf(" ↳ selected release: %s [%s / %s]", releaseMBID, format, rel.Country))
|
|
}
|
|
|
|
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)
|
|
}
|