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)
}
+9 -1
View File
@@ -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.
+1 -1
View File
@@ -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",