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 // (milestone #290, M502 #5520): 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 (M504). 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. 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 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, since #5567): 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 }