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
127 lines
3.7 KiB
Go
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
|
|
}
|