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