feat(uploads): match release groups with MusicBrainz and AcoustID
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
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
This commit is contained in:
1 parent
1e3a149aef
commit
39bf7d4b70
45 files changed
+4792
-417
No files matched your search
@@ -0,0 +1,353 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user