The scan has marked missing files since f6d1cf24 and every selection
path filters them out, so they cause no harm -- and are invisible. The
operator found out about the first batch only because an unrelated MBID
backfill logged "no such file or directory" forty times.
GET /api/admin/library/missing reports them, grouped by directory. The
grouping is the whole ergonomic argument: the case that produced #2523
was three reorganised albums, which a flat list renders as forty
unrelated problems and a folder list renders as three decisions.
ListMissingTracks orders by directory so the handler can fold runs
without a map, which also keeps the query's ordering instead of Go's
random map iteration.
Each row carries last_played_at, nullable, because "gone six months,
never played" and "gone yesterday, played 200 times" deserve opposite
reactions and a file path tells you neither. The correlated MAX needs
its ::timestamptz cast or sqlc infers interface{} and the Go layer
loses the type.
Read-only, deliberately. Nothing here deletes: a missing file keeps its
row, its play history and its likes because it may come back, and if it
comes back renamed the scanner adopts it (#2528). The route sits under
/library rather than /tracks so it can't be confused with the
destructive DELETE /admin/tracks/{id} beside it.
135 lines
5.0 KiB
Go
135 lines
5.0 KiB
Go
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
|
|
}
|