From f13da6279761bbbd21c8850bafc89465ce8d27a1 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 23:16:52 -0400
Subject: [PATCH 1/5] feat(library): AcoustID lookup client and the compressed
fpcalc print (M401 #3919)
internal/acoustid posts AcoustID's v2 lookup as a gzip form with
meta=recordings, one request per 400ms (their limit is 3/s) and a 20s
deadline. It returns every linked recording with its best score; choosing
among them is the worker's job (D5). The server's error codes map to an
invalid key (stop and say so), a rejected fingerprint (a verdict on the
track) or unavailable (try again later). A cancelled caller stays a
cancellation.
fpcalcLookupArgs and parseFpcalcCompressed read fpcalc's default output,
the compressed string the lookup takes (D1), always over the first 120s.
Co-Authored-By: Claude Opus 5.5
---
internal/acoustid/client.go | 267 +++++++++++++++++++++++++++
internal/acoustid/client_test.go | 220 ++++++++++++++++++++++
internal/library/fingerprint.go | 73 ++++++++
internal/library/fingerprint_test.go | 61 ++++++
4 files changed, 621 insertions(+)
create mode 100644 internal/acoustid/client.go
create mode 100644 internal/acoustid/client_test.go
diff --git a/internal/acoustid/client.go b/internal/acoustid/client.go
new file mode 100644
index 00000000..d6153dea
--- /dev/null
+++ b/internal/acoustid/client.go
@@ -0,0 +1,267 @@
+// Package acoustid looks up MusicBrainz recording ids by acoustic fingerprint
+// through the AcoustID web service (M401).
+//
+// What it relies on, from https://acoustid.org/webservice (read 2026-10-06):
+// - POST https://api.acoustid.org/v2/lookup with client, duration (whole
+// seconds), fingerprint (fpcalc's compressed string) and meta. A gzip body
+// with Content-Encoding: gzip is accepted and preferred, because
+// fingerprints are long.
+// - meta=recordings returns each recording's title, duration and artists,
+// which the worker needs to tell several candidates apart (M401 D5).
+// - "Do not make more than 3 requests per second."
+// - The service is free for non-commercial use only.
+//
+// Error codes are the server's own, from acoustid-server's
+// acoustid/api/errors.py.
+package acoustid
+
+import (
+ "bytes"
+ "cmp"
+ "compress/gzip"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "slices"
+ "strconv"
+ "sync"
+ "time"
+)
+
+// DefaultBaseURL is the lookup endpoint.
+const DefaultBaseURL = "https://api.acoustid.org/v2/lookup"
+
+const (
+ // minInterval keeps to AcoustID's 3 requests a second with headroom: one
+ // every 400ms is 2.5 a second.
+ minInterval = 400 * time.Millisecond
+ // lookupTimeout bounds one request, connection included (rule 156). A
+ // lookup answers in well under a second; 20s is the line past which slow
+ // has become never.
+ lookupTimeout = 20 * time.Second
+ // bodyLimit caps the response read. A lookup with meta=recordings for a
+ // popular song runs to tens of kilobytes.
+ bodyLimit = 2 << 20
+ userAgent = "Minstrel ( https://git.fabledsword.com/bvandeusen/minstrel )"
+)
+
+// The server's error codes that the worker treats differently.
+const (
+ codeInvalidFingerprint = 3
+ codeInvalidAPIKey = 4
+ codeServiceUnavailable = 13
+ codeTooManyRequests = 14
+ codeUnknownApplication = 17
+)
+
+var (
+ // ErrInvalidKey: the operator's application key was refused. No lookup
+ // can succeed until it is changed, so the worker stops and says so.
+ ErrInvalidKey = errors.New("acoustid: the API key was refused")
+ // ErrInvalidFingerprint: AcoustID rejected this track's fingerprint. A
+ // verdict on the track, not on the service.
+ ErrInvalidFingerprint = errors.New("acoustid: the fingerprint was rejected")
+ // ErrUnavailable: the service could not answer now (unreachable, timed
+ // out, overloaded or rate limiting). Says nothing about the track, so the
+ // lookup is tried again later.
+ ErrUnavailable = errors.New("acoustid: the service is unavailable")
+)
+
+// Recording is one MusicBrainz recording a fingerprint matched.
+type Recording struct {
+ ID string
+ Title string
+ // DurationSec is MusicBrainz's length for the recording; 0 when unknown.
+ DurationSec int
+ Artists []string
+}
+
+// Candidate is a recording with the score of the best AcoustID result that
+// linked to it.
+type Candidate struct {
+ Recording
+ Score float64
+}
+
+// Client calls the lookup endpoint, at most one request per minInterval.
+type Client struct {
+ baseURL string
+ http *http.Client
+
+ mu sync.Mutex
+ lastCall time.Time
+}
+
+// New returns a client for baseURL (DefaultBaseURL in production).
+func New(baseURL string) *Client {
+ return &Client{baseURL: baseURL, http: &http.Client{Timeout: lookupTimeout}}
+}
+
+// Lookup asks which recordings match fingerprint, a compressed chromaprint
+// of audio durationSec long. It returns every candidate, best score first;
+// choosing among them is the caller's job. No match is an empty slice and
+// no error.
+func (c *Client) Lookup(ctx context.Context, apiKey, fingerprint string, durationSec int) ([]Candidate, error) {
+ if err := c.wait(ctx); err != nil {
+ return nil, err
+ }
+ body, err := gzipForm(url.Values{
+ "client": {apiKey},
+ "duration": {strconv.Itoa(durationSec)},
+ "fingerprint": {fingerprint},
+ "meta": {"recordings"},
+ "format": {"json"},
+ })
+ if err != nil {
+ return nil, err
+ }
+ reqCtx, cancel := context.WithTimeout(ctx, lookupTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.baseURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("acoustid: build request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Encoding", "gzip")
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("User-Agent", userAgent)
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ // The caller giving up is not the service failing.
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit))
+ if err != nil {
+ return nil, fmt.Errorf("%w: read body: %v", ErrUnavailable, err)
+ }
+ return parseLookup(resp.StatusCode, raw)
+}
+
+// wait blocks until minInterval has passed since the last request, then
+// claims the slot.
+func (c *Client) wait(ctx context.Context) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if d := time.Until(c.lastCall.Add(minInterval)); d > 0 {
+ t := time.NewTimer(d)
+ defer t.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-t.C:
+ }
+ }
+ c.lastCall = time.Now()
+ return nil
+}
+
+func gzipForm(v url.Values) ([]byte, error) {
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+ if _, err := zw.Write([]byte(v.Encode())); err != nil {
+ return nil, fmt.Errorf("acoustid: compress request: %w", err)
+ }
+ if err := zw.Close(); err != nil {
+ return nil, fmt.Errorf("acoustid: compress request: %w", err)
+ }
+ return buf.Bytes(), nil
+}
+
+type lookupResponse struct {
+ Status string `json:"status"`
+ Error *struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ Results []struct {
+ ID string `json:"id"`
+ Score float64 `json:"score"`
+ Recordings []struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Duration float64 `json:"duration"`
+ Artists []struct {
+ Name string `json:"name"`
+ } `json:"artists"`
+ } `json:"recordings"`
+ } `json:"results"`
+}
+
+// parseLookup reads a lookup response. AcoustID reports its errors in the
+// body with a code, alongside a 4xx or 5xx status, so the body is read first
+// and the status only decides when the body says nothing usable.
+func parseLookup(status int, raw []byte) ([]Candidate, error) {
+ var r lookupResponse
+ if err := json.Unmarshal(raw, &r); err != nil {
+ if status != http.StatusOK {
+ return nil, fmt.Errorf("%w: status %d", ErrUnavailable, status)
+ }
+ return nil, fmt.Errorf("%w: unreadable response: %v", ErrUnavailable, err)
+ }
+ if r.Status != "ok" {
+ return nil, lookupError(status, r)
+ }
+ return candidates(r), nil
+}
+
+func lookupError(status int, r lookupResponse) error {
+ if r.Error == nil {
+ return fmt.Errorf("%w: status %d", ErrUnavailable, status)
+ }
+ switch r.Error.Code {
+ case codeInvalidAPIKey, codeUnknownApplication:
+ return fmt.Errorf("%w: %s", ErrInvalidKey, r.Error.Message)
+ case codeInvalidFingerprint:
+ return fmt.Errorf("%w: %s", ErrInvalidFingerprint, r.Error.Message)
+ case codeServiceUnavailable, codeTooManyRequests:
+ return fmt.Errorf("%w: %s", ErrUnavailable, r.Error.Message)
+ }
+ if status >= http.StatusInternalServerError {
+ return fmt.Errorf("%w: %s", ErrUnavailable, r.Error.Message)
+ }
+ // Any other code is a request this client built wrongly. Retrying would
+ // repeat it, so it is named rather than folded into unavailable.
+ return fmt.Errorf("acoustid: error %d: %s", r.Error.Code, r.Error.Message)
+}
+
+// candidates flattens the results into one entry per recording. The same
+// recording can hang off several AcoustID results (one audio, fingerprinted
+// from different sources); it keeps the best score it was seen with.
+// Results with no linked recording are fingerprints AcoustID knows but nobody
+// has tied to MusicBrainz, and carry nothing to fill.
+func candidates(r lookupResponse) []Candidate {
+ out := []Candidate{}
+ at := map[string]int{}
+ for _, res := range r.Results {
+ for _, rec := range res.Recordings {
+ if rec.ID == "" {
+ continue
+ }
+ if i, ok := at[rec.ID]; ok {
+ out[i].Score = max(out[i].Score, res.Score)
+ continue
+ }
+ c := Candidate{
+ Recording: Recording{ID: rec.ID, Title: rec.Title, DurationSec: int(rec.Duration + 0.5)},
+ Score: res.Score,
+ }
+ for _, a := range rec.Artists {
+ c.Artists = append(c.Artists, a.Name)
+ }
+ at[rec.ID] = len(out)
+ out = append(out, c)
+ }
+ }
+ // Best first, keeping AcoustID's order among equals.
+ slices.SortStableFunc(out, func(a, b Candidate) int { return cmp.Compare(b.Score, a.Score) })
+ return out
+}
diff --git a/internal/acoustid/client_test.go b/internal/acoustid/client_test.go
new file mode 100644
index 00000000..3226fcc4
--- /dev/null
+++ b/internal/acoustid/client_test.go
@@ -0,0 +1,220 @@
+package acoustid
+
+import (
+ "compress/gzip"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+// docSample is the meta=recordings response printed in AcoustID's own
+// documentation (https://acoustid.org/webservice), artists and release groups
+// as given.
+const docSample = `{
+ "status": "ok",
+ "results": [{
+ "score": 1.0,
+ "id": "9ff43b6a-4f16-427c-93c2-92307ca505e0",
+ "recordings": [{
+ "duration": 639,
+ "releasegroups": [{"type": "Album", "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", "title": "Before the Dawn Heals Us"}],
+ "title": "Lower Your Eyelids to Die With the Sun",
+ "id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff",
+ "artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}]
+ }]
+ }]
+}`
+
+// severalSample is the shape the worker has to disambiguate: one audio linked
+// to two recordings (an album and a single release), the album recording
+// reached again through a weaker second result, and a third result that
+// AcoustID knows but nobody has tied to MusicBrainz.
+const severalSample = `{
+ "status": "ok",
+ "results": [
+ {"score": 0.71, "id": "a2", "recordings": [{"id": "rec-album", "title": "Song", "duration": 200.4}]},
+ {"score": 0.93, "id": "a1", "recordings": [
+ {"id": "rec-album", "title": "Song", "duration": 200.4, "artists": [{"name": "A"}, {"name": "B"}]},
+ {"id": "rec-single", "title": "Song (radio edit)", "duration": 181}
+ ]},
+ {"score": 0.88, "id": "a3"}
+ ]
+}`
+
+func TestParseLookup(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ want []Candidate
+ }{
+ {"single match", docSample, []Candidate{{
+ Recording: Recording{
+ ID: "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", Title: "Lower Your Eyelids to Die With the Sun",
+ DurationSec: 639, Artists: []string{"M83"},
+ },
+ Score: 1.0,
+ }}},
+ {"several recordings", severalSample, []Candidate{
+ {Recording: Recording{ID: "rec-album", Title: "Song", DurationSec: 200}, Score: 0.93},
+ {Recording: Recording{ID: "rec-single", Title: "Song (radio edit)", DurationSec: 181}, Score: 0.93},
+ }},
+ // Choosing a threshold is the worker's; the parser carries the score.
+ {"below threshold", `{"status":"ok","results":[{"score":0.31,"id":"x","recordings":[{"id":"rec"}]}]}`,
+ []Candidate{{Recording: Recording{ID: "rec"}, Score: 0.31}}},
+ {"no results", `{"status":"ok","results":[]}`, []Candidate{}},
+ {"results with no recordings", `{"status":"ok","results":[{"score":0.99,"id":"x"}]}`, []Candidate{}},
+ }
+ for _, c := range cases {
+ got, err := parseLookup(http.StatusOK, []byte(c.body))
+ if err != nil {
+ t.Errorf("%s: err = %v", c.name, err)
+ continue
+ }
+ if !sameCandidates(got, c.want) {
+ t.Errorf("%s:\n got %+v\nwant %+v", c.name, got, c.want)
+ }
+ }
+}
+
+// The first result's recording is the album one with both artists; the
+// weaker repeat must not replace its fields, only fail to lower its score.
+func TestParseLookup_RepeatedRecordingKeepsBestScoreAndFirstFields(t *testing.T) {
+ got, err := parseLookup(http.StatusOK, []byte(severalSample))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got[0].ID != "rec-album" || got[0].Score != 0.93 {
+ t.Errorf("album recording = %+v, want score 0.93", got[0])
+ }
+}
+
+func TestParseLookup_Errors(t *testing.T) {
+ cases := []struct {
+ name string
+ status int
+ body string
+ want error
+ }{
+ {"invalid key", 400, `{"status":"error","error":{"code":4,"message":"invalid API key"}}`, ErrInvalidKey},
+ {"unknown application", 400, `{"status":"error","error":{"code":17,"message":"unknown application"}}`, ErrInvalidKey},
+ {"bad fingerprint", 400, `{"status":"error","error":{"code":3,"message":"invalid fingerprint"}}`, ErrInvalidFingerprint},
+ {"rate limited", 429, `{"status":"error","error":{"code":14,"message":"rate limit (3.000000 requests per second) exceeded, try again later"}}`, ErrUnavailable},
+ {"down", 503, `{"status":"error","error":{"code":13,"message":"service currently unavailable, try again later"}}`, ErrUnavailable},
+ {"internal error", 500, `{"status":"error","error":{"code":5,"message":"internal error"}}`, ErrUnavailable},
+ {"proxy page", 502, `Bad Gateway`, ErrUnavailable},
+ }
+ for _, c := range cases {
+ _, err := parseLookup(c.status, []byte(c.body))
+ if !errors.Is(err, c.want) {
+ t.Errorf("%s: err = %v, want %v", c.name, err, c.want)
+ }
+ }
+
+ // A code this client caused (here a missing parameter) is neither the
+ // key nor the service, so it must not read as either: retrying repeats it.
+ _, err := parseLookup(400, []byte(`{"status":"error","error":{"code":2,"message":"missing required parameter \"fingerprint\""}}`))
+ if err == nil || errors.Is(err, ErrUnavailable) || errors.Is(err, ErrInvalidKey) || !strings.Contains(err.Error(), "missing required parameter") {
+ t.Errorf("missing parameter: err = %v", err)
+ }
+}
+
+// The request is what AcoustID documents: a gzip form POST carrying the key,
+// whole-second duration, fingerprint and meta=recordings.
+func TestLookup_SendsACompressedForm(t *testing.T) {
+ var form url.Values
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.Header.Get("Content-Encoding") != "gzip" {
+ t.Errorf("request %s with Content-Encoding %q, want a gzip POST", r.Method, r.Header.Get("Content-Encoding"))
+ }
+ zr, err := gzip.NewReader(r.Body)
+ if err != nil {
+ t.Fatalf("body is not gzip: %v", err)
+ }
+ raw, _ := io.ReadAll(zr)
+ form, _ = url.ParseQuery(string(raw))
+ _, _ = io.WriteString(w, docSample)
+ }))
+ defer srv.Close()
+
+ got, err := New(srv.URL).Lookup(context.Background(), "key123", "AQADtE", 241)
+ if err != nil || len(got) != 1 {
+ t.Fatalf("Lookup = %+v, %v", got, err)
+ }
+ for k, want := range map[string]string{
+ "client": "key123", "duration": "241", "fingerprint": "AQADtE", "meta": "recordings", "format": "json",
+ } {
+ if form.Get(k) != want {
+ t.Errorf("form %s = %q, want %q", k, form.Get(k), want)
+ }
+ }
+}
+
+func TestLookup_DeadlineIsUnavailable(t *testing.T) {
+ release := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-release }))
+ defer srv.Close()
+ defer close(release)
+
+ c := New(srv.URL)
+ c.http.Timeout = 50 * time.Millisecond
+ _, err := c.Lookup(context.Background(), "k", "f", 1)
+ if !errors.Is(err, ErrUnavailable) {
+ t.Errorf("err = %v, want ErrUnavailable", err)
+ }
+}
+
+// A cancelled caller is reported as such, never as the service being down.
+func TestLookup_CancelledCallerIsNotUnavailable(t *testing.T) {
+ release := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-release }))
+ defer srv.Close()
+ defer close(release)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ _, err := New(srv.URL).Lookup(ctx, "k", "f", 1)
+ if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrUnavailable) {
+ t.Errorf("err = %v, want the caller's own deadline", err)
+ }
+}
+
+func TestLookup_KeepsToTheRateLimit(t *testing.T) {
+ var at []time.Time
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ at = append(at, time.Now())
+ _, _ = io.WriteString(w, `{"status":"ok","results":[]}`)
+ }))
+ defer srv.Close()
+
+ c := New(srv.URL)
+ for range 3 {
+ if _, err := c.Lookup(context.Background(), "k", "f", 1); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for i := 1; i < len(at); i++ {
+ // A little slack for the server's own clock reading.
+ if gap := at[i].Sub(at[i-1]); gap < minInterval-10*time.Millisecond {
+ t.Errorf("requests %d and %d were %s apart, want at least %s", i-1, i, gap, minInterval)
+ }
+ }
+}
+
+func sameCandidates(a, b []Candidate) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i].ID != b[i].ID || a[i].Title != b[i].Title || a[i].DurationSec != b[i].DurationSec ||
+ a[i].Score != b[i].Score || strings.Join(a[i].Artists, "|") != strings.Join(b[i].Artists, "|") {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/library/fingerprint.go b/internal/library/fingerprint.go
index 4a33117d..c890842b 100644
--- a/internal/library/fingerprint.go
+++ b/internal/library/fingerprint.go
@@ -111,6 +111,32 @@ func fpcalcArgs(path string, lengthSec int) []string {
}
}
+// lookupFingerprintLengthSec is how much audio the AcoustID lookup print
+// covers (M401). Fixed at fpcalc's default rather than following the
+// duplicate-detection length setting: AcoustID's database is built from
+// prints that taggers such as Picard take at the default, and a lookup print
+// of a different span matches it less well.
+const lookupFingerprintLengthSec = 120
+
+// fpcalcLookupArgs asks for the COMPRESSED fingerprint, fpcalc's default
+// output and the string AcoustID's lookup takes. A second decode beside the
+// raw print, chosen over re-encoding the stored raw array in Go (M401 D1):
+// fpcalc is the reference encoder, while a subtle bug in a port would still
+// produce a well-formed string that AcoustID answers with "no match".
+func fpcalcLookupArgs(path string) []string {
+ return []string{
+ "-length", strconv.Itoa(lookupFingerprintLengthSec),
+ path,
+ }
+}
+
+// lookupFingerprint is what an AcoustID lookup sends for one file.
+type lookupFingerprint struct {
+ fingerprint string
+ // durationSec is the whole file's length, not the span fingerprinted.
+ durationSec int
+}
+
// fingerprintResult is one attempt at both halves of a track's identity. They
// fail independently: a file ffmpeg can demux may still defeat fpcalc.
type fingerprintResult struct {
@@ -337,3 +363,50 @@ func parseFpcalcRaw(out []byte) ([]int32, error) {
}
return nil, errors.New("fpcalc printed no FINGERPRINT= line")
}
+
+// parseFpcalcCompressed reads fpcalc's default text output:
+//
+// DURATION=
+// FINGERPRINT=
+//
+// The fingerprint is kept as the opaque string fpcalc printed. It is checked
+// only for being non-empty and in fpcalc's alphabet, which catches a -raw
+// list or a truncated line; whether it is a GOOD print is AcoustID's to judge.
+func parseFpcalcCompressed(out []byte) (lookupFingerprint, error) {
+ var fp lookupFingerprint
+ var sawDuration bool
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimSpace(line)
+ if v, ok := strings.CutPrefix(line, "DURATION="); ok {
+ d, err := strconv.Atoi(v)
+ if err != nil {
+ return lookupFingerprint{}, fmt.Errorf("fpcalc duration %q: %w", v, err)
+ }
+ fp.durationSec, sawDuration = d, true
+ }
+ if v, ok := strings.CutPrefix(line, "FINGERPRINT="); ok {
+ fp.fingerprint = v
+ }
+ }
+ switch {
+ case fp.fingerprint == "":
+ return lookupFingerprint{}, errors.New("fpcalc printed no fingerprint")
+ case strings.IndexFunc(fp.fingerprint, notURLBase64) >= 0:
+ return lookupFingerprint{}, fmt.Errorf("fpcalc fingerprint %.20q… is not a compressed print", fp.fingerprint)
+ case !sawDuration:
+ return lookupFingerprint{}, errors.New("fpcalc printed no DURATION= line")
+ // AcoustID refuses a duration that is not a positive integer, and a file
+ // under a second long has nothing a lookup could match.
+ case fp.durationSec <= 0:
+ return lookupFingerprint{}, fmt.Errorf("fpcalc duration %d is too short to look up", fp.durationSec)
+ }
+ return fp, nil
+}
+
+func notURLBase64(r rune) bool {
+ switch {
+ case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
+ return false
+ }
+ return true
+}
diff --git a/internal/library/fingerprint_test.go b/internal/library/fingerprint_test.go
index e4b5cee1..a2bf4d56 100644
--- a/internal/library/fingerprint_test.go
+++ b/internal/library/fingerprint_test.go
@@ -60,6 +60,67 @@ func TestParseFpcalcRaw(t *testing.T) {
}
}
+// The lookup print is fpcalc's default output, kept as the string it printed.
+func TestParseFpcalcCompressed(t *testing.T) {
+ cases := []struct {
+ name string
+ out string
+ want lookupFingerprint
+ wantErr string
+ }{
+ {
+ name: "default output",
+ out: "DURATION=641\nFINGERPRINT=AQABz0qUkZK4oOfhL-CPc4e5C_wW2H2QH9uDL4cvoT8UNQ-eHtsE8cceeFJx\n",
+ want: lookupFingerprint{fingerprint: "AQABz0qUkZK4oOfhL-CPc4e5C_wW2H2QH9uDL4cvoT8UNQ-eHtsE8cceeFJx", durationSec: 641},
+ },
+ {
+ name: "line order does not matter",
+ out: "FINGERPRINT=AQAB\nDURATION=3\n",
+ want: lookupFingerprint{fingerprint: "AQAB", durationSec: 3},
+ },
+ // A -raw invocation by mistake: a list of numbers is not a compressed
+ // print, and sent to AcoustID it would only come back as "no match".
+ {name: "raw list is refused", out: "DURATION=213\nFINGERPRINT=-1453821711,17\n", wantErr: "not a compressed print"},
+ {name: "standard base64 is refused", out: "DURATION=213\nFINGERPRINT=AQAB+/==\n", wantErr: "not a compressed print"},
+ {name: "empty fingerprint", out: "DURATION=213\nFINGERPRINT=\n", wantErr: "no fingerprint"},
+ {name: "no duration", out: "FINGERPRINT=AQAB\n", wantErr: "no DURATION= line"},
+ {name: "zero duration", out: "DURATION=0\nFINGERPRINT=AQAB\n", wantErr: "too short"},
+ {name: "fractional duration", out: "DURATION=12.5\nFINGERPRINT=AQAB\n", wantErr: "duration"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := parseFpcalcCompressed([]byte(tc.out))
+ if tc.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("err = %v, want one containing %q", err, tc.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if got != tc.want {
+ t.Fatalf("got %+v, want %+v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestFpcalcLookupArgs_RequestCompressedOutput(t *testing.T) {
+ args := fpcalcLookupArgs("/music/a.flac")
+ for _, flag := range []string{"-raw", "-signed"} {
+ if slices.Contains(args, flag) {
+ t.Errorf("fpcalcLookupArgs has %s, so fpcalc would not print the compressed form: %v", flag, args)
+ }
+ }
+ if !followedBy(args, "-length", "120") {
+ t.Errorf("fpcalcLookupArgs does not fingerprint fpcalc's default 120s: %v", args)
+ }
+ if args[len(args)-1] != "/music/a.flac" {
+ t.Errorf("path is not last: %v", args)
+ }
+}
+
func TestParseStreamHash(t *testing.T) {
// The real value ffmpeg printed for both files of the #3885 pair.
const www = "24e2daa3b4a534ff1a8d1a76f67810205869daf89f728d83a16625da4d28a18e"
From 3c575b137c121adb049d0b5929905789efc440e7 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 23:26:03 -0400
Subject: [PATCH 2/5] feat(library): AcoustID lookup worker fills the MBIDs
tags leave empty (M401 #3920 #3921)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Migration 0069 adds tracks.mbid_source (tag | acoustid), a lookup state per
track (matched | ambiguous | no_match | failed) and the acoustid_settings
row (off, no key, min score 0.85).
The file's tag outranks a lookup (D4). UpsertTrack keeps a looked-up id
through a re-read that finds no tag id and replaces it as soon as one
appears. SetTrackMbidFromAcoustID refuses to write over a tag id.
The worker fingerprints each untagged track with fpcalc's compressed
print, looks it up and writes an id only when D5 settles it: one
recording at or above the threshold, or one left after matching title and
length. Ambiguous and no-match results write nothing. A key AcoustID
refuses, or the service being unreachable, stops the pass and is reported
in the worker's status. It never counts as a verdict on a track.
A changed file drops its lookup in the scan. The re-lookup takes back an
id that no longer matches.
Admin API: GET /api/admin/library/acoustid (settings, status, coverage by
source), PUT …/acoustid-settings (write-only key), POST …/acoustid/run,
GET …/acoustid/unsettled.
Co-Authored-By: Claude Opus 5.5
---
cmd/minstrel/main.go | 16 +
internal/api/admin_acoustid.go | 171 +++++++
internal/api/api.go | 10 +-
internal/api/library_test.go | 2 +-
internal/db/dbq/acoustid.sql.go | 309 ++++++++++++
internal/db/dbq/events.sql.go | 3 +-
internal/db/dbq/history.sql.go | 3 +-
internal/db/dbq/likes.sql.go | 3 +-
internal/db/dbq/merge.sql.go | 2 +-
internal/db/dbq/models.go | 19 +
internal/db/dbq/recommendation.sql.go | 12 +-
internal/db/dbq/tracks.sql.go | 39 +-
.../migrations/0069_acoustid_lookup.down.sql | 7 +
.../db/migrations/0069_acoustid_lookup.up.sql | 69 +++
internal/db/queries/acoustid.sql | 99 ++++
internal/db/queries/merge.sql | 2 +-
internal/db/queries/tracks.sql | 15 +-
internal/library/acoustid_lookup.go | 454 ++++++++++++++++++
internal/library/acoustid_lookup_test.go | 371 ++++++++++++++
internal/library/acoustid_settings.go | 132 +++++
internal/library/duplicate_merge_test.go | 2 +-
internal/library/fingerprint.go | 12 +
internal/library/scanner.go | 6 +
internal/server/server.go | 14 +-
24 files changed, 1743 insertions(+), 29 deletions(-)
create mode 100644 internal/api/admin_acoustid.go
create mode 100644 internal/db/dbq/acoustid.sql.go
create mode 100644 internal/db/migrations/0069_acoustid_lookup.down.sql
create mode 100644 internal/db/migrations/0069_acoustid_lookup.up.sql
create mode 100644 internal/db/queries/acoustid.sql
create mode 100644 internal/library/acoustid_lookup.go
create mode 100644 internal/library/acoustid_lookup_test.go
create mode 100644 internal/library/acoustid_settings.go
diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go
index 7652bd2f..92a7ac43 100644
--- a/cmd/minstrel/main.go
+++ b/cmd/minstrel/main.go
@@ -14,6 +14,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
@@ -139,6 +140,15 @@ func run() error {
logger.Warn("loudness settings: using defaults", "err", loudErr)
}
+ // AcoustID lookup settings (M401): shared by the lookup worker and the
+ // admin API. A load failure leaves the lookup off.
+ acoustIDSettings, acoustIDErr := library.NewAcoustIDSettingsService(ctx, pool)
+ if acoustIDErr != nil {
+ logger.Warn("acoustid settings: using defaults (lookup off)", "err", acoustIDErr)
+ }
+ acoustIDLookup := library.NewAcoustIDLookupWorker(pool, logger.With("component", "acoustid_lookup"),
+ acoustIDSettings, acoustid.New(acoustid.DefaultBaseURL))
+
contact := cfg.Library.ContactEmail
if contact == "" {
contact = "https://git.fabledsword.com/bvandeusen/minstrel"
@@ -240,6 +250,11 @@ func run() error {
// measurement. See internal/library/loudness_backfill.go.
go library.NewLoudnessBackfillWorker(pool, logger.With("component", "loudness_backfill"), loudSettings).Run(ctx)
+ // AcoustID lookup (M401): fills the recording MBIDs untagged tracks lack,
+ // so the similarity arms can see them. Idle until the operator adds a key
+ // and switches it on. See internal/library/acoustid_lookup.go.
+ go acoustIDLookup.Run(ctx)
+
// Duplicate sweep (M400 #3910): proposes groups of tracks holding one
// recording, from the fingerprints above. Sweeps only when fingerprints have
// changed since the last sweep.
@@ -390,6 +405,7 @@ func run() error {
srv.TagSettings = tagSettings
srv.FingerprintSettings = fpSettings
srv.LoudnessSettings = loudSettings
+ srv.AcoustIDLookup = acoustIDLookup
// The sweeper above holds this same instance, so a save from the admin
// card changes what it does on its next tick (#3936).
srv.ReacqSettings = reacqSettings
diff --git a/internal/api/admin_acoustid.go b/internal/api/admin_acoustid.go
new file mode 100644
index 00000000..6a3a433a
--- /dev/null
+++ b/internal/api/admin_acoustid.go
@@ -0,0 +1,171 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "time"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/library"
+)
+
+// AcoustID recording-id lookup, admin side (M401 #3922).
+
+// acoustIDUnsettledLimit caps the admin list of tracks a lookup could not
+// settle. It is a list to read, not to page through.
+const acoustIDUnsettledLimit = 200
+
+// acoustIDSettingsResp never carries the key, only whether one is stored.
+type acoustIDSettingsResp struct {
+ Enabled bool `json:"enabled"`
+ MinScore float32 `json:"min_score"`
+ APIKeySet bool `json:"api_key_set"`
+}
+
+func acoustIDSettingsRespOf(s library.AcoustIDSettings) acoustIDSettingsResp {
+ return acoustIDSettingsResp{Enabled: s.Enabled, MinScore: s.MinScore, APIKeySet: s.APIKey != ""}
+}
+
+type acoustIDStatusResp struct {
+ Running bool `json:"running"`
+ LastPassAt *time.Time `json:"last_pass_at"`
+ // Problem is why the last pass stopped short, in words for the card.
+ Problem string `json:"problem"`
+}
+
+// acoustIDCoverageResp: from_tag + from_acoustid + the tracks with no MBID =
+// total. ambiguous, no_match and failed are lookup states of tracks with no
+// MBID; pending is what the worker still has to look at.
+type acoustIDCoverageResp struct {
+ Total int64 `json:"total"`
+ FromTag int64 `json:"from_tag"`
+ FromAcoustID int64 `json:"from_acoustid"`
+ Ambiguous int64 `json:"ambiguous"`
+ NoMatch int64 `json:"no_match"`
+ Failed int64 `json:"failed"`
+ Pending int64 `json:"pending"`
+}
+
+type acoustIDResp struct {
+ Settings acoustIDSettingsResp `json:"settings"`
+ // Ready is whether the worker will call AcoustID at all: switched on
+ // and holding a key.
+ Ready bool `json:"ready"`
+ Status acoustIDStatusResp `json:"status"`
+ Coverage acoustIDCoverageResp `json:"coverage"`
+}
+
+// handleGetAcoustID implements GET /api/admin/library/acoustid: the settings,
+// what the worker is doing, and how much of the library has an MBID by source.
+func (h *handlers) handleGetAcoustID(w http.ResponseWriter, r *http.Request) {
+ row, err := library.AcoustIDCoverage(r.Context(), h.pool)
+ if err != nil {
+ writeErrWithLog(w, h.logger, "admin: get acoustid coverage", apierror.InternalMsg("lookup failed", err))
+ return
+ }
+ cfg := h.acoustIDLookup.Settings().Get()
+ st := h.acoustIDLookup.Status()
+ resp := acoustIDResp{
+ Settings: acoustIDSettingsRespOf(cfg),
+ Ready: cfg.Ready(),
+ Status: acoustIDStatusResp{Running: st.Running, Problem: st.Problem},
+ Coverage: acoustIDCoverageResp{
+ Total: row.Total,
+ FromTag: row.FromTag,
+ FromAcoustID: row.FromAcoustid,
+ Ambiguous: row.Ambiguous,
+ NoMatch: row.NoMatch,
+ Failed: row.Failed,
+ Pending: row.Pending,
+ },
+ }
+ if !st.LastPassAt.IsZero() {
+ at := st.LastPassAt.UTC()
+ resp.Status.LastPassAt = &at
+ }
+ writeJSON(w, http.StatusOK, resp)
+}
+
+// acoustIDSettingsReq is the PUT body. api_key left out keeps the stored key;
+// an empty string clears it.
+type acoustIDSettingsReq struct {
+ Enabled bool `json:"enabled"`
+ MinScore float32 `json:"min_score"`
+ APIKey *string `json:"api_key"`
+}
+
+// handleUpdateAcoustIDSettings implements PUT /api/admin/library/acoustid-settings.
+func (h *handlers) handleUpdateAcoustIDSettings(w http.ResponseWriter, r *http.Request) {
+ var req acoustIDSettingsReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
+ return
+ }
+ saved, err := h.acoustIDLookup.Settings().Set(r.Context(), library.AcoustIDSettingsUpdate{
+ Enabled: req.Enabled, MinScore: req.MinScore, APIKey: req.APIKey,
+ })
+ if err != nil {
+ if errors.Is(err, library.ErrAcoustIDSettingOutOfRange) {
+ writeErr(w, apierror.BadRequest("invalid_setting", err.Error()))
+ return
+ }
+ writeErrWithLog(w, h.logger, "admin acoustid settings: update failed", apierror.Internal(err))
+ return
+ }
+ writeJSON(w, http.StatusOK, acoustIDSettingsRespOf(saved))
+}
+
+// handleRunAcoustIDLookup implements POST /api/admin/library/acoustid/run:
+// start a pass now rather than at the next tick. 409 when the lookup is off
+// or has no key, so the card can say so rather than appear to do nothing.
+func (h *handlers) handleRunAcoustIDLookup(w http.ResponseWriter, _ *http.Request) {
+ if !h.acoustIDLookup.Settings().Get().Ready() {
+ writeErr(w, apierror.Conflict("not_ready",
+ "Switch the AcoustID lookup on and add an API key first."))
+ return
+ }
+ h.acoustIDLookup.Kick()
+ w.WriteHeader(http.StatusAccepted)
+}
+
+type acoustIDUnsettledResp struct {
+ TrackID string `json:"track_id"`
+ Title string `json:"title"`
+ Artist string `json:"artist"`
+ Album string `json:"album"`
+ FilePath string `json:"file_path"`
+ State string `json:"state"`
+ BestScore *float32 `json:"best_score"`
+ Candidates int32 `json:"candidates"`
+ Detail *string `json:"detail"`
+ LookedUpAt time.Time `json:"looked_up_at"`
+}
+
+// handleListUnsettledAcoustID implements GET /api/admin/library/acoustid/unsettled:
+// the tracks a lookup could not settle (ambiguous or failed), so they can be
+// found and tagged by hand.
+func (h *handlers) handleListUnsettledAcoustID(w http.ResponseWriter, r *http.Request) {
+ rows, err := dbq.New(h.pool).ListUnsettledAcoustIDLookups(r.Context(), acoustIDUnsettledLimit)
+ if err != nil {
+ writeErrWithLog(w, h.logger, "admin: list unsettled acoustid lookups", apierror.InternalMsg("lookup failed", err))
+ return
+ }
+ out := make([]acoustIDUnsettledResp, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, acoustIDUnsettledResp{
+ TrackID: uuidToString(row.ID),
+ Title: row.Title,
+ Artist: row.ArtistName,
+ Album: row.AlbumTitle,
+ FilePath: row.FilePath,
+ State: row.State,
+ BestScore: row.BestScore,
+ Candidates: row.Candidates,
+ Detail: row.Detail,
+ LookedUpAt: row.LookedUpAt.Time.UTC(),
+ })
+ }
+ writeJSON(w, http.StatusOK, out)
+}
diff --git a/internal/api/api.go b/internal/api/api.go
index 55318155..9d4bebaa 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -35,7 +35,7 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
-func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService) {
+func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
rng := rand.New(rand.NewSource(rand.Int63()))
setupToken, err := auth.NewSetupToken()
if err != nil {
@@ -67,6 +67,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
reacqSettings: reacqSettings,
fingerprintSettings: fpSettings,
loudnessSettings: loudSettings,
+ acoustIDLookup: acoustIDLookup,
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
librarySize: recommendation.NewLibrarySize(nil),
loginGuard: auth.NewLoginGuard(),
@@ -251,6 +252,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/library/loudness", h.handleGetLoudnessCoverage)
admin.Get("/library/loudness-settings", h.handleGetLoudnessSettings)
admin.Put("/library/loudness-settings", h.handleUpdateLoudnessSettings)
+ admin.Get("/library/acoustid", h.handleGetAcoustID)
+ admin.Put("/library/acoustid-settings", h.handleUpdateAcoustIDSettings)
+ admin.Post("/library/acoustid/run", h.handleRunAcoustIDLookup)
+ admin.Get("/library/acoustid/unsettled", h.handleListUnsettledAcoustID)
// Duplicates report (#3912): proposals from the duplicate sweep, a
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
// the removed copies' files after moving their history onto the kept one.
@@ -348,6 +353,9 @@ type handlers struct {
// loudnessSettings is the loudness analysis policy (M464 #4995), the same
// instance the loudness backfill reads. Nil serves the defaults.
loudnessSettings *library.LoudnessSettingsService
+ // acoustIDLookup is the AcoustID lookup worker (M401): its settings, its
+ // status and "look up now". Nil serves the defaults (off).
+ acoustIDLookup *library.AcoustIDLookupWorker
// leveled renders the leveled streams handed to Sonos/UPnP speakers
// (M464 #5001). Nil when its cache directory cannot be made: a level
// request then gets the plain stream.
diff --git a/internal/api/library_test.go b/internal/api/library_test.go
index db74f2fc..7bb8dc83 100644
--- a/internal/api/library_test.go
+++ b/internal/api/library_test.go
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
- Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil)
+ Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
paths := []string{
"/api/artists",
diff --git a/internal/db/dbq/acoustid.sql.go b/internal/db/dbq/acoustid.sql.go
new file mode 100644
index 00000000..7aba72b8
--- /dev/null
+++ b/internal/db/dbq/acoustid.sql.go
@@ -0,0 +1,309 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: acoustid.sql
+
+package dbq
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const clearTrackAcoustIDMbid = `-- name: ClearTrackAcoustIDMbid :exec
+UPDATE tracks
+ SET mbid = NULL, mbid_source = NULL
+ WHERE id = $1
+ AND mbid_source = 'acoustid'
+`
+
+// A re-lookup of changed bytes that no longer matches takes back the id an
+// earlier lookup wrote. A tag id is never touched.
+func (q *Queries) ClearTrackAcoustIDMbid(ctx context.Context, id pgtype.UUID) error {
+ _, err := q.db.Exec(ctx, clearTrackAcoustIDMbid, id)
+ return err
+}
+
+const deleteAcoustIDLookup = `-- name: DeleteAcoustIDLookup :exec
+DELETE FROM track_acoustid_lookups WHERE track_id = $1
+`
+
+// The scan drops the lookup of bytes that are gone, so the worker checks the
+// new ones.
+func (q *Queries) DeleteAcoustIDLookup(ctx context.Context, trackID pgtype.UUID) error {
+ _, err := q.db.Exec(ctx, deleteAcoustIDLookup, trackID)
+ return err
+}
+
+const getAcoustIDSettings = `-- name: GetAcoustIDSettings :one
+SELECT id, enabled, api_key, min_score, updated_at FROM acoustid_settings WHERE id = true
+`
+
+func (q *Queries) GetAcoustIDSettings(ctx context.Context) (AcoustidSetting, error) {
+ row := q.db.QueryRow(ctx, getAcoustIDSettings)
+ var i AcoustidSetting
+ err := row.Scan(
+ &i.ID,
+ &i.Enabled,
+ &i.ApiKey,
+ &i.MinScore,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const getMbidCoverage = `-- name: GetMbidCoverage :one
+SELECT count(*)::bigint AS total,
+ count(*) FILTER (WHERE t.mbid_source = 'tag')::bigint AS from_tag,
+ count(*) FILTER (WHERE t.mbid_source = 'acoustid')::bigint AS from_acoustid,
+ count(*) FILTER (WHERE l.state = 'ambiguous')::bigint AS ambiguous,
+ count(*) FILTER (WHERE l.state = 'no_match')::bigint AS no_match,
+ count(*) FILTER (WHERE l.state = 'failed')::bigint AS failed,
+ count(*) FILTER (
+ WHERE l.track_id IS NULL AND (t.mbid IS NULL OR t.mbid_source = 'acoustid')
+ )::bigint AS pending
+ FROM tracks t
+ LEFT JOIN track_acoustid_lookups l ON l.track_id = t.id
+ WHERE t.missing_since IS NULL
+`
+
+type GetMbidCoverageRow struct {
+ Total int64
+ FromTag int64
+ FromAcoustid int64
+ Ambiguous int64
+ NoMatch int64
+ Failed int64
+ Pending int64
+}
+
+// The admin gauge: present tracks by where their MBID came from, and the
+// lookup states of those a lookup has seen. pending is what the worker's
+// queue holds.
+func (q *Queries) GetMbidCoverage(ctx context.Context) (GetMbidCoverageRow, error) {
+ row := q.db.QueryRow(ctx, getMbidCoverage)
+ var i GetMbidCoverageRow
+ err := row.Scan(
+ &i.Total,
+ &i.FromTag,
+ &i.FromAcoustid,
+ &i.Ambiguous,
+ &i.NoMatch,
+ &i.Failed,
+ &i.Pending,
+ )
+ return i, err
+}
+
+const listTracksNeedingAcoustIDLookup = `-- name: ListTracksNeedingAcoustIDLookup :many
+
+SELECT t.id, t.file_path, t.title, t.duration_ms
+ FROM tracks t
+ WHERE (t.mbid IS NULL OR t.mbid_source = 'acoustid')
+ AND t.missing_since IS NULL
+ AND t.id > $1::uuid
+ AND NOT EXISTS (SELECT 1 FROM track_acoustid_lookups l WHERE l.track_id = t.id)
+ ORDER BY t.id
+ LIMIT $2
+`
+
+type ListTracksNeedingAcoustIDLookupParams struct {
+ AfterID pgtype.UUID
+ BatchLimit int32
+}
+
+type ListTracksNeedingAcoustIDLookupRow struct {
+ ID pgtype.UUID
+ FilePath string
+ Title string
+ DurationMs int32
+}
+
+// AcoustID recording-id lookup (M401). Migration 0069 says what each
+// lookup state means.
+// The lookup worker's queue: present tracks with no tag MBID and no lookup
+// since their file last changed. A looked-up id is included once its row is
+// gone (the file changed), so the new bytes are checked rather than trusted.
+// Keyset-paged on id, as the loudness queue is, so a pass ends even when
+// every attempt is inconclusive and writes nothing.
+func (q *Queries) ListTracksNeedingAcoustIDLookup(ctx context.Context, arg ListTracksNeedingAcoustIDLookupParams) ([]ListTracksNeedingAcoustIDLookupRow, error) {
+ rows, err := q.db.Query(ctx, listTracksNeedingAcoustIDLookup, arg.AfterID, arg.BatchLimit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListTracksNeedingAcoustIDLookupRow
+ for rows.Next() {
+ var i ListTracksNeedingAcoustIDLookupRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.FilePath,
+ &i.Title,
+ &i.DurationMs,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listUnsettledAcoustIDLookups = `-- name: ListUnsettledAcoustIDLookups :many
+SELECT t.id, t.title, t.file_path, ar.name AS artist_name, al.title AS album_title,
+ l.state, l.best_score, l.candidates, l.detail, l.looked_up_at
+ FROM track_acoustid_lookups l
+ JOIN tracks t ON t.id = l.track_id
+ JOIN artists ar ON ar.id = t.artist_id
+ JOIN albums al ON al.id = t.album_id
+ WHERE l.state IN ('ambiguous', 'failed')
+ AND t.missing_since IS NULL
+ ORDER BY ar.name, al.title, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
+ LIMIT $1
+`
+
+type ListUnsettledAcoustIDLookupsRow struct {
+ ID pgtype.UUID
+ Title string
+ FilePath string
+ ArtistName string
+ AlbumTitle string
+ State string
+ BestScore *float32
+ Candidates int32
+ Detail *string
+ LookedUpAt pgtype.Timestamptz
+}
+
+// The admin list of tracks a lookup could not settle (ambiguous or failed),
+// so they are findable rather than silently empty.
+func (q *Queries) ListUnsettledAcoustIDLookups(ctx context.Context, rowLimit int32) ([]ListUnsettledAcoustIDLookupsRow, error) {
+ rows, err := q.db.Query(ctx, listUnsettledAcoustIDLookups, rowLimit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListUnsettledAcoustIDLookupsRow
+ for rows.Next() {
+ var i ListUnsettledAcoustIDLookupsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Title,
+ &i.FilePath,
+ &i.ArtistName,
+ &i.AlbumTitle,
+ &i.State,
+ &i.BestScore,
+ &i.Candidates,
+ &i.Detail,
+ &i.LookedUpAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const recordAcoustIDLookup = `-- name: RecordAcoustIDLookup :exec
+INSERT INTO track_acoustid_lookups (track_id, state, best_score, candidates, recording_mbid, detail)
+VALUES ($1, $2, $3, $4,
+ $5, $6)
+ON CONFLICT (track_id) DO UPDATE SET
+ state = EXCLUDED.state,
+ best_score = EXCLUDED.best_score,
+ candidates = EXCLUDED.candidates,
+ recording_mbid = EXCLUDED.recording_mbid,
+ detail = EXCLUDED.detail,
+ looked_up_at = now()
+`
+
+type RecordAcoustIDLookupParams struct {
+ TrackID pgtype.UUID
+ State string
+ BestScore *float32
+ Candidates int32
+ RecordingMbid *string
+ Detail *string
+}
+
+func (q *Queries) RecordAcoustIDLookup(ctx context.Context, arg RecordAcoustIDLookupParams) error {
+ _, err := q.db.Exec(ctx, recordAcoustIDLookup,
+ arg.TrackID,
+ arg.State,
+ arg.BestScore,
+ arg.Candidates,
+ arg.RecordingMbid,
+ arg.Detail,
+ )
+ return err
+}
+
+const setTrackMbidFromAcoustID = `-- name: SetTrackMbidFromAcoustID :execrows
+UPDATE tracks
+ SET mbid = $1, mbid_source = 'acoustid'
+ WHERE id = $2
+ AND (mbid IS NULL OR mbid_source = 'acoustid')
+`
+
+type SetTrackMbidFromAcoustIDParams struct {
+ Mbid *string
+ ID pgtype.UUID
+}
+
+// Never over a tag id (D4): the guard is here, not only in the worker, so a
+// tag the scan read between the worker's list and this write still wins.
+// updated_at is left alone: it gates the scan's mtime skip, and moving it
+// past a file's mtime would hide a change made just before the lookup.
+func (q *Queries) SetTrackMbidFromAcoustID(ctx context.Context, arg SetTrackMbidFromAcoustIDParams) (int64, error) {
+ result, err := q.db.Exec(ctx, setTrackMbidFromAcoustID, arg.Mbid, arg.ID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const updateAcoustIDSettings = `-- name: UpdateAcoustIDSettings :one
+UPDATE acoustid_settings
+ SET enabled = $1,
+ min_score = $2,
+ api_key = CASE WHEN $3::boolean
+ THEN NULLIF($4::text, '')
+ ELSE api_key END,
+ updated_at = now()
+ WHERE id = true
+RETURNING id, enabled, api_key, min_score, updated_at
+`
+
+type UpdateAcoustIDSettingsParams struct {
+ Enabled bool
+ MinScore float32
+ SetApiKey bool
+ ApiKey string
+}
+
+// The key is write-only from the admin card: set_api_key false keeps the
+// stored one, and an empty api_key with set_api_key true clears it.
+func (q *Queries) UpdateAcoustIDSettings(ctx context.Context, arg UpdateAcoustIDSettingsParams) (AcoustidSetting, error) {
+ row := q.db.QueryRow(ctx, updateAcoustIDSettings,
+ arg.Enabled,
+ arg.MinScore,
+ arg.SetApiKey,
+ arg.ApiKey,
+ )
+ var i AcoustidSetting
+ err := row.Scan(
+ &i.ID,
+ &i.Enabled,
+ &i.ApiKey,
+ &i.MinScore,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
diff --git a/internal/db/dbq/events.sql.go b/internal/db/dbq/events.sql.go
index 64b35b72..d626f477 100644
--- a/internal/db/dbq/events.sql.go
+++ b/internal/db/dbq/events.sql.go
@@ -261,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
}
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
-SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since FROM tracks t
+SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source FROM tracks t
JOIN play_events pe ON pe.track_id = t.id
WHERE pe.session_id = $1
AND pe.started_at < $2
@@ -307,6 +307,7 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
+ &i.MbidSource,
); err != nil {
return nil, err
}
diff --git a/internal/db/dbq/history.sql.go b/internal/db/dbq/history.sql.go
index 26f99fc9..0d0a4efa 100644
--- a/internal/db/dbq/history.sql.go
+++ b/internal/db/dbq/history.sql.go
@@ -14,7 +14,7 @@ import (
const listUserHistory = `-- name: ListUserHistory :many
SELECT pe.id AS event_id,
pe.started_at,
- t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
+ t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
albums.title AS album_title,
artists.name AS artist_name
FROM play_events pe
@@ -81,6 +81,7 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
+ &i.Track.MbidSource,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
diff --git a/internal/db/dbq/likes.sql.go b/internal/db/dbq/likes.sql.go
index f5eaa5e6..047d9c50 100644
--- a/internal/db/dbq/likes.sql.go
+++ b/internal/db/dbq/likes.sql.go
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
}
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
-SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since FROM tracks t
+SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source FROM tracks t
JOIN general_likes l ON l.track_id = t.id
WHERE l.user_id = $1
ORDER BY l.liked_at DESC
@@ -301,6 +301,7 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
+ &i.MbidSource,
); err != nil {
return nil, err
}
diff --git a/internal/db/dbq/merge.sql.go b/internal/db/dbq/merge.sql.go
index 95240836..542eb77b 100644
--- a/internal/db/dbq/merge.sql.go
+++ b/internal/db/dbq/merge.sql.go
@@ -196,7 +196,7 @@ func (q *Queries) MergeCopyTrackTags(ctx context.Context, arg MergeCopyTrackTags
const mergeInheritTrackMbid = `-- name: MergeInheritTrackMbid :exec
UPDATE tracks AS survivor
- SET mbid = loser.mbid
+ SET mbid = loser.mbid, mbid_source = loser.mbid_source
FROM tracks AS loser
WHERE survivor.id = $1::uuid
AND loser.id = $2::uuid
diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go
index e62b9121..4034fb2c 100644
--- a/internal/db/dbq/models.go
+++ b/internal/db/dbq/models.go
@@ -187,6 +187,14 @@ func (ns NullLidarrRequestStatus) Value() (driver.Value, error) {
return string(ns.LidarrRequestStatus), nil
}
+type AcoustidSetting struct {
+ ID bool
+ Enabled bool
+ ApiKey *string
+ MinScore float32
+ UpdatedAt pgtype.Timestamptz
+}
+
type Album struct {
ID pgtype.UUID
Title string
@@ -720,6 +728,17 @@ type Track struct {
TagSourcesVersion int32
TagReadVersion int16
MissingSince pgtype.Timestamptz
+ MbidSource *string
+}
+
+type TrackAcoustidLookup struct {
+ TrackID pgtype.UUID
+ State string
+ BestScore *float32
+ Candidates int32
+ RecordingMbid *string
+ Detail *string
+ LookedUpAt pgtype.Timestamptz
}
type TrackFingerprint struct {
diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go
index bd798644..a1e9a59e 100644
--- a/internal/db/dbq/recommendation.sql.go
+++ b/internal/db/dbq/recommendation.sql.go
@@ -208,7 +208,7 @@ WITH plays AS (
WHERE user_id = $2 AND was_skipped = false
GROUP BY track_id
)
-SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
+SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
albums.title AS album_title,
artists.name AS artist_name
FROM plays p
@@ -270,6 +270,7 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
+ &i.Track.MbidSource,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -290,7 +291,7 @@ WITH plays AS (
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
)
-SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
+SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
albums.title AS album_title,
artists.name AS artist_name
FROM plays p
@@ -354,6 +355,7 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
+ &i.Track.MbidSource,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -691,7 +693,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
SELECT
- t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
+ t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
@@ -772,6 +774,7 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
+ &i.Track.MbidSource,
&i.IsLiked,
&i.LastPlayedAt,
&i.PlayCount,
@@ -904,7 +907,7 @@ random_fill AS (
LIMIT $9
)
SELECT
- t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
+ t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
@@ -1031,6 +1034,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
+ &i.Track.MbidSource,
&i.IsLiked,
&i.LastPlayedAt,
&i.PlayCount,
diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go
index 638a5687..979fe70b 100644
--- a/internal/db/dbq/tracks.sql.go
+++ b/internal/db/dbq/tracks.sql.go
@@ -233,7 +233,7 @@ func (q *Queries) FindMissingTrackByMbid(ctx context.Context, mbid string) ([]Fi
}
const getTrackByID = `-- name: GetTrackByID :one
-SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE id = $1
+SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE id = $1
`
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
@@ -259,12 +259,13 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
+ &i.MbidSource,
)
return i, err
}
const getTrackByPath = `-- name: GetTrackByPath :one
-SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE file_path = $1
+SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE file_path = $1
`
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
@@ -290,12 +291,13 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
+ &i.MbidSource,
)
return i, err
}
const getTracksByIDs = `-- name: GetTracksByIDs :many
-SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE id = ANY($1::uuid[])
+SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE id = ANY($1::uuid[])
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
@@ -329,6 +331,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
+ &i.MbidSource,
); err != nil {
return nil, err
}
@@ -341,7 +344,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
}
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
-SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
+SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
@@ -401,6 +404,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
+ &i.Track.MbidSource,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -501,7 +505,7 @@ func (q *Queries) ListMissingTracks(ctx context.Context, arg ListMissingTracksPa
}
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
-SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
+SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
@@ -558,6 +562,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
+ &i.Track.MbidSource,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -607,7 +612,7 @@ func (q *Queries) ListTrackPathsForReconcile(ctx context.Context) ([]ListTrackPa
}
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
-SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks
+SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks
WHERE album_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
@@ -653,6 +658,7 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
+ &i.MbidSource,
); err != nil {
return nil, err
}
@@ -726,7 +732,7 @@ func (q *Queries) MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int
}
const searchTracks = `-- name: SearchTracks :many
-SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks
+SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
@@ -779,6 +785,7 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
+ &i.MbidSource,
); err != nil {
return nil, err
}
@@ -792,7 +799,7 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
const setTrackMbidIfNull = `-- name: SetTrackMbidIfNull :exec
UPDATE tracks
- SET mbid = $2, updated_at = now()
+ SET mbid = $2, mbid_source = 'tag', updated_at = now()
WHERE id = $1 AND mbid IS NULL
`
@@ -812,8 +819,9 @@ const upsertTrack = `-- name: UpsertTrack :one
INSERT INTO tracks (
title, album_id, artist_id, track_number, disc_number,
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre,
- tag_read_version
-) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+ tag_read_version, mbid_source
+) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
+ CASE WHEN $11::text IS NOT NULL THEN 'tag' END)
ON CONFLICT (file_path) DO UPDATE SET
title = EXCLUDED.title,
album_id = EXCLUDED.album_id,
@@ -824,13 +832,17 @@ ON CONFLICT (file_path) DO UPDATE SET
file_size = EXCLUDED.file_size,
file_format = EXCLUDED.file_format,
bitrate = EXCLUDED.bitrate,
- mbid = EXCLUDED.mbid,
+ -- Both read the row as it was before this statement.
+ mbid = CASE WHEN EXCLUDED.mbid IS NULL AND tracks.mbid_source = 'acoustid'
+ THEN tracks.mbid ELSE EXCLUDED.mbid END,
+ mbid_source = CASE WHEN EXCLUDED.mbid IS NOT NULL THEN 'tag'
+ WHEN tracks.mbid_source = 'acoustid' THEN 'acoustid' END,
genre = EXCLUDED.genre,
-- Stamped on update too, so a tag-repair pass marks rows as done and the
-- next scan can short-circuit them again (#2499).
tag_read_version = EXCLUDED.tag_read_version,
updated_at = now()
-RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since
+RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source
`
type UpsertTrackParams struct {
@@ -850,6 +862,8 @@ type UpsertTrackParams struct {
}
// file_path is the canonical identity for library scan; mbid is secondary.
+// mbid is the file's tag. A looked-up id (mbid_source 'acoustid', M401)
+// survives a re-read that finds no tag id, and a tag id replaces it (D4).
func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track, error) {
row := q.db.QueryRow(ctx, upsertTrack,
arg.Title,
@@ -887,6 +901,7 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
+ &i.MbidSource,
)
return i, err
}
diff --git a/internal/db/migrations/0069_acoustid_lookup.down.sql b/internal/db/migrations/0069_acoustid_lookup.down.sql
new file mode 100644
index 00000000..3dae17cb
--- /dev/null
+++ b/internal/db/migrations/0069_acoustid_lookup.down.sql
@@ -0,0 +1,7 @@
+DROP TABLE IF EXISTS acoustid_settings;
+DROP TABLE IF EXISTS track_acoustid_lookups;
+-- Looked-up ids go with the lookups; a tag id stays.
+UPDATE tracks SET mbid = NULL WHERE mbid_source = 'acoustid';
+ALTER TABLE tracks DROP CONSTRAINT IF EXISTS tracks_mbid_source_pair;
+ALTER TABLE tracks DROP CONSTRAINT IF EXISTS tracks_mbid_source_check;
+ALTER TABLE tracks DROP COLUMN IF EXISTS mbid_source;
diff --git a/internal/db/migrations/0069_acoustid_lookup.up.sql b/internal/db/migrations/0069_acoustid_lookup.up.sql
new file mode 100644
index 00000000..7610c8f4
--- /dev/null
+++ b/internal/db/migrations/0069_acoustid_lookup.up.sql
@@ -0,0 +1,69 @@
+-- 0069_acoustid_lookup.up.sql — recording MBIDs looked up through AcoustID
+-- for tracks whose tags carry none (Scribe milestone #401).
+--
+-- A track with no recording MBID is invisible to the ListenBrainz similarity
+-- arm in both directions, so this fills the ids the tags leave empty.
+
+-- Where tracks.mbid came from. The file's own tag always outranks a lookup
+-- (M401 D4): the scan's upsert keeps a looked-up id while the tag carries
+-- none, and replaces it the moment the tag carries one.
+ALTER TABLE tracks ADD COLUMN mbid_source text;
+-- Every id stored so far was read from a tag (the scan or the tag backfill),
+-- or inherited from a merged duplicate's tag.
+UPDATE tracks SET mbid_source = 'tag' WHERE mbid IS NOT NULL;
+ALTER TABLE tracks
+ ADD CONSTRAINT tracks_mbid_source_check CHECK (mbid_source IN ('tag', 'acoustid')),
+ ADD CONSTRAINT tracks_mbid_source_pair CHECK ((mbid IS NULL) = (mbid_source IS NULL));
+
+-- What happened the last time a track was looked up. A table of its own, as
+-- track_loudness is (0065): tracks is read with SELECT * on the hot path.
+--
+-- What a row means, which the lookup worker depends on:
+-- no row never looked up, or the file changed since (the scan deletes
+-- the row): look it up
+-- matched one recording cleared the score threshold, or one was left
+-- after disambiguating by title and length; recording_mbid is it
+-- ambiguous several recordings cleared it and nothing told them apart.
+-- Nothing is written: a wrong MBID would feed similarity the
+-- wrong neighbours, which is worse than none (M401 D5)
+-- no_match AcoustID linked nothing at or above the threshold
+-- failed fpcalc rejected the file, or AcoustID rejected its print
+-- Every state is settled until the file changes. A failure that says nothing
+-- about the file (AcoustID unreachable, a timeout, a cancelled pass) writes no
+-- row, so the worker tries again.
+CREATE TABLE track_acoustid_lookups (
+ track_id uuid PRIMARY KEY REFERENCES tracks (id) ON DELETE CASCADE,
+ state text NOT NULL,
+ -- The best score any linked recording had; NULL when none was linked.
+ best_score real,
+ -- How many distinct recordings were linked, at any score.
+ candidates integer NOT NULL DEFAULT 0,
+ recording_mbid text,
+ -- Why a lookup failed, for the admin list.
+ detail text,
+ looked_up_at timestamptz NOT NULL DEFAULT now(),
+
+ CONSTRAINT track_acoustid_lookups_state_check
+ CHECK (state IN ('matched', 'ambiguous', 'no_match', 'failed')),
+ CONSTRAINT track_acoustid_lookups_matched_has_mbid
+ CHECK ((state = 'matched') = (recording_mbid IS NOT NULL))
+);
+CREATE INDEX track_acoustid_lookups_state ON track_acoustid_lookups (state);
+
+-- AcoustID's knobs, in admin Settings (rule 25). Singleton in the style of
+-- loudness_settings (0065).
+CREATE TABLE acoustid_settings (
+ id boolean PRIMARY KEY DEFAULT true,
+ -- Off by default (rule 26): AcoustID needs a key the operator registers,
+ -- and the lookup sends fingerprints to a third party.
+ enabled boolean NOT NULL DEFAULT false,
+ api_key text,
+ -- The score a recording needs before it is considered at all. AcoustID
+ -- scores a true match close to 1; 0.85 leaves room for a lossy encode.
+ min_score real NOT NULL DEFAULT 0.85,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+
+ CONSTRAINT acoustid_settings_singleton CHECK (id = true),
+ CONSTRAINT acoustid_settings_min_score_range CHECK (min_score >= 0.5 AND min_score <= 1)
+);
+INSERT INTO acoustid_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
diff --git a/internal/db/queries/acoustid.sql b/internal/db/queries/acoustid.sql
new file mode 100644
index 00000000..7a4aa1e1
--- /dev/null
+++ b/internal/db/queries/acoustid.sql
@@ -0,0 +1,99 @@
+-- AcoustID recording-id lookup (M401). Migration 0069 says what each
+-- lookup state means.
+
+-- name: ListTracksNeedingAcoustIDLookup :many
+-- The lookup worker's queue: present tracks with no tag MBID and no lookup
+-- since their file last changed. A looked-up id is included once its row is
+-- gone (the file changed), so the new bytes are checked rather than trusted.
+-- Keyset-paged on id, as the loudness queue is, so a pass ends even when
+-- every attempt is inconclusive and writes nothing.
+SELECT t.id, t.file_path, t.title, t.duration_ms
+ FROM tracks t
+ WHERE (t.mbid IS NULL OR t.mbid_source = 'acoustid')
+ AND t.missing_since IS NULL
+ AND t.id > sqlc.arg(after_id)::uuid
+ AND NOT EXISTS (SELECT 1 FROM track_acoustid_lookups l WHERE l.track_id = t.id)
+ ORDER BY t.id
+ LIMIT sqlc.arg(batch_limit);
+
+-- name: RecordAcoustIDLookup :exec
+INSERT INTO track_acoustid_lookups (track_id, state, best_score, candidates, recording_mbid, detail)
+VALUES (sqlc.arg(track_id), sqlc.arg(state), sqlc.narg(best_score), sqlc.arg(candidates),
+ sqlc.narg(recording_mbid), sqlc.narg(detail))
+ON CONFLICT (track_id) DO UPDATE SET
+ state = EXCLUDED.state,
+ best_score = EXCLUDED.best_score,
+ candidates = EXCLUDED.candidates,
+ recording_mbid = EXCLUDED.recording_mbid,
+ detail = EXCLUDED.detail,
+ looked_up_at = now();
+
+-- name: SetTrackMbidFromAcoustID :execrows
+-- Never over a tag id (D4): the guard is here, not only in the worker, so a
+-- tag the scan read between the worker's list and this write still wins.
+-- updated_at is left alone: it gates the scan's mtime skip, and moving it
+-- past a file's mtime would hide a change made just before the lookup.
+UPDATE tracks
+ SET mbid = sqlc.arg(mbid), mbid_source = 'acoustid'
+ WHERE id = sqlc.arg(id)
+ AND (mbid IS NULL OR mbid_source = 'acoustid');
+
+-- name: ClearTrackAcoustIDMbid :exec
+-- A re-lookup of changed bytes that no longer matches takes back the id an
+-- earlier lookup wrote. A tag id is never touched.
+UPDATE tracks
+ SET mbid = NULL, mbid_source = NULL
+ WHERE id = sqlc.arg(id)
+ AND mbid_source = 'acoustid';
+
+-- name: DeleteAcoustIDLookup :exec
+-- The scan drops the lookup of bytes that are gone, so the worker checks the
+-- new ones.
+DELETE FROM track_acoustid_lookups WHERE track_id = $1;
+
+-- name: GetMbidCoverage :one
+-- The admin gauge: present tracks by where their MBID came from, and the
+-- lookup states of those a lookup has seen. pending is what the worker's
+-- queue holds.
+SELECT count(*)::bigint AS total,
+ count(*) FILTER (WHERE t.mbid_source = 'tag')::bigint AS from_tag,
+ count(*) FILTER (WHERE t.mbid_source = 'acoustid')::bigint AS from_acoustid,
+ count(*) FILTER (WHERE l.state = 'ambiguous')::bigint AS ambiguous,
+ count(*) FILTER (WHERE l.state = 'no_match')::bigint AS no_match,
+ count(*) FILTER (WHERE l.state = 'failed')::bigint AS failed,
+ count(*) FILTER (
+ WHERE l.track_id IS NULL AND (t.mbid IS NULL OR t.mbid_source = 'acoustid')
+ )::bigint AS pending
+ FROM tracks t
+ LEFT JOIN track_acoustid_lookups l ON l.track_id = t.id
+ WHERE t.missing_since IS NULL;
+
+-- name: ListUnsettledAcoustIDLookups :many
+-- The admin list of tracks a lookup could not settle (ambiguous or failed),
+-- so they are findable rather than silently empty.
+SELECT t.id, t.title, t.file_path, ar.name AS artist_name, al.title AS album_title,
+ l.state, l.best_score, l.candidates, l.detail, l.looked_up_at
+ FROM track_acoustid_lookups l
+ JOIN tracks t ON t.id = l.track_id
+ JOIN artists ar ON ar.id = t.artist_id
+ JOIN albums al ON al.id = t.album_id
+ WHERE l.state IN ('ambiguous', 'failed')
+ AND t.missing_since IS NULL
+ ORDER BY ar.name, al.title, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
+ LIMIT sqlc.arg(row_limit);
+
+-- name: GetAcoustIDSettings :one
+SELECT * FROM acoustid_settings WHERE id = true;
+
+-- name: UpdateAcoustIDSettings :one
+-- The key is write-only from the admin card: set_api_key false keeps the
+-- stored one, and an empty api_key with set_api_key true clears it.
+UPDATE acoustid_settings
+ SET enabled = sqlc.arg(enabled),
+ min_score = sqlc.arg(min_score),
+ api_key = CASE WHEN sqlc.arg(set_api_key)::boolean
+ THEN NULLIF(sqlc.arg(api_key)::text, '')
+ ELSE api_key END,
+ updated_at = now()
+ WHERE id = true
+RETURNING *;
diff --git a/internal/db/queries/merge.sql b/internal/db/queries/merge.sql
index 6c71dfb1..024e5d58 100644
--- a/internal/db/queries/merge.sql
+++ b/internal/db/queries/merge.sql
@@ -88,7 +88,7 @@ ON CONFLICT (track_a_id, track_b_id, source) DO NOTHING;
-- A recording MBID is what the similarity pipeline keys on. If only the removed
-- copy carried one, the survivor takes it rather than going dark to similarity.
UPDATE tracks AS survivor
- SET mbid = loser.mbid
+ SET mbid = loser.mbid, mbid_source = loser.mbid_source
FROM tracks AS loser
WHERE survivor.id = sqlc.arg(survivor_id)::uuid
AND loser.id = sqlc.arg(loser_id)::uuid
diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql
index 6d0b179e..97fbfeba 100644
--- a/internal/db/queries/tracks.sql
+++ b/internal/db/queries/tracks.sql
@@ -1,10 +1,13 @@
-- name: UpsertTrack :one
-- file_path is the canonical identity for library scan; mbid is secondary.
+-- mbid is the file's tag. A looked-up id (mbid_source 'acoustid', M401)
+-- survives a re-read that finds no tag id, and a tag id replaces it (D4).
INSERT INTO tracks (
title, album_id, artist_id, track_number, disc_number,
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre,
- tag_read_version
-) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+ tag_read_version, mbid_source
+) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
+ CASE WHEN $11::text IS NOT NULL THEN 'tag' END)
ON CONFLICT (file_path) DO UPDATE SET
title = EXCLUDED.title,
album_id = EXCLUDED.album_id,
@@ -15,7 +18,11 @@ ON CONFLICT (file_path) DO UPDATE SET
file_size = EXCLUDED.file_size,
file_format = EXCLUDED.file_format,
bitrate = EXCLUDED.bitrate,
- mbid = EXCLUDED.mbid,
+ -- Both read the row as it was before this statement.
+ mbid = CASE WHEN EXCLUDED.mbid IS NULL AND tracks.mbid_source = 'acoustid'
+ THEN tracks.mbid ELSE EXCLUDED.mbid END,
+ mbid_source = CASE WHEN EXCLUDED.mbid IS NOT NULL THEN 'tag'
+ WHEN tracks.mbid_source = 'acoustid' THEN 'acoustid' END,
genre = EXCLUDED.genre,
-- Stamped on update too, so a tag-repair pass marks rows as done and the
-- next scan can short-circuit them again (#2499).
@@ -39,7 +46,7 @@ SELECT id, file_path
-- Heal a track's recording MBID only while still NULL — idempotent, so
-- re-running the backfill is a no-op for already-healed rows.
UPDATE tracks
- SET mbid = $2, updated_at = now()
+ SET mbid = $2, mbid_source = 'tag', updated_at = now()
WHERE id = $1 AND mbid IS NULL;
-- name: GetTrackByID :one
diff --git a/internal/library/acoustid_lookup.go b/internal/library/acoustid_lookup.go
new file mode 100644
index 00000000..272cccef
--- /dev/null
+++ b/internal/library/acoustid_lookup.go
@@ -0,0 +1,454 @@
+package library
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+ "time"
+ "unicode"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+// AcoustID lookup worker (M401 #3921).
+//
+// A track whose tags carry no recording MBID is invisible to the ListenBrainz
+// similarity arm: it is never a seed and never comes back as a result. This
+// worker fingerprints such tracks and asks AcoustID which recording they are,
+// writing an id only when the answer is unambiguous (D5). The file's own tag
+// always outranks it (D4).
+//
+// It never gates anything else (rule 164). With no key, switched off or
+// AcoustID unreachable, it idles and says why through Status; scans and
+// playback do not notice.
+
+// acoustIDLookupTick is how often the worker looks for work. A settings save
+// starts a pass at once, so this only paces new tracks and retries after an
+// outage.
+const acoustIDLookupTick = 15 * time.Minute
+
+// acoustIDLookupBatch is how many tracks one query hands the worker.
+const acoustIDLookupBatch = 50
+
+// acoustIDLookupConcurrency is how many files are fingerprinted at once. The
+// client serialises the requests themselves to AcoustID's rate, so this only
+// overlaps fpcalc's decodes; low for the reason the loudness backfill's is.
+const acoustIDLookupConcurrency = 2
+
+// acoustIDDurationToleranceSec is how far a recording's MusicBrainz length may
+// be from the file's and still count as the same take when telling candidates
+// apart. A release's length and a rip's differ by a second or two of
+// silence; a radio edit or a live take differs by far more.
+const acoustIDDurationToleranceSec = 3
+
+// Lookup states, as migration 0069's CHECK has them.
+const (
+ lookupMatched = "matched"
+ lookupAmbiguous = "ambiguous"
+ lookupNoMatch = "no_match"
+ lookupFailed = "failed"
+)
+
+// recordingLookup is the AcoustID client as the worker uses it.
+type recordingLookup interface {
+ Lookup(ctx context.Context, apiKey, fingerprint string, durationSec int) ([]acoustid.Candidate, error)
+}
+
+// AcoustIDLookupStatus is what the admin card shows about the worker.
+type AcoustIDLookupStatus struct {
+ Running bool
+ LastPassAt time.Time
+ // Problem says why the last pass stopped short, in words for the card:
+ // the key was refused, or AcoustID could not be reached. Empty when the
+ // last pass ran to the end.
+ Problem string
+}
+
+// AcoustIDLookupResult tallies one pass.
+type AcoustIDLookupResult struct {
+ Processed int
+ Matched int
+ Ambiguous int
+ NoMatch int
+ Failed int
+ Inconclusive int // nothing stored; tried again on a later pass
+}
+
+func (r *AcoustIDLookupResult) add(state string) {
+ r.Processed++
+ switch state {
+ case lookupMatched:
+ r.Matched++
+ case lookupAmbiguous:
+ r.Ambiguous++
+ case lookupNoMatch:
+ r.NoMatch++
+ case lookupFailed:
+ r.Failed++
+ default:
+ r.Inconclusive++
+ }
+}
+
+// errPassStopped ends a pass early: every further lookup would fail the same
+// way, so asking again would only spend the rate limit.
+type errPassStopped struct{ reason string }
+
+func (e errPassStopped) Error() string { return e.reason }
+
+// AcoustIDLookupWorker fills recording MBIDs through AcoustID.
+type AcoustIDLookupWorker struct {
+ pool *pgxpool.Pool
+ logger *slog.Logger
+ settings *AcoustIDSettingsService
+ client recordingLookup
+ tick time.Duration
+ batch int32
+ // fingerprint is a field so an integration test pins which tracks a pass
+ // touches, not what fpcalc prints.
+ fingerprint func(ctx context.Context, path string) (lookupFingerprint, error)
+ kick chan struct{}
+
+ mu sync.Mutex
+ status AcoustIDLookupStatus
+}
+
+// NewAcoustIDLookupWorker builds a worker with the production cadence.
+func NewAcoustIDLookupWorker(
+ pool *pgxpool.Pool, logger *slog.Logger, settings *AcoustIDSettingsService, client recordingLookup,
+) *AcoustIDLookupWorker {
+ return &AcoustIDLookupWorker{
+ pool: pool,
+ logger: logger,
+ settings: settings,
+ client: client,
+ tick: acoustIDLookupTick,
+ batch: acoustIDLookupBatch,
+ fingerprint: computeLookupFingerprint,
+ kick: make(chan struct{}, 1),
+ }
+}
+
+// Kick asks for a pass now, for the admin card's "look up now". A pass
+// already running absorbs it.
+func (w *AcoustIDLookupWorker) Kick() {
+ if w == nil {
+ return
+ }
+ select {
+ case w.kick <- struct{}{}:
+ default:
+ }
+}
+
+// Settings is the settings service the worker reads, for the admin API. A
+// nil worker has none, which serves the defaults.
+func (w *AcoustIDLookupWorker) Settings() *AcoustIDSettingsService {
+ if w == nil {
+ return nil
+ }
+ return w.settings
+}
+
+// Status reports the worker's state for the admin card.
+func (w *AcoustIDLookupWorker) Status() AcoustIDLookupStatus {
+ if w == nil {
+ return AcoustIDLookupStatus{}
+ }
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.status
+}
+
+// Run blocks until ctx is cancelled: one pass at start, then one per tick, on
+// a kick, and after every settings save.
+func (w *AcoustIDLookupWorker) Run(ctx context.Context) {
+ w.runOnce(ctx)
+ t := time.NewTicker(w.tick)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ case <-w.kick:
+ case <-w.settings.Changed():
+ }
+ w.runOnce(ctx)
+ }
+}
+
+// runOnce contains a pass so that nothing it does (an error, a panic) can stop
+// the next one from starting (rule 157).
+func (w *AcoustIDLookupWorker) runOnce(ctx context.Context) {
+ w.setStatus(func(s *AcoustIDLookupStatus) { s.Running = true })
+ problem := ""
+ defer func() {
+ if r := recover(); r != nil {
+ w.logger.Error("acoustid lookup: pass panicked", "panic", r)
+ problem = "The last lookup pass stopped on an internal error; see the server log."
+ }
+ w.setStatus(func(s *AcoustIDLookupStatus) {
+ s.Running, s.LastPassAt, s.Problem = false, time.Now(), problem
+ })
+ }()
+ res, err := w.pass(ctx)
+ var stopped errPassStopped
+ switch {
+ case errors.As(err, &stopped):
+ problem = stopped.reason
+ w.logger.Warn("acoustid lookup: pass stopped", "reason", stopped.reason, "processed", res.Processed)
+ case err != nil && ctx.Err() == nil:
+ problem = "The last lookup pass failed; see the server log."
+ w.logger.Warn("acoustid lookup: pass failed", "err", err, "processed", res.Processed)
+ }
+ if res.Processed > 0 {
+ w.logger.Info("acoustid lookup: pass complete",
+ "processed", res.Processed, "matched", res.Matched, "ambiguous", res.Ambiguous,
+ "no_match", res.NoMatch, "failed", res.Failed, "inconclusive", res.Inconclusive)
+ }
+}
+
+func (w *AcoustIDLookupWorker) setStatus(f func(*AcoustIDLookupStatus)) {
+ w.mu.Lock()
+ f(&w.status)
+ w.mu.Unlock()
+}
+
+// pass walks the queue once, keyset-paged on id so it ends even when every
+// attempt is inconclusive. Settings are read before every batch, so switching
+// the lookup off or changing the threshold applies at the next batch.
+func (w *AcoustIDLookupWorker) pass(ctx context.Context) (AcoustIDLookupResult, error) {
+ q := dbq.New(w.pool)
+ var res AcoustIDLookupResult
+ after := pgtype.UUID{Valid: true}
+ for {
+ if err := ctx.Err(); err != nil {
+ return res, err
+ }
+ cfg := w.settings.Get()
+ if !cfg.Ready() {
+ return res, nil
+ }
+ rows, err := q.ListTracksNeedingAcoustIDLookup(ctx, dbq.ListTracksNeedingAcoustIDLookupParams{
+ AfterID: after,
+ BatchLimit: w.batch,
+ })
+ if err != nil {
+ return res, fmt.Errorf("list tracks needing a lookup: %w", err)
+ }
+ if len(rows) == 0 {
+ return res, nil
+ }
+ if err := w.lookupBatch(ctx, cfg, rows, &res); err != nil {
+ return res, err
+ }
+ after = rows[len(rows)-1].ID
+ }
+}
+
+// lookupBatch looks up one batch, acoustIDLookupConcurrency at a time. The
+// first track that stops the pass stops the batch: the tracks already started
+// finish, no new ones start.
+func (w *AcoustIDLookupWorker) lookupBatch(
+ ctx context.Context, cfg AcoustIDSettings, rows []dbq.ListTracksNeedingAcoustIDLookupRow, res *AcoustIDLookupResult,
+) error {
+ ctx, cancel := context.WithCancelCause(ctx)
+ defer cancel(nil)
+ var (
+ mu sync.Mutex
+ wg sync.WaitGroup
+ sem = make(chan struct{}, acoustIDLookupConcurrency)
+ )
+ for _, row := range rows {
+ if ctx.Err() != nil {
+ break
+ }
+ sem <- struct{}{}
+ // The slot may have come free because a lookup just stopped the pass.
+ if ctx.Err() != nil {
+ <-sem
+ break
+ }
+ wg.Add(1)
+ go func(row dbq.ListTracksNeedingAcoustIDLookupRow) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ defer func() {
+ if r := recover(); r != nil {
+ w.logger.Error("acoustid lookup: track panicked", "path", row.FilePath, "panic", r)
+ }
+ }()
+ state, err := w.lookupTrack(ctx, cfg, row)
+ if err != nil {
+ cancel(err)
+ }
+ mu.Lock()
+ res.add(state)
+ mu.Unlock()
+ }(row)
+ }
+ wg.Wait()
+ if cause := context.Cause(ctx); cause != nil && !errors.Is(cause, context.Canceled) {
+ return cause
+ }
+ return nil
+}
+
+// lookupTrack fingerprints one file, asks AcoustID and stores the answer. It
+// returns the state stored ("" when nothing was), and an errPassStopped when
+// no further lookup can succeed this pass.
+func (w *AcoustIDLookupWorker) lookupTrack(
+ ctx context.Context, cfg AcoustIDSettings, row dbq.ListTracksNeedingAcoustIDLookupRow,
+) (string, error) {
+ fp, err := w.fingerprint(ctx, row.FilePath)
+ if err != nil {
+ if isInconclusive(err) {
+ return "", nil
+ }
+ // fpcalc ran and rejected the file: a verdict, settled until it changes.
+ return w.store(ctx, row, lookupDecision{state: lookupFailed, detail: err.Error()}), nil
+ }
+ cands, err := w.client.Lookup(ctx, cfg.APIKey, fp.fingerprint, fp.durationSec)
+ switch {
+ case err == nil:
+ case errors.Is(err, acoustid.ErrInvalidKey):
+ return "", errPassStopped{"AcoustID refused the API key. Check it in Settings."}
+ case errors.Is(err, acoustid.ErrUnavailable):
+ return "", errPassStopped{"AcoustID could not be reached; lookups resume on the next pass."}
+ case errors.Is(err, acoustid.ErrInvalidFingerprint):
+ return w.store(ctx, row, lookupDecision{state: lookupFailed, detail: err.Error()}), nil
+ default:
+ // The caller's cancellation, or a request this client built wrongly.
+ // Neither is a verdict on the track.
+ if ctx.Err() == nil {
+ w.logger.Warn("acoustid lookup: lookup failed", "path", row.FilePath, "err", err)
+ }
+ return "", nil
+ }
+ return w.store(ctx, row, chooseRecording(cands, cfg.MinScore, row.Title, row.DurationMs)), nil
+}
+
+// store records a decision and applies it to tracks.mbid in one transaction,
+// so the gauge never shows a match whose id was not written. A failed write
+// stores nothing and the track is tried again.
+func (w *AcoustIDLookupWorker) store(ctx context.Context, row dbq.ListTracksNeedingAcoustIDLookupRow, d lookupDecision) string {
+ err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
+ q := dbq.New(tx)
+ params := dbq.RecordAcoustIDLookupParams{
+ TrackID: row.ID, State: d.state, Candidates: int32(d.candidates),
+ }
+ if d.candidates > 0 {
+ params.BestScore = &d.bestScore
+ }
+ if d.recording != "" {
+ params.RecordingMbid = &d.recording
+ }
+ if d.detail != "" {
+ params.Detail = &d.detail
+ }
+ if err := q.RecordAcoustIDLookup(ctx, params); err != nil {
+ return err
+ }
+ if d.state == lookupMatched {
+ _, err := q.SetTrackMbidFromAcoustID(ctx, dbq.SetTrackMbidFromAcoustIDParams{ID: row.ID, Mbid: &d.recording})
+ return err
+ }
+ // A re-lookup of changed bytes that no longer matches takes back the
+ // id the earlier lookup wrote.
+ return q.ClearTrackAcoustIDMbid(ctx, row.ID)
+ })
+ if err != nil {
+ if ctx.Err() == nil {
+ w.logger.Warn("acoustid lookup: storing the result failed", "path", row.FilePath, "err", err)
+ }
+ return ""
+ }
+ return d.state
+}
+
+// lookupDecision is what one lookup settles on.
+type lookupDecision struct {
+ state string
+ recording string // set only when matched
+ bestScore float32
+ candidates int
+ detail string
+}
+
+// chooseRecording applies D5 to AcoustID's candidates (best score first):
+// only recordings at or above minScore count, one of them is a match, and
+// several are told apart by the track's title and length or not at all. A
+// wrong MBID would feed similarity the wrong neighbours, which is worse than
+// none, so anything left unresolved is ambiguous and writes nothing.
+func chooseRecording(cands []acoustid.Candidate, minScore float32, title string, durationMs int32) lookupDecision {
+ d := lookupDecision{candidates: len(cands)}
+ if len(cands) > 0 {
+ d.bestScore = float32(cands[0].Score)
+ }
+ var above []acoustid.Candidate
+ for _, c := range cands {
+ if float32(c.Score) >= minScore {
+ above = append(above, c)
+ }
+ }
+ switch len(above) {
+ case 0:
+ d.state = lookupNoMatch
+ return d
+ case 1:
+ d.state, d.recording = lookupMatched, above[0].ID
+ return d
+ }
+ want := normalizeTitle(title)
+ var same []acoustid.Candidate
+ for _, c := range above {
+ if normalizeTitle(c.Title) == want && sameLength(c.DurationSec, durationMs) {
+ same = append(same, c)
+ }
+ }
+ if len(same) == 1 {
+ d.state, d.recording = lookupMatched, same[0].ID
+ return d
+ }
+ d.state = lookupAmbiguous
+ return d
+}
+
+// sameLength reports whether a recording's length (0 when MusicBrainz has
+// none) fits the file's. An unknown length on either side tells nothing, so
+// it does not rule a candidate out.
+func sameLength(recordingSec int, fileMs int32) bool {
+ if recordingSec <= 0 || fileMs <= 0 {
+ return true
+ }
+ diff := recordingSec*1000 - int(fileMs)
+ return diff <= acoustIDDurationToleranceSec*1000 && diff >= -acoustIDDurationToleranceSec*1000
+}
+
+// normalizeTitle compares titles by their letters and digits alone, case
+// folded, so "Don't Stop" and "Dont stop" match while "Song (Radio Edit)"
+// and "Song" do not.
+func normalizeTitle(s string) string {
+ var b strings.Builder
+ for _, r := range strings.ToLower(s) {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+// AcoustIDCoverage reports where the library's recording MBIDs came from and
+// what the lookups found, for the admin gauge.
+func AcoustIDCoverage(ctx context.Context, pool *pgxpool.Pool) (dbq.GetMbidCoverageRow, error) {
+ return dbq.New(pool).GetMbidCoverage(ctx)
+}
diff --git a/internal/library/acoustid_lookup_test.go b/internal/library/acoustid_lookup_test.go
new file mode 100644
index 00000000..b9fe4cd7
--- /dev/null
+++ b/internal/library/acoustid_lookup_test.go
@@ -0,0 +1,371 @@
+package library
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
+)
+
+func cand(id, title string, sec int, score float64) acoustid.Candidate {
+ return acoustid.Candidate{Recording: acoustid.Recording{ID: id, Title: title, DurationSec: sec}, Score: score}
+}
+
+// D5, as a table over what decides it: how many candidates clear the
+// threshold, and whether title and length leave exactly one of them.
+func TestChooseRecording(t *testing.T) {
+ const (
+ minScore = 0.85
+ title = "Don't Stop"
+ fileMs = 200_400
+ )
+ cases := []struct {
+ name string
+ cands []acoustid.Candidate
+ state string
+ rec string
+ }{
+ {"no candidates", nil, lookupNoMatch, ""},
+ {"all below the threshold", []acoustid.Candidate{cand("a", title, 200, 0.84)}, lookupNoMatch, ""},
+ {"one above the threshold", []acoustid.Candidate{cand("a", "Other title", 999, 0.9), cand("b", title, 200, 0.4)}, lookupMatched, "a"},
+ {"exactly at the threshold counts", []acoustid.Candidate{cand("a", title, 200, 0.85)}, lookupMatched, "a"},
+ {"several, one with the title and length",
+ []acoustid.Candidate{cand("album", "Dont stop", 201, 0.95), cand("edit", "Don't Stop (Radio Edit)", 181, 0.95)},
+ lookupMatched, "album"},
+ {"several, title alike but only one length fits",
+ []acoustid.Candidate{cand("live", title, 260, 0.9), cand("studio", title, 198, 0.9)},
+ lookupMatched, "studio"},
+ {"several with the same title and length",
+ []acoustid.Candidate{cand("r1", title, 200, 0.95), cand("r2", title, 201, 0.95)},
+ lookupAmbiguous, ""},
+ {"several, none with the title",
+ []acoustid.Candidate{cand("x", "Something", 200, 0.95), cand("y", "Else", 200, 0.95)},
+ lookupAmbiguous, ""},
+ // An unknown length rules nothing out, so it cannot break a tie on
+ // its own: both stay and the lookup is ambiguous.
+ {"unknown lengths do not disambiguate",
+ []acoustid.Candidate{cand("r1", title, 0, 0.95), cand("r2", title, 0, 0.95)},
+ lookupAmbiguous, ""},
+ }
+ for _, c := range cases {
+ d := chooseRecording(c.cands, minScore, title, fileMs)
+ if d.state != c.state || d.recording != c.rec {
+ t.Errorf("%s: got %s %q, want %s %q", c.name, d.state, d.recording, c.state, c.rec)
+ }
+ if d.candidates != len(c.cands) {
+ t.Errorf("%s: candidates = %d, want %d", c.name, d.candidates, len(c.cands))
+ }
+ }
+ if d := chooseRecording([]acoustid.Candidate{cand("a", title, 200, 0.97)}, minScore, title, fileMs); d.bestScore != 0.97 {
+ t.Errorf("best score = %v, want 0.97", d.bestScore)
+ }
+}
+
+func TestNormalizeTitle(t *testing.T) {
+ if normalizeTitle("Don't Stop!") != normalizeTitle("dont stop") {
+ t.Error("punctuation and case should not separate titles")
+ }
+ if normalizeTitle("Song (Radio Edit)") == normalizeTitle("Song") {
+ t.Error("an edit's title must not match the original's")
+ }
+ if normalizeTitle("Ænima") != "ænima" {
+ t.Errorf("non-ASCII letters must survive: %q", normalizeTitle("Ænima"))
+ }
+}
+
+// fakeLookup answers by fingerprint. The worker passes each file's path
+// through as its fingerprint (see testWorker), so a test keys answers by file.
+type fakeLookup struct {
+ mu sync.Mutex
+ answers map[string][]acoustid.Candidate
+ err error
+ calls int
+}
+
+func (f *fakeLookup) Lookup(_ context.Context, _ string, fingerprint string, _ int) ([]acoustid.Candidate, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.calls++
+ if f.err != nil {
+ return nil, f.err
+ }
+ return f.answers[fingerprint], nil
+}
+
+func testWorker(t *testing.T, pool *pgxpool.Pool, client recordingLookup) *AcoustIDLookupWorker {
+ t.Helper()
+ ctx := context.Background()
+ settings, err := NewAcoustIDSettingsService(ctx, pool)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := "test-key"
+ if _, err := settings.Set(ctx, AcoustIDSettingsUpdate{Enabled: true, MinScore: 0.85, APIKey: &key}); err != nil {
+ t.Fatal(err)
+ }
+ w := NewAcoustIDLookupWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), settings, client)
+ w.fingerprint = func(_ context.Context, path string) (lookupFingerprint, error) {
+ return lookupFingerprint{fingerprint: path, durationSec: 200}, nil
+ }
+ return w
+}
+
+func ptr(s string) *string { return &s }
+
+// mbidOf reads a track's MBID and its source.
+func mbidOf(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) (string, string) {
+ t.Helper()
+ var mbid, source *string
+ if err := pool.QueryRow(context.Background(),
+ `SELECT mbid, mbid_source FROM tracks WHERE id = $1`, id).Scan(&mbid, &source); err != nil {
+ t.Fatal(err)
+ }
+ deref := func(p *string) string {
+ if p == nil {
+ return ""
+ }
+ return *p
+ }
+ return deref(mbid), deref(source)
+}
+
+func addTrack(t *testing.T, q *dbq.Queries, album dbq.Album, artist dbq.Artist, title, path string, mbid *string) dbq.Track {
+ t.Helper()
+ tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
+ Title: title, AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 200_000,
+ FilePath: path, FileSize: 100, FileFormat: "mp3", Mbid: mbid,
+ })
+ if err != nil {
+ t.Fatalf("track %s: %v", title, err)
+ }
+ return tr
+}
+
+// D4: the file's tag outranks a lookup, in both directions. Falsified by
+// restoring `mbid = EXCLUDED.mbid` in UpsertTrack: the first re-read below
+// then erases the looked-up id.
+func TestUpsertTrack_KeepsALookedUpMbidUntilATagCarriesOne_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ dir := t.TempDir()
+ tr, album, artist := seedTrack(t, pool, filepath.Join(dir, "a.mp3"))
+ reread := func(mbid *string) {
+ t.Helper()
+ if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
+ Title: tr.Title, AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 1000,
+ FilePath: tr.FilePath, FileSize: 100, FileFormat: "mp3", Mbid: mbid,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if _, err := q.SetTrackMbidFromAcoustID(ctx, dbq.SetTrackMbidFromAcoustIDParams{ID: tr.ID, Mbid: ptr("rec-looked-up")}); err != nil {
+ t.Fatal(err)
+ }
+ reread(nil)
+ if m, s := mbidOf(t, pool, tr.ID); m != "rec-looked-up" || s != "acoustid" {
+ t.Errorf("after an untagged re-read: %q from %q, want the looked-up id kept", m, s)
+ }
+
+ reread(ptr("rec-from-tag"))
+ if m, s := mbidOf(t, pool, tr.ID); m != "rec-from-tag" || s != "tag" {
+ t.Errorf("after a tagged re-read: %q from %q, want the tag's id", m, s)
+ }
+
+ // A tag id is the file's to take away: removing the tag clears it, and a
+ // lookup cannot write over one.
+ if n, err := q.SetTrackMbidFromAcoustID(ctx, dbq.SetTrackMbidFromAcoustIDParams{ID: tr.ID, Mbid: ptr("rec-other")}); err != nil || n != 0 {
+ t.Errorf("a lookup wrote over a tag id: %d rows, %v", n, err)
+ }
+ reread(nil)
+ if m, s := mbidOf(t, pool, tr.ID); m != "" || s != "" {
+ t.Errorf("after the tag was removed: %q from %q, want none", m, s)
+ }
+}
+
+func TestAcoustIDLookup_FillsOnlyWhatItCanSettle_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ dir := t.TempDir()
+ _, album, artist := seedTrack(t, pool, filepath.Join(dir, "seed.mp3"))
+ path := func(n string) string { return filepath.Join(dir, n+".mp3") }
+
+ matched := addTrack(t, q, album, artist, "Matched", path("matched"), nil)
+ ambiguous := addTrack(t, q, album, artist, "Twice", path("ambiguous"), nil)
+ none := addTrack(t, q, album, artist, "Unknown", path("none"), nil)
+ tagged := addTrack(t, q, album, artist, "Tagged", path("tagged"), ptr("rec-tag"))
+
+ fake := &fakeLookup{answers: map[string][]acoustid.Candidate{
+ path("matched"): {cand("rec-matched", "Matched", 200, 0.97)},
+ path("ambiguous"): {cand("rec-1", "Twice", 200, 0.95), cand("rec-2", "Twice", 200, 0.95)},
+ path("tagged"): {cand("rec-wrong", "Tagged", 200, 0.99)},
+ }}
+ w := testWorker(t, pool, fake)
+ res, err := w.pass(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // seed.mp3 has no answer, so it settles as no_match beside "Unknown".
+ if res.Matched != 1 || res.Ambiguous != 1 || res.NoMatch != 2 {
+ t.Errorf("pass = %+v, want 1 matched, 1 ambiguous, 2 no match", res)
+ }
+ if m, s := mbidOf(t, pool, matched.ID); m != "rec-matched" || s != "acoustid" {
+ t.Errorf("matched track: %q from %q", m, s)
+ }
+ for _, tr := range []dbq.Track{ambiguous, none} {
+ if m, _ := mbidOf(t, pool, tr.ID); m != "" {
+ t.Errorf("%s got MBID %q from an unsettled lookup", tr.Title, m)
+ }
+ }
+ // The tagged track is never looked up, so the fake's wrong answer for it
+ // is never seen.
+ if m, s := mbidOf(t, pool, tagged.ID); m != "rec-tag" || s != "tag" {
+ t.Errorf("tagged track: %q from %q, want its tag untouched", m, s)
+ }
+
+ // Settled tracks are not looked up again.
+ before := fake.calls
+ if res, err := w.pass(ctx); err != nil || res.Processed != 0 || fake.calls != before {
+ t.Errorf("second pass = %+v (err %v, %d new calls), want nothing to do", res, err, fake.calls-before)
+ }
+
+ // The matched file changes and its new bytes no longer match: the scan
+ // drops the lookup, and the re-lookup takes the id back.
+ if err := q.DeleteAcoustIDLookup(ctx, matched.ID); err != nil {
+ t.Fatal(err)
+ }
+ fake.mu.Lock()
+ fake.answers[path("matched")] = nil
+ fake.mu.Unlock()
+ if _, err := w.pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if m, s := mbidOf(t, pool, matched.ID); m != "" || s != "" {
+ t.Errorf("after a non-matching re-lookup: %q from %q, want the looked-up id taken back", m, s)
+ }
+
+ cov, err := AcoustIDCoverage(ctx, pool)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cov.Total != 5 || cov.FromTag != 1 || cov.FromAcoustid != 0 || cov.Ambiguous != 1 || cov.NoMatch != 3 || cov.Pending != 0 {
+ t.Errorf("coverage = %+v", cov)
+ }
+}
+
+func TestAcoustIDLookup_StopsWhenTheServiceCannotAnswer_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ dir := t.TempDir()
+ _, album, artist := seedTrack(t, pool, filepath.Join(dir, "seed.mp3"))
+ for i := range 5 {
+ addTrack(t, q, album, artist, fmt.Sprint("T", i), filepath.Join(dir, fmt.Sprint(i, ".mp3")), nil)
+ }
+ for _, c := range []struct {
+ err error
+ problem string
+ }{
+ {fmt.Errorf("%w: invalid API key", acoustid.ErrInvalidKey), "refused the API key"},
+ {fmt.Errorf("%w: status 503", acoustid.ErrUnavailable), "could not be reached"},
+ } {
+ fake := &fakeLookup{err: c.err}
+ w := testWorker(t, pool, fake)
+ w.runOnce(ctx)
+ // Stopped at the first answer, not one failed call per track. Two may
+ // already be in flight at concurrency 2.
+ if fake.calls > acoustIDLookupConcurrency {
+ t.Errorf("%v: %d lookups made, want the pass to stop at the first", c.err, fake.calls)
+ }
+ if st := w.Status(); st.Running || !strings.Contains(st.Problem, c.problem) {
+ t.Errorf("%v: status = %+v, want a problem mentioning %q", c.err, st, c.problem)
+ }
+ var n int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM track_acoustid_lookups`).Scan(&n); err != nil || n != 0 {
+ t.Errorf("%v: %d lookups stored (err %v); a service failure says nothing about a track", c.err, n, err)
+ }
+ }
+}
+
+// The point of the milestone (#3921): once a lookup fills an MBID, the
+// similarity worker picks the track as a seed and can map ListenBrainz's
+// answers back to it.
+func TestAcoustIDLookup_FilledTrackReachesSimilarity_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ dir := t.TempDir()
+ tr, _, _ := seedTrack(t, pool, filepath.Join(dir, "played.mp3"))
+
+ u, err := q.CreateUser(ctx, dbq.CreateUserParams{
+ Username: dbtest.TestUserPrefix + "acoustid", PasswordHash: "x", ApiTokenHash: "acoustid-token",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := pgtype.Timestamptz{Time: time.Now(), Valid: true}
+ session, err := q.InsertPlaySession(ctx, dbq.InsertPlaySessionParams{UserID: u.ID, StartedAt: now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
+ UserID: u.ID, TrackID: tr.ID, SessionID: session.ID, StartedAt: now,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ seeds := func() int {
+ t.Helper()
+ rows, err := q.ListPlayedTracksNeedingSimilarity(ctx, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return len(rows)
+ }
+ if n := seeds(); n != 0 {
+ t.Fatalf("an untagged track is already a similarity seed (%d)", n)
+ }
+
+ fake := &fakeLookup{answers: map[string][]acoustid.Candidate{
+ tr.FilePath: {cand("rec-played", tr.Title, 1, 0.99)},
+ }}
+ if _, err := testWorker(t, pool, fake).pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if n := seeds(); n != 1 {
+ t.Errorf("after the lookup, %d similarity seeds, want the played track", n)
+ }
+ got, err := q.GetTracksByMBIDs(ctx, []string{"rec-played"})
+ if err != nil || len(got) != 1 || got[0].ID != tr.ID {
+ t.Errorf("GetTracksByMBIDs = %+v (err %v), want the played track", got, err)
+ }
+}
+
+func TestAcoustIDLookup_IdleUntilReady(t *testing.T) {
+ var nilSvc *AcoustIDSettingsService
+ if nilSvc.Get().Ready() {
+ t.Error("the defaults are ready; the lookup must ship off")
+ }
+ for _, s := range []AcoustIDSettings{{Enabled: true}, {APIKey: "k"}} {
+ if s.Ready() {
+ t.Errorf("%+v reads as ready", s)
+ }
+ }
+ if _, err := nilSvc.Set(context.Background(), AcoustIDSettingsUpdate{MinScore: 0.2}); !errors.Is(err, ErrAcoustIDSettingOutOfRange) {
+ t.Errorf("min_score 0.2: err = %v, want out of range", err)
+ }
+}
diff --git a/internal/library/acoustid_settings.go b/internal/library/acoustid_settings.go
new file mode 100644
index 00000000..8ee310a6
--- /dev/null
+++ b/internal/library/acoustid_settings.go
@@ -0,0 +1,132 @@
+package library
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+// AcoustID lookup settings (M401 #3922). Rule 25: a database row, changed
+// without a restart, shared by the worker and the admin API.
+
+// AcoustIDSettings mirrors the acoustid_settings row.
+type AcoustIDSettings struct {
+ Enabled bool
+ // APIKey is the operator's registered application key. Never sent back
+ // to the browser; the API reports only whether one is set.
+ APIKey string
+ // MinScore is the AcoustID score a recording needs to be considered.
+ MinScore float32
+ // UpdatedAt is set by the database.
+ UpdatedAt time.Time
+}
+
+// Ready reports whether the worker may call AcoustID at all.
+func (s AcoustIDSettings) Ready() bool { return s.Enabled && s.APIKey != "" }
+
+// DefaultAcoustIDSettings mirrors migration 0069's defaults: off, no key.
+var DefaultAcoustIDSettings = AcoustIDSettings{MinScore: 0.85}
+
+// The score threshold's bounds, as migration 0069's CHECK has them. Below 0.5
+// AcoustID's own docs call a match doubtful; the floor keeps a typo in the
+// field from filling the library with guesses.
+const (
+ minAcoustIDScore = 0.5
+ maxAcoustIDScore = 1
+)
+
+// ErrAcoustIDSettingOutOfRange is returned by Set for a value the CHECK would
+// reject, so the API answers 400 naming the field.
+var ErrAcoustIDSettingOutOfRange = errors.New("acoustid setting out of range")
+
+// AcoustIDSettingsUpdate is the admin save. A nil APIKey keeps the stored key;
+// an empty one clears it.
+type AcoustIDSettingsUpdate struct {
+ Enabled bool
+ MinScore float32
+ APIKey *string
+}
+
+// AcoustIDSettingsService caches the settings and owns their persistence.
+type AcoustIDSettingsService struct {
+ pool *pgxpool.Pool
+
+ mu sync.RWMutex
+ cur AcoustIDSettings
+ // changed is signalled after every save, so the worker can start a pass
+ // at once instead of waiting out its tick.
+ changed chan struct{}
+}
+
+// NewAcoustIDSettingsService loads once and caches. It always returns a usable
+// service, holding the defaults (off) when the load fails; the error says so.
+func NewAcoustIDSettingsService(ctx context.Context, pool *pgxpool.Pool) (*AcoustIDSettingsService, error) {
+ s := &AcoustIDSettingsService{pool: pool, cur: DefaultAcoustIDSettings, changed: make(chan struct{}, 1)}
+ row, err := dbq.New(pool).GetAcoustIDSettings(ctx)
+ if err != nil {
+ return s, fmt.Errorf("acoustid settings: load: %w", err)
+ }
+ s.cur = acoustIDSettingsFromRow(row)
+ return s, nil
+}
+
+// Get returns the cached settings. A nil service answers with the defaults.
+func (s *AcoustIDSettingsService) Get() AcoustIDSettings {
+ if s == nil {
+ return DefaultAcoustIDSettings
+ }
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.cur
+}
+
+// Set validates, persists and re-caches.
+func (s *AcoustIDSettingsService) Set(ctx context.Context, in AcoustIDSettingsUpdate) (AcoustIDSettings, error) {
+ if in.MinScore < minAcoustIDScore || in.MinScore > maxAcoustIDScore {
+ return AcoustIDSettings{}, fmt.Errorf("%w: min_score must be %.2f-%.2f",
+ ErrAcoustIDSettingOutOfRange, float32(minAcoustIDScore), float32(maxAcoustIDScore))
+ }
+ if s == nil {
+ return AcoustIDSettings{}, errors.New("acoustid settings: no settings service")
+ }
+ params := dbq.UpdateAcoustIDSettingsParams{Enabled: in.Enabled, MinScore: in.MinScore}
+ if in.APIKey != nil {
+ params.SetApiKey = true
+ params.ApiKey = *in.APIKey
+ }
+ row, err := dbq.New(s.pool).UpdateAcoustIDSettings(ctx, params)
+ if err != nil {
+ return AcoustIDSettings{}, fmt.Errorf("acoustid settings: save: %w", err)
+ }
+ out := acoustIDSettingsFromRow(row)
+ s.mu.Lock()
+ s.cur = out
+ s.mu.Unlock()
+ select {
+ case s.changed <- struct{}{}:
+ default:
+ }
+ return out, nil
+}
+
+// Changed is signalled after a save. A nil service never signals.
+func (s *AcoustIDSettingsService) Changed() <-chan struct{} {
+ if s == nil {
+ return nil
+ }
+ return s.changed
+}
+
+func acoustIDSettingsFromRow(row dbq.AcoustidSetting) AcoustIDSettings {
+ out := AcoustIDSettings{Enabled: row.Enabled, MinScore: row.MinScore, UpdatedAt: row.UpdatedAt.Time}
+ if row.ApiKey != nil {
+ out.APIKey = *row.ApiKey
+ }
+ return out
+}
diff --git a/internal/library/duplicate_merge_test.go b/internal/library/duplicate_merge_test.go
index 5651e7c8..3a6781fe 100644
--- a/internal/library/duplicate_merge_test.go
+++ b/internal/library/duplicate_merge_test.go
@@ -69,7 +69,7 @@ func newMergeFixture(t *testing.T) mergeFixture {
}
}
// Only the copy being removed carries a recording MBID.
- mustExec(`UPDATE tracks SET mbid = 'rec-www' WHERE id = $1`, f.remove.ID)
+ mustExec(`UPDATE tracks SET mbid = 'rec-www', mbid_source = 'tag' WHERE id = $1`, f.remove.ID)
user := func(name string) dbq.User {
t.Helper()
diff --git a/internal/library/fingerprint.go b/internal/library/fingerprint.go
index c890842b..12890a78 100644
--- a/internal/library/fingerprint.go
+++ b/internal/library/fingerprint.go
@@ -270,6 +270,18 @@ func computeChromaprint(ctx context.Context, path string, lengthSec int32) ([]in
return parseFpcalcRaw(out)
}
+// computeLookupFingerprint returns the compressed fingerprint and duration an
+// AcoustID lookup needs. It fails as runFingerprintTool does, so a stall or a
+// missing fpcalc reads as inconclusive (isInconclusive) and a file fpcalc
+// rejects as a verdict.
+func computeLookupFingerprint(ctx context.Context, path string) (lookupFingerprint, error) {
+ out, err := runFingerprintTool(ctx, "fpcalc", fpcalcLookupArgs(path))
+ if err != nil {
+ return lookupFingerprint{}, err
+ }
+ return parseFpcalcCompressed(out)
+}
+
// runFingerprintTool runs one tool under fingerprintTimeout.
//
// Any non-zero exit is an error, and that deliberately includes fpcalc's exit 3:
diff --git a/internal/library/scanner.go b/internal/library/scanner.go
index c7d45dcd..496142eb 100644
--- a/internal/library/scanner.go
+++ b/internal/library/scanner.go
@@ -409,6 +409,12 @@ func (s *Scanner) scanFile(
if err := q.DeleteTrackLoudness(ctx, track.ID); err != nil {
s.logger.Warn("loudness: clearing stale measurement failed", "path", path, "err", err)
}
+ // Likewise an AcoustID lookup (M401): the worker looks the new bytes
+ // up again. A looked-up MBID stays meanwhile and is taken back only if
+ // the new lookup does not match it.
+ if err := q.DeleteAcoustIDLookup(ctx, track.ID); err != nil {
+ s.logger.Warn("acoustid: clearing stale lookup failed", "path", path, "err", err)
+ }
}
if knownTrack {
diff --git a/internal/server/server.go b/internal/server/server.go
index c833c508..bb77f503 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -108,6 +108,10 @@ type Server struct {
// LoudnessSettings is the DB-backed loudness analysis policy (M464 #4995),
// shared with the loudness backfill. Nil makes the router load its own.
LoudnessSettings *library.LoudnessSettingsService
+ // AcoustIDLookup is the AcoustID lookup worker (M401), whose settings,
+ // status and "look up now" the admin card drives. Nil in test contexts;
+ // one is built (and never run) so the admin routes still answer.
+ AcoustIDLookup *library.AcoustIDLookupWorker
// ReacqSettings is the DB-backed missing-file re-acquisition policy
// (milestone #290) — the same instance the sweeper in cmd/minstrel/main.go
// reads, so a save from the admin card reaches it without a restart
@@ -225,7 +229,15 @@ func (s *Server) Router() http.Handler {
s.Logger.Warn("loudness settings unavailable; serving defaults", "err", err)
}
}
- api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings)
+ acoustIDLookup := s.AcoustIDLookup
+ if acoustIDLookup == nil {
+ acoustIDSettings, err := library.NewAcoustIDSettingsService(context.Background(), s.Pool)
+ if err != nil {
+ s.Logger.Warn("acoustid settings unavailable; serving defaults", "err", err)
+ }
+ acoustIDLookup = library.NewAcoustIDLookupWorker(s.Pool, s.Logger, acoustIDSettings, nil)
+ }
+ api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup)
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
From 0a7f78839094310a5ffe2f0b1eb481e2cb29c614 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 23:29:34 -0400
Subject: [PATCH 3/5] =?UTF-8?q?feat(web):=20AcoustID=20card=20on=20Integra?=
=?UTF-8?q?tions=20=E2=80=94=20key,=20threshold,=20coverage=20by=20source?=
=?UTF-8?q?=20(M401=20#3922)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The card takes the slot of the unimplemented "MusicBrainz overrides"
placeholder. Rows:
- the on switch
- a write-only key field (the stored key is never sent back), with a
link to register an application
- the minimum score (0.5 to 1)
Below them, recording-id coverage reads as a column: from tags, looked
up, none, and of the none how many are waiting, no match, ambiguous or
failed. There is a "Look up now" button and a folded list of the tracks
the lookup could not settle.
Off, keyless and stopped-short passes are each a visible state with the
reason (rule 164).
Co-Authored-By: Claude Opus 5.5
---
web/src/lib/api/admin.ts | 60 ++++
web/src/lib/components/AcoustIDCard.svelte | 337 ++++++++++++++++++
web/src/lib/components/AcoustIDCard.test.ts | 193 ++++++++++
.../routes/admin/integrations/+page.svelte | 20 +-
.../admin/integrations/integrations.test.ts | 7 +-
5 files changed, 600 insertions(+), 17 deletions(-)
create mode 100644 web/src/lib/components/AcoustIDCard.svelte
create mode 100644 web/src/lib/components/AcoustIDCard.test.ts
diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts
index b257cb6d..c8426095 100644
--- a/web/src/lib/api/admin.ts
+++ b/web/src/lib/api/admin.ts
@@ -398,6 +398,66 @@ export async function updateLoudnessSettings(s: LoudnessSettings): Promise('/api/admin/library/loudness-settings', s);
}
+// AcoustID recording-id lookup (M401) ---------------------------------------
+
+export type AcoustIDSettings = {
+ enabled: boolean;
+ min_score: number;
+ /** The key itself is never sent back, only whether one is stored. */
+ api_key_set: boolean;
+};
+
+export type AcoustIDOverview = {
+ settings: AcoustIDSettings;
+ /** Switched on and holding a key: the worker will call AcoustID. */
+ ready: boolean;
+ status: { running: boolean; last_pass_at: string | null; problem: string };
+ /** from_tag + from_acoustid + tracks with no MBID = total. */
+ coverage: {
+ total: number;
+ from_tag: number;
+ from_acoustid: number;
+ ambiguous: number;
+ no_match: number;
+ failed: number;
+ pending: number;
+ };
+};
+
+export type AcoustIDUnsettled = {
+ track_id: string;
+ title: string;
+ artist: string;
+ album: string;
+ file_path: string;
+ state: 'ambiguous' | 'failed';
+ best_score: number | null;
+ candidates: number;
+ detail: string | null;
+ looked_up_at: string;
+};
+
+export async function getAcoustID(): Promise {
+ return api.get('/api/admin/library/acoustid');
+}
+
+/** api_key left out keeps the stored key; an empty string clears it. */
+export async function updateAcoustIDSettings(s: {
+ enabled: boolean;
+ min_score: number;
+ api_key?: string;
+}): Promise {
+ return api.put('/api/admin/library/acoustid-settings', s);
+}
+
+export async function runAcoustIDLookup(): Promise {
+ await api.post('/api/admin/library/acoustid/run');
+}
+
+export async function listUnsettledAcoustID(): Promise {
+ return api.get('/api/admin/library/acoustid/unsettled');
+}
+
// Cover-art providers ------------------------------------------------------
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
diff --git a/web/src/lib/components/AcoustIDCard.svelte b/web/src/lib/components/AcoustIDCard.svelte
new file mode 100644
index 00000000..c7973727
--- /dev/null
+++ b/web/src/lib/components/AcoustIDCard.svelte
@@ -0,0 +1,337 @@
+
+
+
+
+
+
AcoustID
+
+ Finds MusicBrainz ids for untagged tracks, so Songs-like and radio can place them.
+
+
+ {#if overview}
+ {#if overview.ready && !overview.status.problem}
+
+
+ {overview.status.running ? 'Looking up' : 'On'}
+
+ {:else}
+
+
+ {#if overview.ready}Needs attention{:else if !overview.settings.api_key_set}Needs a key{:else}Off{/if}
+
+ {/if}
+ {/if}
+
+
+ {#if loadError}
+
+ Couldn't load AcoustID settings.
+ Try again
+
+ {:else if overview === null}
+ Loading…
+ {:else}
+ {#if overview.status.problem}
+
+ {overview.status.problem}
+
+ {/if}
+
+
+
+ {#if !scoreOk}
+
+ Minimum score must be from 0.5 to 1.
+
+ {/if}
+
+
+
+ {saving ? 'Saving…' : 'Save'}
+
+
+
+ {#if overview.coverage.total > 0}
+
+
Recording ids
+
+
+
From tags
+ {overview.coverage.from_tag.toLocaleString()}
+
+
+
Looked up
+ {overview.coverage.from_acoustid.toLocaleString()}
+
+
+
None
+ {withoutId.toLocaleString()}
+
+
+
Waiting to be looked up
+ {overview.coverage.pending.toLocaleString()}
+
+
+
+ No match
+
+ {overview.coverage.no_match.toLocaleString()}
+
+
+
+ Ambiguous
+
+ {overview.coverage.ambiguous.toLocaleString()}
+
+
+
+ Failed
+
+ {overview.coverage.failed.toLocaleString()}
+
+
+
+
+ {overview.status.running ? 'Looking up…' : 'Look up now'}
+
+
+
+
+ {#if overview.coverage.ambiguous + overview.coverage.failed > 0}
+
+
+ Tracks the lookup couldn't settle ({(
+ overview.coverage.ambiguous + overview.coverage.failed
+ ).toLocaleString()})
+
+ {#if unsettled === null}
+ Loading…
+ {:else}
+
+ {#each unsettled as t (t.track_id)}
+
+
+ {t.title}
+ {t.artist} · {t.album}
+
+
+ {t.state === 'ambiguous' ? `${t.candidates} recordings` : 'failed'}
+
+
+ {/each}
+
+ {/if}
+
+ {/if}
+ {/if}
+ {/if}
+
diff --git a/web/src/lib/components/AcoustIDCard.test.ts b/web/src/lib/components/AcoustIDCard.test.ts
new file mode 100644
index 00000000..ef4cf927
--- /dev/null
+++ b/web/src/lib/components/AcoustIDCard.test.ts
@@ -0,0 +1,193 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
+import type { AcoustIDOverview } from "$lib/api/admin";
+
+vi.mock("$lib/api/admin", () => ({
+ getAcoustID: vi.fn(),
+ updateAcoustIDSettings: vi.fn(),
+ runAcoustIDLookup: vi.fn(),
+ listUnsettledAcoustID: vi.fn(),
+}));
+
+vi.mock("$lib/stores/toast.svelte", () => ({ pushToast: vi.fn() }));
+
+import AcoustIDCard from "./AcoustIDCard.svelte";
+import {
+ getAcoustID,
+ listUnsettledAcoustID,
+ runAcoustIDLookup,
+ updateAcoustIDSettings,
+} from "$lib/api/admin";
+
+const base: AcoustIDOverview = {
+ settings: { enabled: true, min_score: 0.85, api_key_set: true },
+ ready: true,
+ status: { running: false, last_pass_at: null, problem: "" },
+ coverage: {
+ total: 1000,
+ from_tag: 700,
+ from_acoustid: 150,
+ ambiguous: 12,
+ no_match: 80,
+ failed: 3,
+ pending: 55,
+ },
+};
+
+function overview(over: Partial = {}): AcoustIDOverview {
+ return { ...base, ...over, settings: { ...base.settings, ...over.settings } };
+}
+
+afterEach(() => vi.clearAllMocks());
+
+async function renderCard(o: AcoustIDOverview = base) {
+ vi.mocked(getAcoustID).mockResolvedValue(o);
+ render(AcoustIDCard);
+ await screen.findByRole("checkbox", { name: /look up untagged tracks/i });
+}
+
+const saveButton = () => screen.getByRole("button", { name: /^save$/i });
+const lookUpNow = () => screen.getByRole("button", { name: /look up now/i });
+
+describe("AcoustIDCard", () => {
+ test("shows where the library’s recording ids came from", async () => {
+ await renderCard();
+ const cov = screen.getByTestId("acoustid-coverage");
+ // 1000 - 700 - 150 have no id.
+ for (const [label, n] of [
+ ["From tags", "700"],
+ ["Looked up", "150"],
+ ["None", "150"],
+ ["Waiting to be looked up", "55"],
+ ["No match", "80"],
+ ["Ambiguous", "12"],
+ ["Failed", "3"],
+ ]) {
+ const row = [...cov.querySelectorAll("dt")].find(
+ (dt) => dt.textContent?.trim() === label,
+ );
+ expect(row?.nextElementSibling?.textContent?.trim(), label).toBe(n);
+ }
+ });
+
+ test("never shows the stored key, and sends one only when typed", async () => {
+ vi.mocked(updateAcoustIDSettings).mockResolvedValue(base.settings);
+ await renderCard();
+ const key = screen.getByLabelText(/api key/i) as HTMLInputElement;
+ expect(key.value).toBe("");
+ expect(key.placeholder).toBe("Stored");
+ expect(saveButton()).toHaveProperty("disabled", true);
+
+ await fireEvent.input(
+ screen.getByRole("spinbutton", { name: /minimum score/i }),
+ {
+ target: { value: "0.9" },
+ },
+ );
+ await fireEvent.click(saveButton());
+ await waitFor(() =>
+ expect(updateAcoustIDSettings).toHaveBeenCalledWith({
+ enabled: true,
+ min_score: 0.9,
+ }),
+ );
+
+ await fireEvent.input(key, { target: { value: " new-key " } });
+ await fireEvent.click(saveButton());
+ await waitFor(() =>
+ expect(updateAcoustIDSettings).toHaveBeenLastCalledWith({
+ enabled: true,
+ min_score: 0.85,
+ api_key: "new-key",
+ }),
+ );
+ });
+
+ test("a score outside 0.5 to 1 is named and cannot be saved", async () => {
+ await renderCard();
+ await fireEvent.input(
+ screen.getByRole("spinbutton", { name: /minimum score/i }),
+ {
+ target: { value: "0.3" },
+ },
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId("acoustid-problems").textContent).toMatch(
+ /0\.5 to 1/,
+ ),
+ );
+ expect(saveButton()).toHaveProperty("disabled", true);
+ });
+
+ // Rule 164: off, keyless or unreachable is a visible state, not a silent one.
+ test("says why it is not looking anything up", async () => {
+ await renderCard(
+ overview({
+ ready: false,
+ settings: { enabled: true, min_score: 0.85, api_key_set: false },
+ }),
+ );
+ expect(screen.getByTestId("acoustid-state").textContent).toMatch(
+ /needs a key/i,
+ );
+ expect(lookUpNow()).toHaveProperty("disabled", true);
+ });
+
+ test("a pass that stopped short says why", async () => {
+ await renderCard(
+ overview({
+ status: {
+ running: false,
+ last_pass_at: "2026-10-07T03:00:00Z",
+ problem: "AcoustID refused the API key. Check it in Settings.",
+ },
+ }),
+ );
+ expect(screen.getByTestId("acoustid-problem").textContent).toMatch(
+ /refused the API key/,
+ );
+ expect(screen.getByTestId("acoustid-state").textContent).toMatch(
+ /needs attention/i,
+ );
+ });
+
+ test("look up now starts a pass", async () => {
+ vi.mocked(runAcoustIDLookup).mockResolvedValue();
+ await renderCard();
+ await fireEvent.click(lookUpNow());
+ await waitFor(() => expect(runAcoustIDLookup).toHaveBeenCalledTimes(1));
+ });
+
+ test("the tracks a lookup could not settle can be found", async () => {
+ vi.mocked(listUnsettledAcoustID).mockResolvedValue([
+ {
+ track_id: "t1",
+ title: "WWW",
+ artist: "Moe Shop",
+ album: "WWW",
+ file_path: "/music/a.mp3",
+ state: "ambiguous",
+ best_score: 0.97,
+ candidates: 2,
+ detail: null,
+ looked_up_at: "2026-10-07T03:00:00Z",
+ },
+ ]);
+ await renderCard();
+ const details = screen
+ .getByText(/couldn't settle \(15\)/i)
+ .closest("details")!;
+ details.open = true;
+ await fireEvent(details, new Event("toggle"));
+ const list = await screen.findByTestId("acoustid-unsettled");
+ expect(list.textContent).toMatch(/WWW/);
+ expect(list.textContent).toMatch(/2 recordings/);
+ });
+
+ test("a failed load offers a retry", async () => {
+ vi.mocked(getAcoustID).mockRejectedValue(new Error("boom"));
+ render(AcoustIDCard);
+ await screen.findByText(/couldn't load acoustid settings/i);
+ expect(screen.getByRole("button", { name: /try again/i })).toBeTruthy();
+ });
+});
diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte
index eb749efb..9d6bae2d 100644
--- a/web/src/routes/admin/integrations/+page.svelte
+++ b/web/src/routes/admin/integrations/+page.svelte
@@ -27,6 +27,7 @@
import { errCode } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
import Modal from '$lib/components/Modal.svelte';
+ import AcoustIDCard from '$lib/components/AcoustIDCard.svelte';
import NetworkSettingsCard from '$lib/components/NetworkSettingsCard.svelte';
import PublicAddressCard from '$lib/components/PublicAddressCard.svelte';
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
@@ -549,22 +550,9 @@
-
-
-
-
- MusicBrainz overrides
-
-
-
- unset
-
-
- Not yet configured.
-
+
+
diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts
index 360d41a3..053bcb06 100644
--- a/web/src/routes/admin/integrations/integrations.test.ts
+++ b/web/src/routes/admin/integrations/integrations.test.ts
@@ -28,7 +28,12 @@ vi.mock('$lib/api/admin', () => ({
testTagProvider: vi.fn(),
createSMTPConfigQuery: vi.fn(),
updateSMTPConfig: vi.fn(),
- testSMTPConfig: vi.fn()
+ testSMTPConfig: vi.fn(),
+ // AcoustIDCard loads on mount; a never-settling read leaves it on "Loading…".
+ getAcoustID: vi.fn(() => new Promise(() => {})),
+ updateAcoustIDSettings: vi.fn(),
+ runAcoustIDLookup: vi.fn(),
+ listUnsettledAcoustID: vi.fn()
}));
import IntegrationsPage from './+page.svelte';
From 03e07e7c6613682e4e6e6f7c1e23871734388bc1 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 23:42:25 -0400
Subject: [PATCH 4/5] fix(web): send the empty body api.post requires for
AcoustID run-now (M401 #3922)
svelte-check on 0a7f7883: api.post takes a body. Every other body-less POST
in the client passes {}.
Co-Authored-By: Claude Opus 5.5
---
web/src/lib/api/admin.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts
index c8426095..9b5bccd1 100644
--- a/web/src/lib/api/admin.ts
+++ b/web/src/lib/api/admin.ts
@@ -451,7 +451,7 @@ export async function updateAcoustIDSettings(s: {
}
export async function runAcoustIDLookup(): Promise {
- await api.post('/api/admin/library/acoustid/run');
+ await api.post('/api/admin/library/acoustid/run', {});
}
export async function listUnsettledAcoustID(): Promise {
From e8eee55325199ce9711dcb069be51903c935d345 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 23:47:14 -0400
Subject: [PATCH 5/5] fix(web): AcoustID card's loading line names itself (M401
#3922)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Its bare "Loading…" made the Integrations page's cover-providers test find
two matches for /loading…/i (Vitest, run 8487). "Loading AcoustID
settings…" also says which card is loading.
Co-Authored-By: Claude Opus 5.5
---
web/src/lib/components/AcoustIDCard.svelte | 4 ++--
web/src/routes/admin/integrations/integrations.test.ts | 3 ++-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/web/src/lib/components/AcoustIDCard.svelte b/web/src/lib/components/AcoustIDCard.svelte
index c7973727..3de30bb6 100644
--- a/web/src/lib/components/AcoustIDCard.svelte
+++ b/web/src/lib/components/AcoustIDCard.svelte
@@ -162,7 +162,7 @@
Try again
{:else if overview === null}
- Loading…
+ Loading AcoustID settings…
{:else}
{#if overview.status.problem}
@@ -312,7 +312,7 @@
).toLocaleString()})
{#if unsettled === null}
-
Loading…
+ Loading tracks…
{:else}
{#each unsettled as t (t.track_id)}
diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts
index 053bcb06..13a45314 100644
--- a/web/src/routes/admin/integrations/integrations.test.ts
+++ b/web/src/routes/admin/integrations/integrations.test.ts
@@ -29,7 +29,8 @@ vi.mock('$lib/api/admin', () => ({
createSMTPConfigQuery: vi.fn(),
updateSMTPConfig: vi.fn(),
testSMTPConfig: vi.fn(),
- // AcoustIDCard loads on mount; a never-settling read leaves it on "Loading…".
+ // AcoustIDCard loads on mount; a never-settling read leaves it on its own
+ // "Loading AcoustID settings…", which the page's /loading…/i queries skip.
getAcoustID: vi.fn(() => new Promise(() => {})),
updateAcoustIDSettings: vi.fn(),
runAcoustIDLookup: vi.fn(),