Files
pipu-music/services/api/internal/musicbrainz/client.go
T
mrleo1nid 39bf7d4b70
ci / module boundaries / boundaries (push) Successful in 10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / api / api (push) Successful in 58s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Failing after 25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Failing after 6m3s
feat(uploads): match release groups with MusicBrainz and AcoustID
2026-10-29 20:44:59 +03:00

354 lines
9.7 KiB
Go

// Package musicbrainz adapts MusicBrainz and Cover Art Archive without receiving audio files.
package musicbrainz
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
type Track struct {
RecordingID string `json:"recording_id"`
Title string `json:"title"`
Artist string `json:"artist"`
Disc int `json:"disc"`
Position int `json:"position"`
DurationMs int `json:"duration_ms"`
}
type Release struct {
ID string `json:"id"`
GroupID string `json:"group_id"`
Title string `json:"title"`
Artist string `json:"artist"`
Type string `json:"type"`
Date string `json:"date"`
Country string `json:"country"`
Comment string `json:"comment"`
HasCover bool `json:"has_cover"`
Tracks []Track `json:"tracks"`
}
type Query struct {
GroupID string `json:"group_id,omitempty"`
Artist string `json:"artist"`
Album string `json:"album"`
ReleaseID string `json:"release_id"`
Audio bool `json:"audio,omitempty"`
RecordingIDs []string `json:"-"`
}
type Cover struct {
Data []byte
MIME string
URL string
Exact bool
}
type Cache interface {
GetMetadataCache(context.Context, string) ([]byte, bool, error)
PutMetadataCache(context.Context, string, []byte, time.Duration) error
}
type Client struct {
http *http.Client
cache Cache
mu sync.Mutex
next time.Time
base string
coverBase string
interval time.Duration
userAgent string
}
func New(cache Cache, userAgent string) *Client {
return &Client{cache: cache, base: "https://musicbrainz.org/ws/2", coverBase: "https://coverartarchive.org", interval: 1100 * time.Millisecond,
userAgent: userAgent, http: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
h := req.URL.Hostname()
if len(via) > 5 || req.URL.Scheme != "https" || req.URL.Port() != "" || req.URL.User != nil ||
!(h == "musicbrainz.org" || h == "coverartarchive.org" || h == "archive.org" || strings.HasSuffix(h, ".archive.org")) {
return errors.New("metadata: redirect outside provider")
}
return nil
}}}
}
func (c *Client) wait(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if delay := time.Until(c.next); delay > 0 {
t := time.NewTimer(delay)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
c.next = time.Now().Add(c.interval)
return nil
}
var ErrNotFound = errors.New("metadata: not found")
func (c *Client) get(ctx context.Context, address string, limited bool, maxBytes int64) ([]byte, error) {
if data, ok, err := c.cache.GetMetadataCache(ctx, address); err == nil && ok {
return data, nil
}
if limited {
if err := c.wait(ctx); err != nil {
return nil, err
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json, image/jpeg, image/png, image/webp")
res, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
return nil, ErrNotFound
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("metadata: HTTP %d", res.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(res.Body, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, errors.New("metadata: response too large")
}
if limited && !json.Valid(data) {
return nil, errors.New("metadata: invalid JSON response")
}
if !limited {
mime := http.DetectContentType(data)
if mime != "image/jpeg" && mime != "image/png" && mime != "image/webp" {
return nil, errors.New("metadata: unsupported cover")
}
}
_ = c.cache.PutMetadataCache(ctx, address, data, 7*24*time.Hour)
return data, nil
}
type credit struct {
Name string `json:"name"`
Join string `json:"joinphrase"`
Artist struct {
Name string `json:"name"`
} `json:"artist"`
}
func artist(credits []credit) string {
var s strings.Builder
for _, a := range credits {
name := a.Name
if name == "" {
name = a.Artist.Name
}
s.WriteString(name)
s.WriteString(a.Join)
}
return s.String()
}
type releaseJSON struct {
ID string `json:"id"`
Title string `json:"title"`
Date string `json:"date"`
Country string `json:"country"`
Comment string `json:"disambiguation"`
Credits []credit `json:"artist-credit"`
Group struct {
ID string `json:"id"`
Type string `json:"primary-type"`
} `json:"release-group"`
Cover struct {
Front bool `json:"front"`
} `json:"cover-art-archive"`
Media []struct {
Position int `json:"position"`
Tracks []struct {
Position int `json:"position"`
Title string `json:"title"`
Length int `json:"length"`
Credits []credit `json:"artist-credit"`
Recording struct {
ID string `json:"id"`
Title string `json:"title"`
Length int `json:"length"`
Credits []credit `json:"artist-credit"`
} `json:"recording"`
} `json:"tracks"`
} `json:"media"`
}
func (c *Client) release(ctx context.Context, id string) (Release, error) {
if _, err := uuid.Parse(id); err != nil {
return Release{}, errors.New("metadata: invalid release ID")
}
data, err := c.get(ctx, c.base+"/release/"+id+"?fmt=json&inc=recordings%2Bartist-credits%2Brelease-groups", true, 8<<20)
if err != nil {
return Release{}, err
}
var raw releaseJSON
if err = json.Unmarshal(data, &raw); err != nil {
return Release{}, err
}
r := Release{ID: raw.ID, GroupID: raw.Group.ID, Title: raw.Title, Artist: artist(raw.Credits), Type: raw.Group.Type, Date: raw.Date, Country: raw.Country, Comment: raw.Comment, HasCover: raw.Cover.Front, Tracks: []Track{}}
if _, err = uuid.Parse(r.ID); err != nil {
return Release{}, errors.New("metadata: invalid release response")
}
for _, m := range raw.Media {
for _, t := range m.Tracks {
title := t.Title
if title == "" {
title = t.Recording.Title
}
a := artist(t.Credits)
if a == "" {
a = artist(t.Recording.Credits)
}
if a == "" {
a = r.Artist
}
length := t.Length
if length == 0 {
length = t.Recording.Length
}
r.Tracks = append(r.Tracks, Track{RecordingID: t.Recording.ID, Title: title, Artist: a, Disc: m.Position, Position: t.Position, DurationMs: length})
}
}
if len(r.Tracks) == 0 || len(r.Tracks) > 1000 {
return Release{}, errors.New("metadata: invalid track list")
}
return r, nil
}
func quote(s string) string { return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` }
func (c *Client) Search(ctx context.Context, q Query) ([]Release, error) {
if len(q.RecordingIDs) > 0 {
return c.byRecordings(ctx, q.RecordingIDs)
}
if q.ReleaseID != "" {
r, err := c.release(ctx, q.ReleaseID)
if errors.Is(err, ErrNotFound) {
return []Release{}, nil
}
return []Release{r}, err
}
params := url.Values{"fmt": {"json"}, "limit": {"5"}, "query": {"release:" + quote(q.Album) + " AND artist:" + quote(q.Artist)}}
data, err := c.get(ctx, c.base+"/release?"+params.Encode(), true, 4<<20)
if err != nil {
return nil, err
}
var found struct {
Releases []struct {
ID string `json:"id"`
} `json:"releases"`
}
if err = json.Unmarshal(data, &found); err != nil {
return nil, err
}
out := []Release{}
for _, candidate := range found.Releases {
r, err := c.release(ctx, candidate.ID)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
// byRecordings discovers editions from a bounded sample; all batch files are matched afterwards.
func (c *Client) byRecordings(ctx context.Context, ids []string) ([]Release, error) {
votes := map[string]int{}
seen := map[string]bool{}
for _, id := range ids {
if _, err := uuid.Parse(id); err != nil {
return nil, errors.New("metadata: invalid recording ID")
}
if seen[id] {
continue
}
if len(seen) == 8 {
break
}
seen[id] = true
params := url.Values{"fmt": {"json"}, "recording": {id}, "limit": {"100"}}
data, err := c.get(ctx, c.base+"/release?"+params.Encode(), true, 4<<20)
if err != nil {
return nil, err
}
var found struct {
Releases []struct {
ID string `json:"id"`
} `json:"releases"`
}
if err = json.Unmarshal(data, &found); err != nil {
return nil, err
}
perRecording := map[string]bool{}
for _, r := range found.Releases {
if _, err := uuid.Parse(r.ID); err == nil && !perRecording[r.ID] {
votes[r.ID]++
perRecording[r.ID] = true
}
}
}
candidates := []string{}
for id := range votes {
candidates = append(candidates, id)
}
sort.Slice(candidates, func(i, j int) bool {
if votes[candidates[i]] == votes[candidates[j]] {
return candidates[i] < candidates[j]
}
return votes[candidates[i]] > votes[candidates[j]]
})
out := []Release{}
for _, id := range candidates[:min(5, len(candidates))] {
r, err := c.release(ctx, id)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
func (c *Client) Cover(ctx context.Context, r Release) (Cover, error) {
for i, id := range []string{r.ID, r.GroupID} {
if _, err := uuid.Parse(id); err != nil {
continue
}
kind := "release"
if i == 1 {
kind = "release-group"
}
address := c.coverBase + "/" + kind + "/" + id + "/front-1200"
data, err := c.get(ctx, address, false, 8<<20)
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return Cover{}, err
}
mime := http.DetectContentType(data)
if mime != "image/jpeg" && mime != "image/png" && mime != "image/webp" {
return Cover{}, errors.New("metadata: unsupported cover")
}
return Cover{Data: data, MIME: mime, URL: address, Exact: i == 0}, nil
}
return Cover{}, ErrNotFound
}