feat(api): admin surface for files the library has lost — #2527
test-go / test (push) Successful in 52s
test-go / integration (push) Successful in 5m21s

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:
2026-08-16 11:48:45 -04:00
parent c3f3a17c6d
commit 4dd0a58d63
5 changed files with 407 additions and 0 deletions
+99
View File
@@ -57,6 +57,19 @@ func (q *Queries) ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (in
return result.RowsAffected(), nil
}
const countMissingTracks = `-- name: CountMissingTracks :one
SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL
`
// Total for the admin surface's badge and paging. Uses the same partial index
// (tracks_missing_since_idx) as the list above.
func (q *Queries) CountMissingTracks(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countMissingTracks)
var count int64
err := row.Scan(&count)
return count, err
}
const countTracksByAlbum = `-- name: CountTracksByAlbum :one
SELECT count(*) FROM tracks WHERE album_id = $1
`
@@ -404,6 +417,92 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
return items, nil
}
const listMissingTracks = `-- name: ListMissingTracks :many
SELECT t.id,
t.title,
t.file_path,
regexp_replace(t.file_path, '/[^/]*$', '') AS directory,
t.missing_since,
t.duration_ms,
albums.id AS album_id,
albums.title AS album_title,
artists.id AS artist_id,
artists.name AS artist_name,
-- Cast is load-bearing: without it sqlc infers the correlated
-- subquery as interface{} and the Go layer loses the timestamp type.
(SELECT MAX(pe.started_at) FROM play_events pe WHERE pe.track_id = t.id)::timestamptz AS last_played_at
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.missing_since IS NOT NULL
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
LIMIT $2 OFFSET $1
`
type ListMissingTracksParams struct {
PageOffset int32
PageLimit int32
}
type ListMissingTracksRow struct {
ID pgtype.UUID
Title string
FilePath string
Directory string
MissingSince pgtype.Timestamptz
DurationMs int32
AlbumID pgtype.UUID
AlbumTitle string
ArtistID pgtype.UUID
ArtistName string
LastPlayedAt pgtype.Timestamptz
}
// The admin review surface for files the scan could not find (#2527).
//
// Ordered by directory, then by the file's own position within its album,
// because the unit an operator actually reasons about is a FOLDER: the case
// this was built for was three whole albums that had been reorganised, and a
// flat list ordered by timestamp presents that as forty unrelated decisions.
// Grouping happens in the handler; the ordering here is what makes a group
// contiguous, so a page boundary splits a directory at worst.
//
// last_played_at is a correlated MAX rather than a join so a track with no
// plays stays in the result with NULL. It is here because "gone six months,
// never played" and "gone yesterday, played 200 times" deserve opposite
// reactions, and the operator can't tell them apart from a path.
func (q *Queries) ListMissingTracks(ctx context.Context, arg ListMissingTracksParams) ([]ListMissingTracksRow, error) {
rows, err := q.db.Query(ctx, listMissingTracks, arg.PageOffset, arg.PageLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListMissingTracksRow
for rows.Next() {
var i ListMissingTracksRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.FilePath,
&i.Directory,
&i.MissingSince,
&i.DurationMs,
&i.AlbumID,
&i.AlbumTitle,
&i.ArtistID,
&i.ArtistName,
&i.LastPlayedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
albums.title AS album_title,
+39
View File
@@ -212,3 +212,42 @@ UPDATE tracks
SET missing_since = NULL
WHERE id = ANY(sqlc.arg(ids)::uuid[])
AND missing_since IS NOT NULL;
-- name: ListMissingTracks :many
-- The admin review surface for files the scan could not find (#2527).
--
-- Ordered by directory, then by the file's own position within its album,
-- because the unit an operator actually reasons about is a FOLDER: the case
-- this was built for was three whole albums that had been reorganised, and a
-- flat list ordered by timestamp presents that as forty unrelated decisions.
-- Grouping happens in the handler; the ordering here is what makes a group
-- contiguous, so a page boundary splits a directory at worst.
--
-- last_played_at is a correlated MAX rather than a join so a track with no
-- plays stays in the result with NULL. It is here because "gone six months,
-- never played" and "gone yesterday, played 200 times" deserve opposite
-- reactions, and the operator can't tell them apart from a path.
SELECT t.id,
t.title,
t.file_path,
regexp_replace(t.file_path, '/[^/]*$', '') AS directory,
t.missing_since,
t.duration_ms,
albums.id AS album_id,
albums.title AS album_title,
artists.id AS artist_id,
artists.name AS artist_name,
-- Cast is load-bearing: without it sqlc infers the correlated
-- subquery as interface{} and the Go layer loses the timestamp type.
(SELECT MAX(pe.started_at) FROM play_events pe WHERE pe.track_id = t.id)::timestamptz AS last_played_at
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.missing_since IS NOT NULL
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset);
-- name: CountMissingTracks :one
-- Total for the admin surface's badge and paging. Uses the same partial index
-- (tracks_missing_since_idx) as the list above.
SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL;