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