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>
172 lines
5.9 KiB
Go
172 lines
5.9 KiB
Go
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)
|
|
}
|