feat: show what re-acquisition has done, per folder — #2527
test-web / test (push) Failing after 42s
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 5m3s

Completes milestone #290. The sweeper has been running and the settings
have been editable, but the list itself said nothing about either, so
the only way to tell "not tried yet" from "asked twice and nothing came
back" was to go and read the Requests queue.

Each folder now carries its album's attempt record: how many times, when
last, when next -- or that it gave up, with the reassurance that a file
coming back and going missing later starts the process over. Null when
nothing has been attempted, which is the common case for a folder that
just went missing and would be noise on every row.

next_attempt_at is computed, not stored. The schedule is a function of
the attempt count and the current settings, so persisting it would go
stale the moment an operator edited the backoff -- and the card lets
them do exactly that.

Needed a forward-looking formatter. relativeTime deliberately collapses
a future timestamp to "just now" (pinned by its own test) because that
is the right answer for a clock-skewed past event; it is the wrong one
for a scheduled future attempt, which would have rendered "next just
now". timeUntil is its companion rather than a sign-aware rewrite: the
two read differently in the same sentence -- "last tried 3d ago, next in
4h" -- and a test asserts they disagree about the future on purpose, so
nobody later "fixes" the divergence.

The state lookup is one batched query for the whole page and best-effort:
this is context on a list whose real job is showing what is missing, so
a failure leaves the groups bare rather than failing the page. The
settings service is read with a nil guard falling back to the shipped
defaults, since contexts that wire routing without services exist and a
backoff projection is not worth a nil-pointer panic (rule #48).
This commit is contained in:
2026-08-17 00:19:48 -04:00
parent 952132714e
commit 414dfb23b6
6 changed files with 264 additions and 3 deletions
+117 -1
View File
@@ -2,8 +2,12 @@ package api
import (
"net/http"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
)
// missingTrackView is one track whose file the scan could not find.
@@ -26,6 +30,21 @@ type missingTrackView struct {
LastPlayedAt *string `json:"last_played_at"`
}
// reacquisitionStateView is what the sweeper has done about one album
// (milestone #290), attached to the group so the operator can tell "nothing
// has happened yet" from "asked twice, still nothing" without cross-checking
// the Requests queue.
//
// NextAttemptAt is computed rather than stored: the schedule is a function of
// the attempt count and the current settings, so persisting it would go stale
// the moment an operator edited the backoff.
type reacquisitionStateView struct {
Attempts int `json:"attempts"`
LastAttemptAt *string `json:"last_attempt_at"`
NextAttemptAt *string `json:"next_attempt_at"`
GaveUpAt *string `json:"gave_up_at"`
}
// missingGroupView is a directory's worth of missing tracks.
//
// Grouping is the whole ergonomic argument for this surface. The case that
@@ -37,6 +56,10 @@ type missingGroupView struct {
Directory string `json:"directory"`
MissingSince string `json:"missing_since"`
Tracks []missingTrackView `json:"tracks"`
// Nil when nothing has been attempted for this group's album — the
// common case for a folder that just went missing, and distinct from
// an attempts:0 record, which cannot occur.
Reacquisition *reacquisitionStateView `json:"reacquisition"`
}
// adminMissingResponse is the paged envelope. Total counts TRACKS, not
@@ -79,11 +102,14 @@ func (h *handlers) handleListMissingTracks(w http.ResponseWriter, r *http.Reques
return
}
groups := groupMissingByDirectory(rows)
h.attachReacquisitionState(r, q, rows, groups)
out := adminMissingResponse{
Total: total,
Limit: limit,
Offset: offset,
Groups: groupMissingByDirectory(rows),
Groups: groups,
}
writeJSON(w, http.StatusOK, out)
}
@@ -132,3 +158,93 @@ func groupMissingByDirectory(rows []dbq.ListMissingTracksRow) []missingGroupView
}
return groups
}
// attachReacquisitionState decorates each group with what the sweeper has
// done about its album (milestone #290).
//
// Best-effort: this is context on a list whose primary job is showing what is
// missing, so a failure here leaves the groups bare rather than failing the
// page. One batched query for the whole page, not one per group.
//
// A group is keyed by directory while re-acquisition is keyed by album, and
// those line up in practice (an album's files live in one folder) but are not
// guaranteed to — a directory holding two albums takes the first album's
// state, which is the same album its first track belongs to.
func (h *handlers) attachReacquisitionState(
r *http.Request,
q *dbq.Queries,
rows []dbq.ListMissingTracksRow,
groups []missingGroupView,
) {
if len(groups) == 0 {
return
}
// Directory -> the album its first row belongs to, matching the order
// groupMissingByDirectory folded them in.
dirAlbum := make(map[string]pgtype.UUID, len(groups))
ids := make([]pgtype.UUID, 0, len(groups))
for _, row := range rows {
if _, seen := dirAlbum[row.Directory]; seen {
continue
}
dirAlbum[row.Directory] = row.AlbumID
ids = append(ids, row.AlbumID)
}
states, err := q.GetReacquisitionForAlbums(r.Context(), ids)
if err != nil {
h.logger.Warn("admin missing: reacquisition state lookup failed", "err", err)
return
}
byAlbum := make(map[string]dbq.MissingReacquisition, len(states))
for _, s := range states {
byAlbum[uuidToString(s.AlbumID)] = s
}
// The settings service is optional in contexts that only wire routing
// (tests), and the backoff projection is decoration on a list whose real
// job is elsewhere — so fall back to the shipped defaults rather than
// making this a nil-pointer waiting to happen (rule #48).
cfg := reacquisition.Defaults
if h.reacqSettings != nil {
cfg = h.reacqSettings.Get()
}
for i := range groups {
albumID, ok := dirAlbum[groups[i].Directory]
if !ok {
continue
}
state, ok := byAlbum[uuidToString(albumID)]
if !ok {
continue
}
groups[i].Reacquisition = buildReacquisitionState(state, cfg.Backoff(state.Attempts))
}
}
// buildReacquisitionState renders one album's attempt record, projecting the
// next attempt from the last one plus the backoff the current settings imply.
func buildReacquisitionState(
state dbq.MissingReacquisition,
backoff time.Duration,
) *reacquisitionStateView {
out := &reacquisitionStateView{Attempts: int(state.Attempts)}
if state.LastAttemptAt.Valid {
s := formatTimestamp(state.LastAttemptAt)
out.LastAttemptAt = &s
// Only meaningful while more attempts remain; an album that has given
// up has no next attempt to promise.
if !state.GaveUpAt.Valid {
next := formatTimestamp(pgtype.Timestamptz{
Time: state.LastAttemptAt.Time.Add(backoff),
Valid: true,
})
out.NextAttemptAt = &next
}
}
if state.GaveUpAt.Valid {
s := formatTimestamp(state.GaveUpAt)
out.GaveUpAt = &s
}
return out
}