package metadata import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "sync" "time" ) // MusicBrainz matches albums. No API key, but it demands a descriptive // User-Agent and a hard rate limit of one request per second. type MusicBrainz struct { Client *http.Client // mu and lastCall enforce the 1 req/s ceiling. Enrichment is sequential // today, but the limiter is here so a future parallel worker cannot trip // the server into blocking us. mu sync.Mutex lastCall time.Time } // NewMusicBrainz builds the provider with a bounded HTTP client. func NewMusicBrainz() *MusicBrainz { return &MusicBrainz{Client: &http.Client{Timeout: 15 * time.Second}} } func (p *MusicBrainz) Name() string { return "musicbrainz" } func (p *MusicBrainz) Kinds() []string { return []string{"music"} } type mbRelease struct { ID string `json:"id"` Title string `json:"title"` Date string `json:"date"` Country string `json:"country"` ArtistCredit []struct { Name string `json:"name"` Artist struct { ID string `json:"id"` Name string `json:"name"` } `json:"artist"` } `json:"artist-credit"` } type mbResponse struct { Releases []mbRelease `json:"releases"` } func (p *MusicBrainz) Search(ctx context.Context, q Query) ([]Candidate, error) { if strings.TrimSpace(q.Title) == "" { return nil, nil } if err := p.waitTurn(ctx); err != nil { return nil, err } // Lucene-ish query: exact-ish release match, optionally narrowed by artist. query := `release:"` + escapeLucene(q.Title) + `"` if q.Author != "" { query += ` AND artist:"` + escapeLucene(q.Author) + `"` } params := url.Values{} params.Set("query", query) params.Set("fmt", "json") params.Set("limit", "5") endpoint := "https://musicbrainz.org/ws/2/release/?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } // MusicBrainz blocks generic user agents; a real contact string is required. req.Header.Set("User-Agent", "Vellum/0.1 ( self-hosted media server )") resp, err := p.Client.Do(req) if err != nil { return nil, fmt.Errorf("musicbrainz: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusServiceUnavailable { return nil, fmt.Errorf("musicbrainz: rate limited (503)") } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("musicbrainz: status %d", resp.StatusCode) } var parsed mbResponse if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { return nil, fmt.Errorf("musicbrainz: decode: %w", err) } out := make([]Candidate, 0, len(parsed.Releases)) for _, r := range parsed.Releases { if r.Title == "" || r.ID == "" { continue } var artists []string for _, ac := range r.ArtistCredit { name := strings.TrimSpace(ac.Name) if name == "" { name = ac.Artist.Name } if name != "" { artists = append(artists, name) } } out = append(out, Candidate{ Provider: p.Name(), ExternalID: r.ID, Title: r.Title, Authors: artists, Year: parseYear(r.Date), // Cover Art Archive serves artwork by release id, no key. CoverURL: "https://coverartarchive.org/release/" + r.ID + "/front-500", }) } return out, nil } // waitTurn blocks until at least one second has passed since the last call. func (p *MusicBrainz) waitTurn(ctx context.Context) error { p.mu.Lock() defer p.mu.Unlock() const minInterval = time.Second if wait := minInterval - time.Since(p.lastCall); wait > 0 { select { case <-ctx.Done(): return ctx.Err() case <-time.After(wait): } } p.lastCall = time.Now() return nil } // escapeLucene neutralises characters that would break a MusicBrainz query. func escapeLucene(s string) string { replacer := strings.NewReplacer( `\`, `\\`, `"`, `\"`, `+`, `\+`, `-`, `\-`, `&&`, `\&&`, `||`, `\||`, `!`, `\!`, `(`, `\(`, `)`, `\)`, `{`, `\{`, `}`, `\}`, `[`, `\[`, `]`, `\]`, `^`, `\^`, `~`, `\~`, `*`, `\*`, `?`, `\?`, ) return replacer.Replace(s) }