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
354 lines
9.7 KiB
Go
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
|
|
}
|