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) }