package api import ( "net/http" "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"` } // 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"` } // 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 } out := adminMissingResponse{ Total: total, Limit: limit, Offset: offset, Groups: groupMissingByDirectory(rows), } 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 }