feat(api): admin surface for files the library has lost — #2527
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.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// missingRow builds a ListMissingTracksRow with just the fields the grouping
|
||||
// fold reads, so a test case states its directory and title and nothing else.
|
||||
func missingRow(dir, title string, missingAt time.Time) dbq.ListMissingTracksRow {
|
||||
return dbq.ListMissingTracksRow{
|
||||
ID: pgtype.UUID{Bytes: [16]byte{1}, Valid: true},
|
||||
Title: title,
|
||||
FilePath: dir + "/" + title + ".flac",
|
||||
Directory: dir,
|
||||
MissingSince: pgtype.Timestamptz{Time: missingAt, Valid: true},
|
||||
DurationMs: 180_000,
|
||||
AlbumTitle: "Album",
|
||||
ArtistName: "Artist",
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMissingByDirectory(t *testing.T) {
|
||||
base := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("consecutive rows in one directory collapse to one group", func(t *testing.T) {
|
||||
groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{
|
||||
missingRow("/music/Linkin Park/Minutes to Midnight", "Given Up", base),
|
||||
missingRow("/music/Linkin Park/Minutes to Midnight", "Leave Out All the Rest", base),
|
||||
missingRow("/music/Linkin Park/Minutes to Midnight", "Bleed It Out", base),
|
||||
})
|
||||
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("want 1 group, got %d", len(groups))
|
||||
}
|
||||
if got := len(groups[0].Tracks); got != 3 {
|
||||
t.Errorf("want 3 tracks in the group, got %d", got)
|
||||
}
|
||||
if groups[0].Directory != "/music/Linkin Park/Minutes to Midnight" {
|
||||
t.Errorf("unexpected directory %q", groups[0].Directory)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("distinct directories stay separate and keep query order", func(t *testing.T) {
|
||||
groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{
|
||||
missingRow("/music/A/One", "a1", base),
|
||||
missingRow("/music/B/Two", "b1", base),
|
||||
missingRow("/music/B/Two", "b2", base),
|
||||
missingRow("/music/C/Three", "c1", base),
|
||||
})
|
||||
|
||||
if len(groups) != 3 {
|
||||
t.Fatalf("want 3 groups, got %d", len(groups))
|
||||
}
|
||||
wantDirs := []string{"/music/A/One", "/music/B/Two", "/music/C/Three"}
|
||||
for i, want := range wantDirs {
|
||||
if groups[i].Directory != want {
|
||||
t.Errorf("group %d: want %q, got %q", i, want, groups[i].Directory)
|
||||
}
|
||||
}
|
||||
if got := len(groups[1].Tracks); got != 2 {
|
||||
t.Errorf("middle group: want 2 tracks, got %d", got)
|
||||
}
|
||||
})
|
||||
|
||||
// The fold is run-length, not a map, so a directory that appears in two
|
||||
// non-adjacent runs legitimately produces two groups. That can only happen
|
||||
// if the query's ORDER BY directory is dropped — pinning it here means such
|
||||
// a change fails a test instead of silently fragmenting the UI.
|
||||
t.Run("a directory split by a foreign row yields two groups", func(t *testing.T) {
|
||||
groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{
|
||||
missingRow("/music/A", "a1", base),
|
||||
missingRow("/music/B", "b1", base),
|
||||
missingRow("/music/A", "a2", base),
|
||||
})
|
||||
|
||||
if len(groups) != 3 {
|
||||
t.Fatalf("want 3 groups from an unordered input, got %d", len(groups))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no rows yields an empty, non-nil slice", func(t *testing.T) {
|
||||
groups := groupMissingByDirectory(nil)
|
||||
if groups == nil {
|
||||
t.Fatal("want a non-nil slice so the JSON encoder emits [] not null")
|
||||
}
|
||||
if len(groups) != 0 {
|
||||
t.Errorf("want 0 groups, got %d", len(groups))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a never-played track carries a null last_played_at", func(t *testing.T) {
|
||||
groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{
|
||||
missingRow("/music/A", "a1", base),
|
||||
})
|
||||
|
||||
if groups[0].Tracks[0].LastPlayedAt != nil {
|
||||
t.Errorf("want nil last_played_at, got %v", *groups[0].Tracks[0].LastPlayedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a played track carries its timestamp", func(t *testing.T) {
|
||||
row := missingRow("/music/A", "a1", base)
|
||||
row.LastPlayedAt = pgtype.Timestamptz{Time: base.Add(-48 * time.Hour), Valid: true}
|
||||
|
||||
groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{row})
|
||||
|
||||
got := groups[0].Tracks[0].LastPlayedAt
|
||||
if got == nil {
|
||||
t.Fatal("want a last_played_at, got nil")
|
||||
}
|
||||
if want := "2026-08-04T12:00:00Z"; *got != want {
|
||||
t.Errorf("want %q, got %q", want, *got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration is reported in seconds", func(t *testing.T) {
|
||||
groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{
|
||||
missingRow("/music/A", "a1", base),
|
||||
})
|
||||
|
||||
if got := groups[0].Tracks[0].DurationSec; got != 180 {
|
||||
t.Errorf("want 180s from 180000ms, got %d", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user