package api import ( "context" "encoding/json" "errors" "net/http" "github.com/jackc/pgx/v5" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/library" ) // scanStatusResp mirrors the scan_runs row on the wire. Stage tallies are // surfaced as json.RawMessage so the frontend gets actual JSON objects // rather than base64 byte arrays — the DB column is bytea-shaped jsonb in // pgx but the data is already valid JSON. type scanStatusResp struct { ID string `json:"id"` StartedAt string `json:"started_at"` FinishedAt *string `json:"finished_at"` Library json.RawMessage `json:"library,omitempty"` MbidBackfill json.RawMessage `json:"mbid_backfill,omitempty"` CoverEnrich json.RawMessage `json:"cover_enrich,omitempty"` ArtistArtEnrich json.RawMessage `json:"artist_art_enrich,omitempty"` ErrorMessage *string `json:"error_message,omitempty"` InFlight bool `json:"in_flight"` } // handleGetScanStatus implements GET /api/admin/scan/status. // Returns 200 with an empty payload (no fields except in_flight=false) when // no scan has ever run. func (h *handlers) handleGetScanStatus(w http.ResponseWriter, r *http.Request) { q := dbq.New(h.pool) row, err := q.GetLatestScanRun(r.Context()) if err != nil { if errors.Is(err, pgx.ErrNoRows) { writeJSON(w, http.StatusOK, scanStatusResp{InFlight: false}) return } writeErrWithLog(w, h.logger, "admin: get scan status", apierror.InternalMsg("lookup failed", err)) return } resp := scanStatusResp{ ID: uuidToString(row.ID), StartedAt: row.StartedAt.Time.Format("2006-01-02T15:04:05Z07:00"), ErrorMessage: row.ErrorMessage, InFlight: !row.FinishedAt.Valid, } if row.FinishedAt.Valid { s := row.FinishedAt.Time.Format("2006-01-02T15:04:05Z07:00") resp.FinishedAt = &s } if len(row.Library) > 0 { resp.Library = row.Library } if len(row.MbidBackfill) > 0 { resp.MbidBackfill = row.MbidBackfill } if len(row.CoverEnrich) > 0 { resp.CoverEnrich = row.CoverEnrich } if len(row.ArtistArtEnrich) > 0 { resp.ArtistArtEnrich = row.ArtistArtEnrich } writeJSON(w, http.StatusOK, resp) } type scanTriggerResp struct { ID string `json:"id,omitempty"` } // handleTriggerScan implements POST /api/admin/scan/run. // Returns 202 on success, 409 if a scan is already in flight (and not stale), // 500 on DB errors. The lazy reaper inside library.TryStartScan handles // stale (>1h) in-flight rows automatically — the operator no longer needs // to manually clear stuck rows after a server crash. func (h *handlers) handleTriggerScan(w http.ResponseWriter, _ *http.Request) { bgCtx := context.Background() started, existing, err := library.TryStartScan( bgCtx, h.pool, h.scanner, h.coverart, h.logger.With("source", "manual"), h.scanCfg, ) if err != nil { writeErrWithLog(w, h.logger, "admin: trigger scan failed", apierror.InternalMsg("trigger failed", err)) return } if !started { writeJSON(w, http.StatusConflict, scanTriggerResp{ID: uuidToString(existing.ID)}) return } writeJSON(w, http.StatusAccepted, scanTriggerResp{}) }