feat: show what re-acquisition has done, per folder — #2527
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user