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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user