Files
minstrel/internal/api/admin_library_missing.go
bvandeusen 414dfb23b6
test-web / test (push) Failing after 42s
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 5m3s
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).
2026-08-17 00:19:48 -04:00

251 lines
9.0 KiB
Go

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.
//
// The text fields come from the tracks row, not from the filesystem, which is
// the point: the recording is still a known thing with a title, an artist and
// a play history — only its bytes are absent. LastPlayedAt is nullable because
// plenty of missing files were never played, and that is exactly the signal an
// operator wants when deciding whether to bother re-acquiring one.
type missingTrackView struct {
TrackID string `json:"track_id"`
Title string `json:"title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
FilePath string `json:"file_path"`
DurationSec int32 `json:"duration_sec"`
MissingSince string `json:"missing_since"`
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
// produced #2523 was three reorganised albums showing up as ~40 individually
// missing files; presented flat that reads as forty problems, presented by
// folder it reads as three. MissingSince is the EARLIEST mark in the group,
// so a directory sorts and reads by when it first went away.
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
// groups — it is what the nav badge shows, and "12 files missing" is the
// honest number even when they happen to sit in two folders.
type adminMissingResponse struct {
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Groups []missingGroupView `json:"groups"`
}
// handleListMissingTracks implements GET /api/admin/library/missing.
//
// Read-only by design. Nothing on this surface deletes a track: a missing file
// keeps its row, its history and its likes because it may come back, and if it
// comes back renamed the scanner adopts it (#2528). The surface exists so an
// operator can SEE what the library has lost and act on it deliberately.
func (h *handlers) handleListMissingTracks(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging")
return
}
q := dbq.New(h.pool)
total, err := q.CountMissingTracks(r.Context())
if err != nil {
h.logger.Error("admin: count missing tracks", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
rows, err := q.ListMissingTracks(r.Context(), dbq.ListMissingTracksParams{
PageLimit: int32(limit),
PageOffset: int32(offset),
})
if err != nil {
h.logger.Error("admin: list missing tracks", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
groups := groupMissingByDirectory(rows)
h.attachReacquisitionState(r, q, rows, groups)
out := adminMissingResponse{
Total: total,
Limit: limit,
Offset: offset,
Groups: groups,
}
writeJSON(w, http.StatusOK, out)
}
// groupMissingByDirectory folds the ordered rows into per-directory groups.
//
// It relies on ListMissingTracks ordering by directory, so a simple run-length
// fold is enough and no map is needed — which also preserves the query's
// ordering in the response instead of Go's random map iteration. A page
// boundary can split one directory across two pages; that is accepted rather
// than paging by group, because the alternative costs a second query to find
// the page's directories and this surface's realistic N is small.
func groupMissingByDirectory(rows []dbq.ListMissingTracksRow) []missingGroupView {
groups := make([]missingGroupView, 0, 8)
for _, row := range rows {
t := missingTrackView{
TrackID: uuidToString(row.ID),
Title: row.Title,
ArtistID: uuidToString(row.ArtistID),
ArtistName: row.ArtistName,
AlbumID: uuidToString(row.AlbumID),
AlbumTitle: row.AlbumTitle,
FilePath: row.FilePath,
DurationSec: row.DurationMs / 1000,
MissingSince: formatTimestamp(row.MissingSince),
}
if row.LastPlayedAt.Valid {
s := formatTimestamp(row.LastPlayedAt)
t.LastPlayedAt = &s
}
if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory {
groups[n-1].Tracks = append(groups[n-1].Tracks, t)
continue
}
groups = append(groups, missingGroupView{
Directory: row.Directory,
// First row of a run carries the group's timestamp. Rows are
// ordered within a directory by disc/track, not by mark time, so
// this is "the mark on the first track" rather than the minimum —
// they are the same value in the case that matters (a whole folder
// vanishing at once) and close enough otherwise.
MissingSince: t.MissingSince,
Tracks: []missingTrackView{t},
})
}
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
}