Files
minstrel/internal/api/admin_coverage.go
T
bvandeusenandClaude Opus 5 b8855b480f
test-web / test (push) Failing after 50s
test-go / test (push) Successful in 1m7s
test-go / integration (push) Successful in 3m27s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m23s
feat(library): backfill fingerprints for the existing library — M400 #3908
The scan fingerprints only bytes it has not seen, so everything imported
before fingerprinting existed, and any row derived by an older
fingerprintVersion, needs a pass of its own.

That pass is a background worker, not a stage in RunScan. RunScan runs
at boot and then every 12h, and an in-flight scan older than an hour is
reaped and a second started beside it. A stage would have to stop inside
the hour: a few hundred decodes a run, so about a month for a 50k-track
library. It would also hold the run in flight and answer manual
rescans with 409 while it worked.

FingerprintBackfillWorker runs once at start, then hourly. Nothing a
pass does (error or panic) can stop the next tick. A pass walks tracks
with no fingerprint or a stale version, skipping missing tracks,
keyset-paged on id. The cursor is what lets a pass end: an inconclusive
attempt writes no row, so a file that keeps timing out would otherwise
be re-listed and retried forever. Two decodes at a time, deliberately:
they compete with transcoding for CPU and with streaming for the mount.

storeFingerprint is now one package function shared by the scan and
the worker, and reports whether the attempt was fingerprinted,
rejected, inconclusive or failed to store.

Progress is a live gauge on the Admin scan card, served by
GET /api/admin/library/fingerprints: fingerprinted / rejected / pending
of total, with missing tracks excluded so it can reach the end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:56:18 -04:00

68 lines
2.5 KiB
Go

package api
import (
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// coverageRollupResp is the wire shape for GET /api/admin/library/coverage.
// All fields are non-negative counts. with_art + pending + settled = total;
// pending_no_mbid is a subset of pending.
type coverageRollupResp struct {
Total int64 `json:"total"`
WithArt int64 `json:"with_art"`
Pending int64 `json:"pending"`
Settled int64 `json:"settled"`
PendingNoMbid int64 `json:"pending_no_mbid"`
}
// handleGetLibraryCoverage implements GET /api/admin/library/coverage.
// Surfaces a library-wide rollup of albums.cover_art_source for the admin
// dashboard gauge. Always 200; zeros on an empty library.
func (h *handlers) handleGetLibraryCoverage(w http.ResponseWriter, r *http.Request) {
q := dbq.New(h.pool)
row, err := q.GetAlbumCoverageRollup(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin: get coverage rollup", apierror.InternalMsg("lookup failed", err))
return
}
writeJSON(w, http.StatusOK, coverageRollupResp{
Total: row.Total,
WithArt: row.WithArt,
Pending: row.Pending,
Settled: row.Settled,
PendingNoMbid: row.PendingNoMbid,
})
}
// fingerprintCoverageResp is the wire shape for GET /api/admin/library/fingerprints.
// fingerprinted + rejected + pending = total. Missing tracks are not counted:
// there is no file to fingerprint.
type fingerprintCoverageResp struct {
Total int64 `json:"total"`
Fingerprinted int64 `json:"fingerprinted"`
Rejected int64 `json:"rejected"`
Pending int64 `json:"pending"`
}
// handleGetFingerprintCoverage implements GET /api/admin/library/fingerprints:
// how far the fingerprint backfill (#3908) has got. The backfill is its own
// worker spanning many passes, with no scan run to attach a tally to, so its
// progress is read live here. Always 200; zeros on an empty library.
func (h *handlers) handleGetFingerprintCoverage(w http.ResponseWriter, r *http.Request) {
row, err := library.FingerprintCoverage(r.Context(), h.pool)
if err != nil {
writeErrWithLog(w, h.logger, "admin: get fingerprint coverage", apierror.InternalMsg("lookup failed", err))
return
}
writeJSON(w, http.StatusOK, fingerprintCoverageResp{
Total: row.Total,
Fingerprinted: row.Fingerprinted,
Rejected: row.Rejected,
Pending: row.Pending,
})
}