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
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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user