Files
minstrel/internal/api/admin_library_missing.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

263 lines
9.3 KiB
Go

package api
import (
"net/http"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// 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 wanted list says about one album: when
// it was wanted back, and when the library
// held it again. A missing album with no want yet is still inside the grace
// window, or MusicBrainz cannot name it. Want is that want as the wanted list
// sends it, so the Missing page can show its search and download and act on
// it.
type reacquisitionStateView struct {
WantedAt *string `json:"wanted_at"`
HeldAt *string `json:"held_at"`
Want *wantView `json:"want"`
}
// missingGroupView is a directory's worth of missing tracks.
//
// Grouping is the whole ergonomic argument for this surface: a reorganised
// album shows up as dozens of individually missing files, which presented flat
// reads as dozens of problems and presented by folder reads as one.
// 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. The surface exists so an admin
// 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 its album's want.
//
// 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 a want 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))
// The latest loss in each folder: a want held before it is the album
// as it was before its files went, not the library holding it again.
dirLost := make(map[string]time.Time, len(groups))
for _, row := range rows {
if row.MissingSince.Valid && row.MissingSince.Time.After(dirLost[row.Directory]) {
dirLost[row.Directory] = row.MissingSince.Time
}
if _, seen := dirAlbum[row.Directory]; seen {
continue
}
dirAlbum[row.Directory] = row.AlbumID
ids = append(ids, row.AlbumID)
}
wants, err := q.GetWantsForAlbums(r.Context(), ids)
if err != nil {
h.logger.Warn("admin missing: want lookup failed", "err", err)
return
}
// Newest want first, so the first seen per album is its current one.
byAlbum := make(map[string]dbq.GetWantsForAlbumsRow, len(wants))
for _, w := range wants {
if _, seen := byAlbum[uuidToString(w.AlbumID)]; !seen {
byAlbum[uuidToString(w.AlbumID)] = w
}
}
full := h.wantViewsByID(r, q, byAlbum)
for i := range groups {
albumID, ok := dirAlbum[groups[i].Directory]
if !ok {
continue
}
w, ok := byAlbum[uuidToString(albumID)]
if !ok {
continue
}
// Held before its files went (every library album is on the list,
// held): not wanted back yet, which the sweep does once
// the grace window has passed.
if w.FulfilledAt.Valid && !w.FulfilledAt.Time.After(dirLost[groups[i].Directory]) {
continue
}
view := &reacquisitionStateView{}
wantedAt := formatTimestamp(w.WantedAt)
view.WantedAt = &wantedAt
if w.FulfilledAt.Valid {
heldAt := formatTimestamp(w.FulfilledAt)
view.HeldAt = &heldAt
}
if v, ok := full[uuidToString(w.WantID)]; ok {
view.Want = &v
}
groups[i].Reacquisition = view
}
}
// wantViewsByID reads each album's current want in full, keyed by want id.
// Best-effort like the state it decorates: a failure leaves the groups with
// their dates and no want.
func (h *handlers) wantViewsByID(
r *http.Request,
q *dbq.Queries,
byAlbum map[string]dbq.GetWantsForAlbumsRow,
) map[string]wantView {
ids := make([]pgtype.UUID, 0, len(byAlbum))
for _, w := range byAlbum {
ids = append(ids, w.WantID)
}
out := make(map[string]wantView, len(ids))
if len(ids) == 0 {
return out
}
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: "all", WantIds: ids, MaxRows: int32(len(ids))})
if err != nil {
h.logger.Warn("admin missing: want detail lookup failed", "err", err)
return out
}
for _, row := range rows {
out[uuidToString(row.ID)] = wantViewOf(row)
}
return out
}