Files
supersonic/backend/musicbrainzartwork.go
2026-07-29 04:35:25 -05:00

148 lines
3.8 KiB
Go

package backend
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
coverArtAPIBase = "https://coverartarchive.org"
)
// musicBrainzArtworkResolver resolves public album artwork URLs while obeying
// MusicBrainz's one-request-per-second client limit.
type musicBrainzArtworkResolver struct {
ctx context.Context
client *http.Client
userAgent string
mu sync.Mutex
lastCall time.Time
cache map[string]string
inFlight map[string][]chan string
}
func newMusicBrainzArtworkResolver(ctx context.Context, userAgent string) *musicBrainzArtworkResolver {
return &musicBrainzArtworkResolver{
ctx: ctx, client: &http.Client{Timeout: 10 * time.Second}, userAgent: userAgent,
cache: make(map[string]string), inFlight: make(map[string][]chan string),
}
}
func (r *musicBrainzArtworkResolver) Resolve(artist, album string) string {
artist, album = strings.TrimSpace(artist), strings.TrimSpace(album)
if artist == "" || album == "" {
return ""
}
key := strings.ToLower(artist + "\x00" + album)
r.mu.Lock()
if result, ok := r.cache[key]; ok {
r.mu.Unlock()
return result
}
if waiters, ok := r.inFlight[key]; ok {
wait := make(chan string, 1)
r.inFlight[key] = append(waiters, wait)
r.mu.Unlock()
select {
case result := <-wait:
return result
case <-r.ctx.Done():
return ""
}
}
r.inFlight[key] = nil
r.mu.Unlock()
result := r.lookup(artist, album)
r.mu.Lock()
r.cache[key] = result
for _, waiter := range r.inFlight[key] {
waiter <- result
close(waiter)
}
delete(r.inFlight, key)
r.mu.Unlock()
return result
}
func (r *musicBrainzArtworkResolver) lookup(artist, album string) string {
if !r.waitForRateLimit() {
return ""
}
query := fmt.Sprintf(`releasegroup:"%s" AND artist:"%s"`, luceneEscape(album), luceneEscape(artist))
endpoint := musicBrainzAPIBase + "/release-group/?fmt=json&limit=1&query=" + url.QueryEscape(query)
req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ""
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", r.userAgent)
resp, err := r.client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var result struct {
ReleaseGroups []struct {
ID string `json:"id"`
} `json:"release-groups"`
}
if json.NewDecoder(resp.Body).Decode(&result) != nil || len(result.ReleaseGroups) == 0 {
return ""
}
artURL := coverArtAPIBase + "/release-group/" + result.ReleaseGroups[0].ID + "/front-500"
artReq, err := http.NewRequestWithContext(r.ctx, http.MethodHead, artURL, nil)
if err != nil {
return ""
}
// Validate the Cover Art Archive endpoint itself without following the
// redirect to an archive.org image host. Some archive mirrors reject HEAD
// even though Discord can fetch the redirected image normally.
artClient := *r.client
artClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
artResp, err := artClient.Do(artReq)
if err != nil {
return ""
}
artResp.Body.Close()
validRedirect := artResp.StatusCode >= 300 && artResp.StatusCode < 400 && artResp.Header.Get("Location") != ""
if (artResp.StatusCode < 200 || artResp.StatusCode >= 300) && !validRedirect {
return ""
}
return artURL
}
func (r *musicBrainzArtworkResolver) waitForRateLimit() bool {
r.mu.Lock()
wait := time.Until(r.lastCall.Add(time.Second))
if wait < 0 {
wait = 0
}
r.lastCall = time.Now().Add(wait)
r.mu.Unlock()
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-timer.C:
return true
case <-r.ctx.Done():
return false
}
}
func luceneEscape(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
return strings.ReplaceAll(value, `"`, `\"`)
}