Files
minstrel/internal/api/admin_acoustid.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

173 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.
// 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)
}