Files
pipu-music/services/api/internal/acoustid/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

127 lines
3.7 KiB
Go

// 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
}