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).
251 lines
9.0 KiB
Go
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
|
|
}
|