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)
|
||||
}
|
||||
+9
-1
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user