feat(library): AcoustID lookup worker fills the MBIDs tags leave empty (M401 #3920 #3921)
release / web (push) Successful in 1m44s
release / go (push) Successful in 2m1s
release / govulncheck (push) Successful in 17s
release / integration (push) Successful in 5m22s
release / android (push) Successful in 5m48s
release / Build signed APK (releases and dev) (push) Successful in 5m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m6s
release / Verify release artifacts (tag releases only) (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:26:03 -04:00
co-authored by Claude Opus 5.5
parent f13da62797
commit 3c575b137c
24 changed files with 1743 additions and 29 deletions
+171
View File
@@ -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)
}