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,126 @@
|
||||
// Package acoustid identifies recordings using Chromaprint, never audio files.
|
||||
package acoustid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Match struct {
|
||||
RecordingID string
|
||||
Score float64
|
||||
}
|
||||
type Cache interface {
|
||||
GetMetadataCache(context.Context, string) ([]byte, bool, error)
|
||||
PutMetadataCache(context.Context, string, []byte, time.Duration) error
|
||||
}
|
||||
type Client struct {
|
||||
key, endpoint string
|
||||
cache Cache
|
||||
http *http.Client
|
||||
mu sync.Mutex
|
||||
next time.Time
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func New(key string, cache Cache) *Client {
|
||||
return &Client{key: key, endpoint: "https://api.acoustid.org/v2/lookup", cache: cache, interval: 350 * time.Millisecond,
|
||||
http: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
}
|
||||
func (c *Client) wait(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
t := time.NewTimer(max(time.Duration(0), time.Until(c.next)))
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
c.next = time.Now().Add(c.interval)
|
||||
return nil
|
||||
}
|
||||
func (c *Client) Lookup(ctx context.Context, fingerprint string, duration int) ([]Match, error) {
|
||||
if c.key == "" || duration <= 0 || len(fingerprint) == 0 || len(fingerprint) > 65536 {
|
||||
return nil, errors.New("acoustid: invalid lookup input")
|
||||
}
|
||||
hash := sha256.Sum256([]byte(fmt.Sprint(duration) + ":" + fingerprint))
|
||||
key := "acoustid:" + hex.EncodeToString(hash[:])
|
||||
if data, ok, err := c.cache.GetMetadataCache(ctx, key); err == nil && ok {
|
||||
return parse(data)
|
||||
}
|
||||
if err := c.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := url.Values{"client": {c.key}, "duration": {fmt.Sprint(duration)}, "fingerprint": {fingerprint}, "meta": {"recordingids"}, "format": {"json"}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, strings.NewReader(body.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "PipuMusic (https://gitea.hsrv.site/mrleo1nid/pipu-music)")
|
||||
res, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.New("acoustid: request failed")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("acoustid: HTTP %d", res.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(res.Body, (2<<20)+1))
|
||||
if err != nil || len(data) > 2<<20 {
|
||||
return nil, errors.New("acoustid: invalid response size")
|
||||
}
|
||||
matches, err := parse(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = c.cache.PutMetadataCache(ctx, key, data, 7*24*time.Hour)
|
||||
return matches, nil
|
||||
}
|
||||
func parse(data []byte) ([]Match, error) {
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Results []struct {
|
||||
Score float64 `json:"score"`
|
||||
Recordings []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"recordings"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, errors.New("acoustid: invalid response")
|
||||
}
|
||||
if response.Status != "ok" {
|
||||
return nil, errors.New("acoustid: lookup rejected; check application key")
|
||||
}
|
||||
best := map[string]float64{}
|
||||
for _, result := range response.Results {
|
||||
if result.Score <= 0 || result.Score > 1 {
|
||||
continue
|
||||
}
|
||||
for _, recording := range result.Recordings {
|
||||
id, err := uuid.Parse(recording.ID)
|
||||
if err == nil && id != uuid.Nil && result.Score > best[id.String()] {
|
||||
best[id.String()] = result.Score
|
||||
}
|
||||
}
|
||||
}
|
||||
out := []Match{}
|
||||
for id, score := range best {
|
||||
out = append(out, Match{RecordingID: id, Score: score})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in new issue
Block a user