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 }