feat: one Missing files page under Collection: folders gone, each with its album's want, last search or download, and Search, Releases and Remove; an album held before its files went is no longer shown as held again (M504 #5626)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m23s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 6m2s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m23s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 6m2s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -27,7 +27,7 @@ class NotificationUiTest {
|
||||
// Library health pages the app has no screen for open the Admin landing.
|
||||
assertEquals(Admin, routeForLink("/collection/duplicates"))
|
||||
assertEquals(Admin, routeForLink("/collection/duplicates?view=resolved"))
|
||||
assertEquals(Admin, routeForLink("/admin/missing-files"))
|
||||
assertEquals(Admin, routeForLink("/collection/missing"))
|
||||
assertEquals(Admin, routeForLink("/admin"))
|
||||
assertNull(routeForLink("/somewhere-new"))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
@@ -31,10 +32,13 @@ type missingTrackView struct {
|
||||
// reacquisitionStateView is what the wanted list says about one album
|
||||
// (milestone #290, M502 #5520): when it was wanted back, and when the library
|
||||
// held it again. A missing album with no want yet is still inside the grace
|
||||
// window, or MusicBrainz cannot name it.
|
||||
// window, or MusicBrainz cannot name it. Want is that want as the wanted list
|
||||
// sends it, so the Missing page can show its search and download and act on
|
||||
// it (M504).
|
||||
type reacquisitionStateView struct {
|
||||
WantedAt *string `json:"wanted_at"`
|
||||
HeldAt *string `json:"held_at"`
|
||||
WantedAt *string `json:"wanted_at"`
|
||||
HeldAt *string `json:"held_at"`
|
||||
Want *wantView `json:"want"`
|
||||
}
|
||||
|
||||
// missingGroupView is a directory's worth of missing tracks.
|
||||
@@ -174,7 +178,13 @@ func (h *handlers) attachReacquisitionState(
|
||||
// groupMissingByDirectory folded them in.
|
||||
dirAlbum := make(map[string]pgtype.UUID, len(groups))
|
||||
ids := make([]pgtype.UUID, 0, len(groups))
|
||||
// The latest loss in each folder: a want held before it is the album
|
||||
// as it was before its files went, not the library holding it again.
|
||||
dirLost := make(map[string]time.Time, len(groups))
|
||||
for _, row := range rows {
|
||||
if row.MissingSince.Valid && row.MissingSince.Time.After(dirLost[row.Directory]) {
|
||||
dirLost[row.Directory] = row.MissingSince.Time
|
||||
}
|
||||
if _, seen := dirAlbum[row.Directory]; seen {
|
||||
continue
|
||||
}
|
||||
@@ -194,6 +204,7 @@ func (h *handlers) attachReacquisitionState(
|
||||
byAlbum[uuidToString(w.AlbumID)] = w
|
||||
}
|
||||
}
|
||||
full := h.wantViewsByID(r, q, byAlbum)
|
||||
for i := range groups {
|
||||
albumID, ok := dirAlbum[groups[i].Directory]
|
||||
if !ok {
|
||||
@@ -203,6 +214,12 @@ func (h *handlers) attachReacquisitionState(
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Held before its files went (every library album is on the list,
|
||||
// held, since #5567): not wanted back yet, which the sweep does once
|
||||
// the grace window has passed.
|
||||
if w.FulfilledAt.Valid && !w.FulfilledAt.Time.After(dirLost[groups[i].Directory]) {
|
||||
continue
|
||||
}
|
||||
view := &reacquisitionStateView{}
|
||||
wantedAt := formatTimestamp(w.WantedAt)
|
||||
view.WantedAt = &wantedAt
|
||||
@@ -210,6 +227,36 @@ func (h *handlers) attachReacquisitionState(
|
||||
heldAt := formatTimestamp(w.FulfilledAt)
|
||||
view.HeldAt = &heldAt
|
||||
}
|
||||
if v, ok := full[uuidToString(w.WantID)]; ok {
|
||||
view.Want = &v
|
||||
}
|
||||
groups[i].Reacquisition = view
|
||||
}
|
||||
}
|
||||
|
||||
// wantViewsByID reads each album's current want in full, keyed by want id.
|
||||
// Best-effort like the state it decorates: a failure leaves the groups with
|
||||
// their dates and no want.
|
||||
func (h *handlers) wantViewsByID(
|
||||
r *http.Request,
|
||||
q *dbq.Queries,
|
||||
byAlbum map[string]dbq.GetWantsForAlbumsRow,
|
||||
) map[string]wantView {
|
||||
ids := make([]pgtype.UUID, 0, len(byAlbum))
|
||||
for _, w := range byAlbum {
|
||||
ids = append(ids, w.WantID)
|
||||
}
|
||||
out := make(map[string]wantView, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: "all", WantIds: ids, MaxRows: int32(len(ids))})
|
||||
if err != nil {
|
||||
h.logger.Warn("admin missing: want detail lookup failed", "err", err)
|
||||
return out
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[uuidToString(row.ID)] = wantViewOf(row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// seedMissingWant marks a track's file missing for two days and puts its
|
||||
// album on the wanted list, held at heldDaysAgo (0 for an open want) and
|
||||
// last searched with nothing found.
|
||||
func seedMissingWant(t *testing.T, h *handlers, trackID, albumID pgtype.UUID, group string, heldDaysAgo int) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, err := h.pool.Exec(ctx,
|
||||
`UPDATE tracks SET missing_since = now() - interval '2 days' WHERE id = $1`, trackID); err != nil {
|
||||
t.Fatalf("mark missing: %v", err)
|
||||
}
|
||||
if _, err := h.pool.Exec(ctx, `DELETE FROM wants WHERE release_group_mbid = $1`, group); err != nil {
|
||||
t.Fatalf("clear want: %v", err)
|
||||
}
|
||||
var id pgtype.UUID
|
||||
if err := h.pool.QueryRow(ctx,
|
||||
`INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id,
|
||||
wanted_at, fulfilled_at, searched_at, search_outcome)
|
||||
VALUES ($1, 'artist-'||$1, 'Artist', 'Album '||$1, 'library', $2,
|
||||
now() - interval '30 days',
|
||||
CASE WHEN $3::int > 0 THEN now() - make_interval(days => $3::int) END,
|
||||
now() - interval '1 hour', 'nothing')
|
||||
RETURNING id`, group, albumID, heldDaysAgo).Scan(&id); err != nil {
|
||||
t.Fatalf("insert want: %v", err)
|
||||
}
|
||||
return uuidToString(id)
|
||||
}
|
||||
|
||||
// A folder's album that was held before its files went is not wanted back
|
||||
// yet (every library album is on the list, held, since #5567); an open want
|
||||
// comes with its search, so the Missing page can show it and act on it (M504).
|
||||
func TestListMissingTracks_WantState(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
admin := seedUser(t, pool, "admin", "pw", true)
|
||||
|
||||
heldTrack, _ := seedTrackOnDisk(t, h, "missheld")
|
||||
openTrack, _ := seedTrackOnDisk(t, h, "missopen")
|
||||
seedMissingWant(t, h, heldTrack.ID, heldTrack.AlbumID, "rg-missing-held", 10)
|
||||
openID := seedMissingWant(t, h, openTrack.ID, openTrack.AlbumID, "rg-missing-open", 0)
|
||||
|
||||
req := withUser(httptest.NewRequest(http.MethodGet, "/api/admin/library/missing", nil), admin)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleListMissingTracks(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp adminMissingResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Groups) != 2 {
|
||||
t.Fatalf("groups = %d, want 2", len(resp.Groups))
|
||||
}
|
||||
byTitle := map[string]missingGroupView{}
|
||||
for _, g := range resp.Groups {
|
||||
byTitle[g.Tracks[0].Title] = g
|
||||
}
|
||||
|
||||
if r := byTitle["AdmQ Track missheld"].Reacquisition; r != nil {
|
||||
t.Errorf("held before the loss: reacquisition = %+v, want none", r)
|
||||
}
|
||||
r := byTitle["AdmQ Track missopen"].Reacquisition
|
||||
if r == nil || r.WantedAt == nil || r.HeldAt != nil {
|
||||
t.Fatalf("open want: reacquisition = %+v, want wanted and not held", r)
|
||||
}
|
||||
if r.Want == nil || r.Want.ID != openID {
|
||||
t.Fatalf("open want: want = %+v, want id %s", r.Want, openID)
|
||||
}
|
||||
if r.Want.Search == nil || r.Want.Search.Outcome != "nothing" {
|
||||
t.Errorf("open want: search = %+v, want outcome nothing", r.Want.Search)
|
||||
}
|
||||
}
|
||||
@@ -270,59 +270,65 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
for _, row := range rows {
|
||||
out.Total = row.Total
|
||||
v := wantView{
|
||||
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
|
||||
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
|
||||
PrimaryType: row.PrimaryType, SecondaryTypes: row.SecondaryTypes,
|
||||
Year: first4(row.FirstReleaseDate), Reason: row.Reason,
|
||||
WantedAt: formatTimestamp(row.WantedAt), Upgrade: row.Upgrade, HeldQuality: row.HeldQuality,
|
||||
ImageURL: groupArtURL(musicbrainz.ReleaseGroup{MBID: row.ReleaseGroupMbid, Title: row.Title, ArtistCredit: row.ArtistName}),
|
||||
}
|
||||
if row.LibraryArtistID.Valid {
|
||||
v.ArtistID = uuidToString(row.LibraryArtistID)
|
||||
}
|
||||
if row.AlbumID.Valid {
|
||||
v.AlbumID = uuidToString(row.AlbumID)
|
||||
}
|
||||
if row.RequestedBy != nil {
|
||||
v.RequestedBy = *row.RequestedBy
|
||||
}
|
||||
if row.FulfilledAt.Valid {
|
||||
held := formatTimestamp(row.FulfilledAt)
|
||||
v.HeldAt = &held
|
||||
}
|
||||
if v.SecondaryTypes == nil {
|
||||
v.SecondaryTypes = []string{}
|
||||
}
|
||||
if row.SearchedAt.Valid {
|
||||
v.Search = &wantSearchView{
|
||||
SearchedAt: formatTimestamp(row.SearchedAt), Outcome: row.SearchOutcome,
|
||||
Considered: row.SearchConsidered, BestRelease: row.BestRelease,
|
||||
BestVerdict: row.BestVerdict, Error: row.SearchError,
|
||||
}
|
||||
}
|
||||
if row.GrabName != nil {
|
||||
v.Grab = &wantGrabView{ID: uuidToString(row.GrabID), Name: *row.GrabName,
|
||||
GrabbedAt: formatTimestamp(row.GrabbedAt), Live: !row.GrabEndedAt.Valid,
|
||||
Origin: deref(row.GrabOrigin), Quality: deref(row.GrabQuality), EndReason: deref(row.GrabEndReason),
|
||||
ImportState: deref(row.GrabImportState), ImportReason: deref(row.GrabImportReason),
|
||||
EndDetail: deref(row.GrabEndDetail)}
|
||||
if row.GrabProgress != nil {
|
||||
v.Grab.Progress = *row.GrabProgress
|
||||
}
|
||||
if row.GrabImportedFiles != nil {
|
||||
v.Grab.ImportedFiles = *row.GrabImportedFiles
|
||||
}
|
||||
if row.GrabImportedAt.Valid {
|
||||
at := formatTimestamp(row.GrabImportedAt)
|
||||
v.Grab.ImportedAt = &at
|
||||
}
|
||||
}
|
||||
out.Items = append(out.Items, v)
|
||||
out.Items = append(out.Items, wantViewOf(row))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// wantViewOf is one ListWants row as the wanted list and the Missing page
|
||||
// send it.
|
||||
func wantViewOf(row dbq.ListWantsRow) wantView {
|
||||
v := wantView{
|
||||
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
|
||||
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
|
||||
PrimaryType: row.PrimaryType, SecondaryTypes: row.SecondaryTypes,
|
||||
Year: first4(row.FirstReleaseDate), Reason: row.Reason,
|
||||
WantedAt: formatTimestamp(row.WantedAt), Upgrade: row.Upgrade, HeldQuality: row.HeldQuality,
|
||||
ImageURL: groupArtURL(musicbrainz.ReleaseGroup{MBID: row.ReleaseGroupMbid, Title: row.Title, ArtistCredit: row.ArtistName}),
|
||||
}
|
||||
if row.LibraryArtistID.Valid {
|
||||
v.ArtistID = uuidToString(row.LibraryArtistID)
|
||||
}
|
||||
if row.AlbumID.Valid {
|
||||
v.AlbumID = uuidToString(row.AlbumID)
|
||||
}
|
||||
if row.RequestedBy != nil {
|
||||
v.RequestedBy = *row.RequestedBy
|
||||
}
|
||||
if row.FulfilledAt.Valid {
|
||||
held := formatTimestamp(row.FulfilledAt)
|
||||
v.HeldAt = &held
|
||||
}
|
||||
if v.SecondaryTypes == nil {
|
||||
v.SecondaryTypes = []string{}
|
||||
}
|
||||
if row.SearchedAt.Valid {
|
||||
v.Search = &wantSearchView{
|
||||
SearchedAt: formatTimestamp(row.SearchedAt), Outcome: row.SearchOutcome,
|
||||
Considered: row.SearchConsidered, BestRelease: row.BestRelease,
|
||||
BestVerdict: row.BestVerdict, Error: row.SearchError,
|
||||
}
|
||||
}
|
||||
if row.GrabName != nil {
|
||||
v.Grab = &wantGrabView{ID: uuidToString(row.GrabID), Name: *row.GrabName,
|
||||
GrabbedAt: formatTimestamp(row.GrabbedAt), Live: !row.GrabEndedAt.Valid,
|
||||
Origin: deref(row.GrabOrigin), Quality: deref(row.GrabQuality), EndReason: deref(row.GrabEndReason),
|
||||
ImportState: deref(row.GrabImportState), ImportReason: deref(row.GrabImportReason),
|
||||
EndDetail: deref(row.GrabEndDetail)}
|
||||
if row.GrabProgress != nil {
|
||||
v.Grab.Progress = *row.GrabProgress
|
||||
}
|
||||
if row.GrabImportedFiles != nil {
|
||||
v.Grab.ImportedFiles = *row.GrabImportedFiles
|
||||
}
|
||||
if row.GrabImportedAt.Valid {
|
||||
at := formatTimestamp(row.GrabImportedAt)
|
||||
v.Grab.ImportedAt = &at
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// wantTracksView is an album's track list for the Wanted view (#5572): the
|
||||
// library's own tracks for an album it holds or held, else MusicBrainz's
|
||||
// standard edition of what is wanted.
|
||||
|
||||
@@ -1147,18 +1147,21 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
|
||||
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
|
||||
END
|
||||
AND ($2::text = '' OR w.artist_mbid = $2::text)
|
||||
AND ($3::text = ''
|
||||
OR strpos(lower(w.artist_name), lower($3::text)) > 0
|
||||
OR strpos(lower(w.title), lower($3::text)) > 0)
|
||||
-- want_ids narrows to given wants: the Missing page's folders (M504).
|
||||
AND (COALESCE(cardinality($3::uuid[]), 0) = 0 OR w.id = ANY($3::uuid[]))
|
||||
AND ($4::text = ''
|
||||
OR strpos(lower(w.artist_name), lower($4::text)) > 0
|
||||
OR strpos(lower(w.title), lower($4::text)) > 0)
|
||||
ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), w.artist_name)), w.artist_mbid,
|
||||
NULLIF(COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, ''), '') NULLS LAST,
|
||||
lower(w.title), w.id
|
||||
LIMIT $5 OFFSET $4
|
||||
LIMIT $6 OFFSET $5
|
||||
`
|
||||
|
||||
type ListWantsParams struct {
|
||||
State string
|
||||
ArtistMbid string
|
||||
WantIds []pgtype.UUID
|
||||
Q string
|
||||
Skip int32
|
||||
MaxRows int32
|
||||
@@ -1204,7 +1207,8 @@ type ListWantsRow struct {
|
||||
}
|
||||
|
||||
// The Wanted page: open wants (all but missing ones, or only missing ones),
|
||||
// the held ones, or all of them; artist_mbid narrows to one artist's page. Ordered like the library (#5572): artists A–Z by the
|
||||
// the held ones, or all of them; artist_mbid narrows to one artist's page,
|
||||
// want_ids to given wants. Ordered like the library (#5572): artists A–Z by the
|
||||
// library's sort name when it holds the artist, each artist's albums by
|
||||
// release date, so the page groups them as they arrive. q narrows to an
|
||||
// artist or album title; total counts every match, past the page.
|
||||
@@ -1212,6 +1216,7 @@ func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWan
|
||||
rows, err := q.db.Query(ctx, listWants,
|
||||
arg.State,
|
||||
arg.ArtistMbid,
|
||||
arg.WantIds,
|
||||
arg.Q,
|
||||
arg.Skip,
|
||||
arg.MaxRows,
|
||||
|
||||
@@ -177,7 +177,8 @@ DELETE FROM wants WHERE id = $1;
|
||||
|
||||
-- name: ListWants :many
|
||||
-- The Wanted page: open wants (all but missing ones, or only missing ones),
|
||||
-- the held ones, or all of them; artist_mbid narrows to one artist's page. Ordered like the library (#5572): artists A–Z by the
|
||||
-- the held ones, or all of them; artist_mbid narrows to one artist's page,
|
||||
-- want_ids to given wants. Ordered like the library (#5572): artists A–Z by the
|
||||
-- library's sort name when it holds the artist, each artist's albums by
|
||||
-- release date, so the page groups them as they arrive. q narrows to an
|
||||
-- artist or album title; total counts every match, past the page.
|
||||
@@ -216,6 +217,8 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
|
||||
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
|
||||
END
|
||||
AND (sqlc.arg(artist_mbid)::text = '' OR w.artist_mbid = sqlc.arg(artist_mbid)::text)
|
||||
-- want_ids narrows to given wants: the Missing page's folders (M504).
|
||||
AND (COALESCE(cardinality(sqlc.arg(want_ids)::uuid[]), 0) = 0 OR w.id = ANY(sqlc.arg(want_ids)::uuid[]))
|
||||
AND (sqlc.arg(q)::text = ''
|
||||
OR strpos(lower(w.artist_name), lower(sqlc.arg(q)::text)) > 0
|
||||
OR strpos(lower(w.title), lower(sqlc.arg(q)::text)) > 0)
|
||||
|
||||
@@ -108,7 +108,7 @@ func Render(kind Kind, payload []byte) Rendered {
|
||||
return Rendered{
|
||||
Title: plural(p.Count, "track went missing", "tracks went missing"),
|
||||
Body: "Their files are no longer where the library expects them.",
|
||||
Link: "/admin/missing-files",
|
||||
Link: "/collection/missing",
|
||||
}
|
||||
case KindDuplicatesFound:
|
||||
return Rendered{
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { AdminQueue, AdminSummary } from '#lib/api/types.js';
|
||||
// The queues that wait on an admin (#5605), in the order the Overview lists
|
||||
// them. The rails and the Overview read the same list, so a queue that
|
||||
// needs you is counted the same in every place. Requests live under Admin;
|
||||
// the library's health queues under Collection (M504).
|
||||
// the library's health queues, Missing files among them, under Collection
|
||||
// (M504).
|
||||
export type QueueKey = keyof AdminSummary;
|
||||
|
||||
export type QueueDef = {
|
||||
@@ -21,7 +22,7 @@ export const QUEUES: QueueDef[] = [
|
||||
{ key: 'reports', href: '/collection/reports', one: 'broken track reported', many: 'broken tracks reported', none: 'reports' },
|
||||
{ key: 'duplicates', href: '/collection/duplicates', one: 'set of duplicates to review', many: 'sets of duplicates to review', none: 'duplicates' },
|
||||
{ key: 'suspect', href: '/collection/suspect-sources', one: 'track from a suspect source', many: 'tracks from suspect sources', none: 'suspect sources' },
|
||||
{ key: 'missing', href: '/admin/missing-files', one: 'missing file', many: 'missing files', none: 'missing files' }
|
||||
{ key: 'missing', href: '/collection/missing', one: 'missing file', many: 'missing files', none: 'missing files' }
|
||||
];
|
||||
|
||||
export function countOf(summary: AdminSummary | undefined, key: QueueKey): number {
|
||||
|
||||
@@ -363,6 +363,9 @@ export type AdminMissingTrack = {
|
||||
export type AdminReacquisitionState = {
|
||||
wanted_at: string | null;
|
||||
held_at: string | null;
|
||||
// The want itself, as the wanted list sends it (M504), so the Missing page
|
||||
// shows its search and download and acts on it.
|
||||
want: Want | null;
|
||||
};
|
||||
|
||||
// A directory's worth of missing tracks. The server groups because the unit an
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('NotificationBell', () => {
|
||||
kind: 'tracks_missing',
|
||||
title: '4 tracks went missing',
|
||||
body: 'Their files are no longer where the library expects them.',
|
||||
link: '/admin/missing-files',
|
||||
link: '/collection/missing',
|
||||
read_at: new Date().toISOString()
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
// second, accented count when downloads are waiting on a person. Unmatched
|
||||
// holds the library albums no MusicBrainz album matched (#5579). Upgrades
|
||||
// holds the albums held but wanted for a better copy (#5524). Reports,
|
||||
// Duplicates and Suspect sources are the library's health queues (M504):
|
||||
// their counts come from the admin summary and are accented, as they wait
|
||||
// on a person.
|
||||
// Duplicates, Suspect sources and Missing files are the library's health
|
||||
// queues (M504): their counts come from the admin summary and are
|
||||
// accented, as they wait on a person. Missing files counts tracks.
|
||||
|
||||
let { counts, summary }: { counts?: WantCounts; summary?: AdminSummary } = $props();
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
const items = $derived([
|
||||
{ href: '/collection', label: 'Catalogue', count: counts?.artists, exact: true, also: ['/collection/artist/'] },
|
||||
{ href: '/collection/wanted', label: 'Wanted', count: counts?.wanted },
|
||||
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
|
||||
{ href: '/collection/upgrades', label: 'Upgrades', count: counts?.upgrades },
|
||||
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
|
||||
{ href: '/collection/following', label: 'Following', count: counts?.following },
|
||||
@@ -26,7 +25,8 @@
|
||||
{ href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched },
|
||||
{ href: '/collection/reports', label: 'Reports', attention: countOf(summary, 'reports') },
|
||||
{ href: '/collection/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
|
||||
{ href: '/collection/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') }
|
||||
{ href: '/collection/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') },
|
||||
{ href: '/collection/missing', label: 'Missing files', attention: countOf(summary, 'missing') }
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
import QuickFilter from '#lib/components/QuickFilter.svelte';
|
||||
import WantCandidates from '#lib/components/WantCandidates.svelte';
|
||||
import WantTracks from './WantTracks.svelte';
|
||||
import type { SearchResult, Want, WantState } from '#lib/api/types.js';
|
||||
import { needsYou, searched, wantStatus } from '#lib/wanted/wantStatus.js';
|
||||
import type { Want, WantState } from '#lib/api/types.js';
|
||||
|
||||
// Albums on the Collection's list (M502 #5520–#5522): the ones still
|
||||
// wanted, the missing ones wanted back, or, given an artist, every album
|
||||
@@ -77,25 +78,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function searched(w: Want, r: SearchResult): string {
|
||||
let said: string;
|
||||
switch (r.outcome) {
|
||||
case 'grabbed':
|
||||
said = `Took ${r.grabbed ?? r.best_release}.`;
|
||||
break;
|
||||
case 'waiting':
|
||||
said = `Found ${r.considered}; nothing taken. Releases shows why.`;
|
||||
break;
|
||||
case 'nothing':
|
||||
said = r.considered ? `Found ${r.considered}, none of them ${w.title}.` : `Nothing found for ${w.title}.`;
|
||||
break;
|
||||
default:
|
||||
said = r.error ?? 'The search failed.';
|
||||
}
|
||||
if (r.narrowed.length) said += ` ${r.narrowed.map((n) => n.label).join(', ')} didn't answer.`;
|
||||
return said;
|
||||
}
|
||||
|
||||
async function onSearch(w: Want) {
|
||||
searching = w.id;
|
||||
try {
|
||||
@@ -112,56 +94,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// A download still running, in a line: how far it has got, or what its
|
||||
// import is doing or waiting on.
|
||||
function downloading(w: Want): string | null {
|
||||
const g = w.grab;
|
||||
if (!g?.live) return null;
|
||||
switch (g.import_state) {
|
||||
case 'downloading':
|
||||
return `Downloading ${Math.floor(g.progress * 100)}% · ${g.name}`;
|
||||
case 'importing':
|
||||
return `Downloaded; checking it is the album and filing it · ${g.name}`;
|
||||
case 'waiting':
|
||||
return `Downloaded; ${g.import_reason || 'waiting to be imported'}`;
|
||||
case 'review':
|
||||
return `Needs you: ${g.import_reason}`;
|
||||
case 'failed':
|
||||
return `Import failed: ${g.import_reason}`;
|
||||
}
|
||||
const who = g.origin === 'operator' ? 'taken by you' : 'taken by the schedule';
|
||||
return `Downloading ${g.name}, ${who} ${relativeTime(g.grabbed_at)}`;
|
||||
}
|
||||
|
||||
// What the schedule last did about a want, in a line.
|
||||
function status(w: Want): string {
|
||||
const live = downloading(w);
|
||||
if (live) return live;
|
||||
const s = w.search;
|
||||
let said = 'Not searched yet';
|
||||
if (s) {
|
||||
const when = `Searched ${relativeTime(s.searched_at)}`;
|
||||
switch (s.outcome) {
|
||||
case 'nothing':
|
||||
said = s.considered ? `${when}: ${s.considered} found, none of them this album` : `${when}: nothing found`;
|
||||
break;
|
||||
case 'waiting':
|
||||
said = `${when}: ${s.considered} found, nothing taken`;
|
||||
break;
|
||||
case 'grabbed':
|
||||
said = `${when}: took ${s.best_release}`;
|
||||
break;
|
||||
default:
|
||||
said = `${when}: ${s.error}`;
|
||||
}
|
||||
}
|
||||
const g = w.grab;
|
||||
if (g?.import_state === 'refused') said += `. Last download refused: ${g.import_reason}`;
|
||||
else if (g && !g.live && ['stalled', 'failed', 'gone'].includes(g.end_reason) && g.end_detail)
|
||||
said += `. Last download given up: ${g.end_detail}`;
|
||||
return said;
|
||||
}
|
||||
|
||||
// How a held album arrived, when Minstrel fetched it.
|
||||
function arrived(w: Want): string | null {
|
||||
const g = w.grab;
|
||||
@@ -179,7 +111,6 @@
|
||||
return w.held_quality ? `You have ${w.held_quality}; looking for better` : 'Looking for a better copy';
|
||||
}
|
||||
|
||||
const attention = (w: Want) => w.grab?.live && (w.grab.import_state === 'review' || w.grab.import_state === 'failed');
|
||||
|
||||
function why(w: Want): string {
|
||||
if (w.reason === 'request') return w.requested_by ? `Requested by ${w.requested_by}` : 'Requested';
|
||||
@@ -243,14 +174,11 @@
|
||||
<ListChecks size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||||
<p class="mt-3 text-text-primary">
|
||||
{#if artist}None of this artist's albums are in the catalogue.
|
||||
{:else if list === 'missing'}No missing albums are wanted back.
|
||||
{:else if list === 'upgrade'}No album is wanted for a better copy.
|
||||
{:else}Nothing is wanted.{/if}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
{#if list === 'missing'}
|
||||
An album whose files stay missing past the grace window on the Missing files page lands here.
|
||||
{:else if list === 'upgrade'}
|
||||
{#if list === 'upgrade'}
|
||||
An album you have below the quality set under
|
||||
<a href="/admin/acquisition" class="text-accent-fg hover:underline">Acquisition</a>, or with a video
|
||||
rip among its tracks, lands here.
|
||||
@@ -301,11 +229,11 @@
|
||||
{/if}
|
||||
{#if searchable(w)}
|
||||
<div
|
||||
class="text-xs {attention(w) ? 'text-text-primary' : 'truncate text-text-secondary'}"
|
||||
class="text-xs {needsYou(w) ? 'text-text-primary' : 'truncate text-text-secondary'}"
|
||||
data-testid="want-search"
|
||||
>
|
||||
{status(w)}
|
||||
{#if attention(w)}
|
||||
{wantStatus(w)}
|
||||
{#if needsYou(w)}
|
||||
<a href="/collection/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -116,9 +116,9 @@ function setup(
|
||||
|
||||
describe('WantList', () => {
|
||||
test('asks for its own state', () => {
|
||||
setup([], 'missing');
|
||||
expect(createWantsQuery).toHaveBeenCalledWith('missing', '');
|
||||
expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy();
|
||||
setup([], 'upgrade');
|
||||
expect(createWantsQuery).toHaveBeenCalledWith('upgrade', '');
|
||||
expect(screen.getByText(/no album is wanted for a better copy/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a want says why it is wanted', () => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import type { SearchResult, Want } from '#lib/api/types.js';
|
||||
|
||||
// How a want reads in a line, wherever it is shown: the Collection's lists
|
||||
// and the Missing page's folders (M504).
|
||||
|
||||
// What a Search the operator ran found, for its toast.
|
||||
export function searched(w: Want, r: SearchResult): string {
|
||||
let said: string;
|
||||
switch (r.outcome) {
|
||||
case 'grabbed':
|
||||
said = `Took ${r.grabbed ?? r.best_release}.`;
|
||||
break;
|
||||
case 'waiting':
|
||||
said = `Found ${r.considered}; nothing taken. Releases shows why.`;
|
||||
break;
|
||||
case 'nothing':
|
||||
said = r.considered ? `Found ${r.considered}, none of them ${w.title}.` : `Nothing found for ${w.title}.`;
|
||||
break;
|
||||
default:
|
||||
said = r.error ?? 'The search failed.';
|
||||
}
|
||||
if (r.narrowed.length) said += ` ${r.narrowed.map((n) => n.label).join(', ')} didn't answer.`;
|
||||
return said;
|
||||
}
|
||||
|
||||
// A download still running, in a line: how far it has got, or what its
|
||||
// import is doing or waiting on.
|
||||
export function downloading(w: Want): string | null {
|
||||
const g = w.grab;
|
||||
if (!g?.live) return null;
|
||||
switch (g.import_state) {
|
||||
case 'downloading':
|
||||
return `Downloading ${Math.floor(g.progress * 100)}% · ${g.name}`;
|
||||
case 'importing':
|
||||
return `Downloaded; checking it is the album and filing it · ${g.name}`;
|
||||
case 'waiting':
|
||||
return `Downloaded; ${g.import_reason || 'waiting to be imported'}`;
|
||||
case 'review':
|
||||
return `Needs you: ${g.import_reason}`;
|
||||
case 'failed':
|
||||
return `Import failed: ${g.import_reason}`;
|
||||
}
|
||||
const who = g.origin === 'operator' ? 'taken by you' : 'taken by the schedule';
|
||||
return `Downloading ${g.name}, ${who} ${relativeTime(g.grabbed_at)}`;
|
||||
}
|
||||
|
||||
// A download waiting on a person: an import to review, or one that failed.
|
||||
export const needsYou = (w: Want) =>
|
||||
!!w.grab?.live && (w.grab.import_state === 'review' || w.grab.import_state === 'failed');
|
||||
|
||||
// What the schedule last did about a want, in a line.
|
||||
export function wantStatus(w: Want): string {
|
||||
const live = downloading(w);
|
||||
if (live) return live;
|
||||
const s = w.search;
|
||||
let said = 'Not searched yet';
|
||||
if (s) {
|
||||
const when = `Searched ${relativeTime(s.searched_at)}`;
|
||||
switch (s.outcome) {
|
||||
case 'nothing':
|
||||
said = s.considered ? `${when}: ${s.considered} found, none of them this album` : `${when}: nothing found`;
|
||||
break;
|
||||
case 'waiting':
|
||||
said = `${when}: ${s.considered} found, nothing taken`;
|
||||
break;
|
||||
case 'grabbed':
|
||||
said = `${when}: took ${s.best_release}`;
|
||||
break;
|
||||
default:
|
||||
said = `${when}: ${s.error}`;
|
||||
}
|
||||
}
|
||||
const g = w.grab;
|
||||
if (g?.import_state === 'refused') said += `. Last download refused: ${g.import_reason}`;
|
||||
else if (g && !g.live && ['stalled', 'failed', 'gone'].includes(g.end_reason) && g.end_detail)
|
||||
said += `. Last download given up: ${g.end_detail}`;
|
||||
return said;
|
||||
}
|
||||
@@ -29,7 +29,6 @@
|
||||
const items: RailItem[] = $derived([
|
||||
{ href: '/admin', label: 'Overview', exact: true },
|
||||
{ href: '/admin/requests', label: 'Requests', attention: countOf(summary, 'requests') },
|
||||
{ href: '/admin/missing-files', label: 'Missing files', attention: countOf(summary, 'missing') },
|
||||
{ href: '/admin/library', label: 'Library' },
|
||||
{ href: '/admin/acquisition', label: 'Acquisition' },
|
||||
{ href: '/admin/metadata', label: 'Metadata sources' },
|
||||
|
||||
@@ -168,7 +168,6 @@ describe('admin layout', () => {
|
||||
expect(links).toEqual([
|
||||
['/admin', 'Overview'],
|
||||
['/admin/requests', 'Requests 2'],
|
||||
['/admin/missing-files', 'Missing files'],
|
||||
['/admin/library', 'Library'],
|
||||
['/admin/acquisition', 'Acquisition'],
|
||||
['/admin/metadata', 'Metadata sources'],
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { FolderX, Music2 } from 'lucide-svelte';
|
||||
import {
|
||||
createMissingFilesQuery,
|
||||
getReacquisitionSettings,
|
||||
type ReacquisitionSettings
|
||||
} from '#lib/api/admin.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import { coverUrl } from '#lib/media/covers.js';
|
||||
import { onMount } from 'svelte';
|
||||
import type { AdminMissingGroup } from '#lib/api/types.js';
|
||||
|
||||
// Files the scan looked for and could not find. Read-only on purpose:
|
||||
// a missing file keeps its track row, its play history and its likes,
|
||||
// because the file may come back — the scanner clears the mark by
|
||||
// itself, and adopts the row if it returns under a new name (#2528).
|
||||
// Nothing on this page deletes anything.
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
let offset = $state(0);
|
||||
|
||||
// $derived so paging re-creates the query with a new key — same shape the
|
||||
// playback-errors page uses for its tab state.
|
||||
const queryStore = $derived(createMissingFilesQuery(offset, PAGE_SIZE));
|
||||
const query = $derived($queryStore);
|
||||
const groups = $derived((query.data?.groups ?? []) as AdminMissingGroup[]);
|
||||
const total = $derived(query.data?.total ?? 0);
|
||||
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
|
||||
const hasMore = $derived(offset + shown < total);
|
||||
|
||||
function trackCountLabel(n: number): string {
|
||||
return n === 1 ? '1 track' : `${n} tracks`;
|
||||
}
|
||||
|
||||
function durationLabel(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// What happens to these is set under Acquisition (#5607); the line above
|
||||
// the list says what it is now, since this is where the question comes up.
|
||||
let policy = $state<ReacquisitionSettings | null>(null);
|
||||
onMount(async () => {
|
||||
try {
|
||||
policy = await getReacquisitionSettings();
|
||||
} catch {
|
||||
policy = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Missing files')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Missing files</h2>
|
||||
{#if total > 0}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
|
||||
data-testid="missing-count-pill"
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-text-secondary">
|
||||
Tracks whose audio file the last scan couldn't find. They keep their play
|
||||
history and likes, and stop being offered anywhere until the file returns.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p class="text-sm text-text-secondary" data-testid="reacquisition-policy">
|
||||
{#if policy}
|
||||
{policy.enabled
|
||||
? `Albums missing for ${policy.grace_hours} hours go back on the wanted list by themselves.`
|
||||
: 'Missing albums are not wanted back automatically.'}
|
||||
{/if}
|
||||
<a href="/admin/acquisition#reacquisition" class="text-accent-fg hover:underline">
|
||||
{policy ? 'Change' : 'Set what happens to missing files'}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{#if query.isPending}
|
||||
<p class="text-text-secondary">Checking what's missing…</p>
|
||||
{:else if query.isError}
|
||||
<p class="text-sm text-error-fg">
|
||||
Couldn't load the missing files.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
|
||||
</p>
|
||||
{:else if groups.length === 0}
|
||||
<!-- Empty state explains the feature, not just the emptiness: an operator
|
||||
who has never seen this page shouldn't have to guess what would put a
|
||||
row here or when it would appear. -->
|
||||
<div class="rounded-lg border border-border bg-surface p-6 text-center">
|
||||
<FolderX size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||||
<p class="mt-3 text-text-primary">Every track's file is where it should be.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
A track lands here when a library scan can't find its file — moved outside
|
||||
Minstrel, deleted, or on a drive that didn't mount. It leaves on its own
|
||||
when the file comes back.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-4">
|
||||
{#each groups as group (group.directory)}
|
||||
<li class="overflow-hidden rounded-lg border border-border bg-surface">
|
||||
<!-- The folder is the unit of decision. Three reorganised albums are
|
||||
three things to think about, not forty. -->
|
||||
<div class="flex items-baseline justify-between gap-4 border-b border-border px-4 py-3">
|
||||
<h3 class="truncate font-mono text-sm text-text-primary" title={group.directory}>
|
||||
{group.directory}
|
||||
</h3>
|
||||
<span class="shrink-0 text-xs text-text-secondary">
|
||||
{trackCountLabel(group.tracks.length)} · gone {relativeTime(group.missing_since)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Where this folder's album stands on the wanted list. Without it
|
||||
the only way to tell "not wanted yet" from "wanted, nothing has
|
||||
come" is to go and read the wanted list. -->
|
||||
{#if group.reacquisition}
|
||||
{@const r = group.reacquisition}
|
||||
<p
|
||||
class="border-b border-border px-4 py-2 text-xs text-text-secondary"
|
||||
data-testid="reacquisition-state"
|
||||
>
|
||||
{#if r.held_at}
|
||||
Held again {relativeTime(r.held_at)}. The rows here clear on the next scan.
|
||||
{:else if r.wanted_at}
|
||||
On the <a href="/collection/missing" class="text-accent-fg hover:underline"
|
||||
>wanted list</a
|
||||
> since {relativeTime(r.wanted_at)}.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<ul class="divide-y divide-border">
|
||||
{#each group.tracks as t (t.track_id)}
|
||||
<li class="flex items-center gap-3 px-4 py-2" data-testid="missing-track-row">
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-surface-hover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if t.album_id}
|
||||
<img
|
||||
src={coverUrl(t.album_id)}
|
||||
alt=""
|
||||
class="h-full w-full rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<Music2 size={18} strokeWidth={1} class="text-text-muted" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm text-text-primary">{t.title}</div>
|
||||
<div class="truncate text-xs text-text-secondary">
|
||||
{t.artist_name} · {t.album_title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last played is the deciding fact: a file gone six months
|
||||
that nobody ever played is not the same problem as one
|
||||
played two hundred times last week. -->
|
||||
<span class="shrink-0 text-xs text-text-muted">
|
||||
{#if t.last_played_at}
|
||||
last played {relativeTime(t.last_played_at)}
|
||||
{:else}
|
||||
never played
|
||||
{/if}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs text-text-muted">{durationLabel(t.duration_sec)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if hasMore || offset > 0}
|
||||
<nav class="flex items-center justify-between" aria-label="Missing files pages">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary
|
||||
hover:bg-surface-hover focus-visible:outline-solid focus-visible:outline-2
|
||||
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={offset === 0}
|
||||
onclick={() => (offset = Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="text-xs text-text-secondary">
|
||||
{offset + 1}–{offset + shown} of {total}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary
|
||||
hover:bg-surface-hover focus-visible:outline-solid focus-visible:outline-2
|
||||
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!hasMore}
|
||||
onclick={() => (offset += PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -51,7 +51,6 @@ describe('/collection layout', () => {
|
||||
expect(links).toEqual([
|
||||
['/collection', 'Catalogue 7'],
|
||||
['/collection/wanted', 'Wanted 5'],
|
||||
['/collection/missing', 'Missing files 1'],
|
||||
['/collection/upgrades', 'Upgrades 6'],
|
||||
['/collection/activity', 'Activity 3'],
|
||||
['/collection/following', 'Following 2'],
|
||||
@@ -59,7 +58,8 @@ describe('/collection layout', () => {
|
||||
['/collection/unmatched', 'Unmatched 3'],
|
||||
['/collection/reports', 'Reports'],
|
||||
['/collection/duplicates', 'Duplicates'],
|
||||
['/collection/suspect-sources', 'Suspect sources']
|
||||
['/collection/suspect-sources', 'Suspect sources'],
|
||||
['/collection/missing', 'Missing files']
|
||||
]);
|
||||
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
|
||||
expect(screen.getByRole('link', { name: /^catalogue/i })).not.toHaveAttribute('aria-current');
|
||||
@@ -90,7 +90,7 @@ describe('/collection layout', () => {
|
||||
|
||||
// The library's health queues moved here from Admin (M504): their counts
|
||||
// come from the admin summary, accented, and a line names the ones waiting.
|
||||
test('reports, duplicates and suspect sources carry what waits on you, and a line links to each', () => {
|
||||
test('the library health queues carry what waits on you, and a line links to each', () => {
|
||||
setup(counts, '/collection', {
|
||||
...clear,
|
||||
reports: { count: 2, since: null },
|
||||
@@ -102,11 +102,12 @@ describe('/collection layout', () => {
|
||||
expect(dup.textContent?.replace(/\s+/g, ' ').trim()).toBe('Duplicates 5');
|
||||
const health = screen.getByTestId('collection-health');
|
||||
expect(health.textContent?.replace(/\s+/g, ' ').trim()).toBe(
|
||||
'To look at: 2 broken tracks reported, 5 sets of duplicates to review.'
|
||||
'To look at: 2 broken tracks reported, 5 sets of duplicates to review, 9 missing files.'
|
||||
);
|
||||
expect(Array.from(health.querySelectorAll('a')).map((a) => a.getAttribute('href'))).toEqual([
|
||||
'/collection/reports',
|
||||
'/collection/duplicates'
|
||||
'/collection/duplicates',
|
||||
'/collection/missing'
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,308 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import WantList from '#lib/components/wanted/WantList.svelte';
|
||||
import { ChevronDown, FolderX, Music2, Search, X } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createMissingFilesQuery,
|
||||
getReacquisitionSettings,
|
||||
type ReacquisitionSettings
|
||||
} from '#lib/api/admin.js';
|
||||
import { removeWant, searchWant } from '#lib/api/wanted.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import { coverUrl } from '#lib/media/covers.js';
|
||||
import { needsYou, searched, wantStatus } from '#lib/wanted/wantStatus.js';
|
||||
import WantCandidates from '#lib/components/WantCandidates.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import type { AdminMissingGroup, Want } from '#lib/api/types.js';
|
||||
|
||||
// Files the scan looked for and could not find, by folder, with where each
|
||||
// folder's album stands on the wanted list (M504: this page and the
|
||||
// Collection's list of missing wants were one subject on two pages). A
|
||||
// missing file keeps its track row, its play history and its likes,
|
||||
// because the file may come back: the scanner clears the mark by itself,
|
||||
// and adopts the row if it returns under a new name (#2528). Nothing on
|
||||
// this page deletes a track; its actions are the wanted list's own.
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
let offset = $state(0);
|
||||
|
||||
// $derived so paging re-creates the query with a new key — same shape the
|
||||
// playback-errors page uses for its tab state.
|
||||
const queryStore = $derived(createMissingFilesQuery(offset, PAGE_SIZE));
|
||||
const query = $derived($queryStore);
|
||||
const groups = $derived((query.data?.groups ?? []) as AdminMissingGroup[]);
|
||||
const total = $derived(query.data?.total ?? 0);
|
||||
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
|
||||
const hasMore = $derived(offset + shown < total);
|
||||
|
||||
function trackCountLabel(n: number): string {
|
||||
return n === 1 ? '1 track' : `${n} tracks`;
|
||||
}
|
||||
|
||||
function durationLabel(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// What happens to these is set under Acquisition (#5607); the line above
|
||||
// the list says what it is now, since this is where the question comes up.
|
||||
let policy = $state<ReacquisitionSettings | null>(null);
|
||||
onMount(async () => {
|
||||
try {
|
||||
policy = await getReacquisitionSettings();
|
||||
} catch {
|
||||
policy = null;
|
||||
}
|
||||
});
|
||||
|
||||
// A folder's want, acted on here as on the wanted list.
|
||||
const client = useQueryClient();
|
||||
let busy = $state<string | null>(null);
|
||||
let searching = $state<string | null>(null);
|
||||
let open = $state<string | null>(null);
|
||||
|
||||
const refresh = () =>
|
||||
Promise.all([
|
||||
client.invalidateQueries({ queryKey: ['adminMissingFiles'] }),
|
||||
client.invalidateQueries({ queryKey: ['adminWants'] })
|
||||
]);
|
||||
|
||||
async function onSearch(w: Want) {
|
||||
searching = w.id;
|
||||
try {
|
||||
const r = await searchWant(w.id);
|
||||
pushToast(searched(w, r), r.outcome === 'error' ? 'error' : 'info');
|
||||
await Promise.all([refresh(), client.invalidateQueries({ queryKey: qk.wantCandidates(w.id) })]);
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
searching = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove(w: Want) {
|
||||
busy = w.id;
|
||||
try {
|
||||
await removeWant(w.id);
|
||||
pushToast(`${w.title} is off the wanted list, and won't be fetched back.`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Collection · Missing files')}</title></svelte:head>
|
||||
|
||||
<WantList list="missing" />
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Missing files</h2>
|
||||
{#if total > 0}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
|
||||
data-testid="missing-count-pill"
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-text-secondary">
|
||||
Tracks whose audio file the last scan couldn't find. They keep their play
|
||||
history and likes, and stop being offered anywhere until the file returns.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p class="text-sm text-text-secondary" data-testid="reacquisition-policy">
|
||||
{#if policy}
|
||||
{policy.enabled
|
||||
? `Albums missing for ${policy.grace_hours} hours go back on the wanted list by themselves.`
|
||||
: 'Missing albums are not wanted back automatically.'}
|
||||
{/if}
|
||||
<a href="/admin/acquisition#reacquisition" class="text-accent-fg hover:underline">
|
||||
{policy ? 'Change' : 'Set what happens to missing files'}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{#if query.isPending}
|
||||
<p class="text-text-secondary">Checking what's missing…</p>
|
||||
{:else if query.isError}
|
||||
<p class="text-sm text-error-fg">
|
||||
Couldn't load the missing files.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
|
||||
</p>
|
||||
{:else if groups.length === 0}
|
||||
<!-- Empty state explains the feature, not just the emptiness: an operator
|
||||
who has never seen this page shouldn't have to guess what would put a
|
||||
row here or when it would appear. -->
|
||||
<div class="rounded-lg border border-border bg-surface p-6 text-center">
|
||||
<FolderX size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||||
<p class="mt-3 text-text-primary">Every track's file is where it should be.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
A track lands here when a library scan can't find its file — moved outside
|
||||
Minstrel, deleted, or on a drive that didn't mount. It leaves on its own
|
||||
when the file comes back.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-4">
|
||||
{#each groups as group (group.directory)}
|
||||
<li class="overflow-hidden rounded-lg border border-border bg-surface">
|
||||
<!-- The folder is the unit of decision. Three reorganised albums are
|
||||
three things to think about, not forty. -->
|
||||
<div class="flex items-baseline justify-between gap-4 border-b border-border px-4 py-3">
|
||||
<h3 class="truncate font-mono text-sm text-text-primary" title={group.directory}>
|
||||
{group.directory}
|
||||
</h3>
|
||||
<span class="shrink-0 text-xs text-text-secondary">
|
||||
{trackCountLabel(group.tracks.length)} · gone {relativeTime(group.missing_since)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Where this folder's album stands on the wanted list. Without it
|
||||
the only way to tell "not wanted yet" from "wanted, nothing has
|
||||
come" is to go and read the wanted list. -->
|
||||
{#if group.reacquisition}
|
||||
{@const r = group.reacquisition}
|
||||
{@const w = r.want}
|
||||
<div class="space-y-1.5 border-b border-border px-4 py-2" data-testid="reacquisition-state">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="min-w-0 space-y-0.5 text-xs">
|
||||
<p class="text-text-secondary">
|
||||
{#if r.held_at}
|
||||
Held again {relativeTime(r.held_at)}. The rows here clear on the next scan.
|
||||
{:else if r.wanted_at}
|
||||
Wanted back since {relativeTime(r.wanted_at)}.
|
||||
{/if}
|
||||
</p>
|
||||
{#if w && !r.held_at}
|
||||
<p
|
||||
class={needsYou(w) ? 'text-text-primary' : 'text-text-secondary'}
|
||||
data-testid="missing-want-status"
|
||||
>
|
||||
{wantStatus(w)}
|
||||
{#if needsYou(w)}
|
||||
<a href="/collection/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if w && !r.held_at}
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onSearch(w)}
|
||||
disabled={searching !== null || !!w.grab?.live}
|
||||
aria-label="Search for {w.title} now"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<Search size={12} strokeWidth={2} /> {searching === w.id ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (open = open === w.id ? null : w.id)}
|
||||
aria-expanded={open === w.id}
|
||||
aria-label="Releases found for {w.title}"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
||||
>
|
||||
Releases <ChevronDown size={12} strokeWidth={2} class={open === w.id ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onRemove(w)}
|
||||
disabled={busy === w.id}
|
||||
aria-label="Stop wanting {w.title}"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<X size={12} strokeWidth={2} /> Remove
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if w && open === w.id}
|
||||
<WantCandidates wantId={w.id} title={w.title} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="divide-y divide-border">
|
||||
{#each group.tracks as t (t.track_id)}
|
||||
<li class="flex items-center gap-3 px-4 py-2" data-testid="missing-track-row">
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-surface-hover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if t.album_id}
|
||||
<img
|
||||
src={coverUrl(t.album_id)}
|
||||
alt=""
|
||||
class="h-full w-full rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<Music2 size={18} strokeWidth={1} class="text-text-muted" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm text-text-primary">{t.title}</div>
|
||||
<div class="truncate text-xs text-text-secondary">
|
||||
{t.artist_name} · {t.album_title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last played is the deciding fact: a file gone six months
|
||||
that nobody ever played is not the same problem as one
|
||||
played two hundred times last week. -->
|
||||
<span class="shrink-0 text-xs text-text-muted">
|
||||
{#if t.last_played_at}
|
||||
last played {relativeTime(t.last_played_at)}
|
||||
{:else}
|
||||
never played
|
||||
{/if}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs text-text-muted">{durationLabel(t.duration_sec)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if hasMore || offset > 0}
|
||||
<nav class="flex items-center justify-between" aria-label="Missing files pages">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary
|
||||
hover:bg-surface-hover focus-visible:outline-solid focus-visible:outline-2
|
||||
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={offset === 0}
|
||||
onclick={() => (offset = Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="text-xs text-text-secondary">
|
||||
{offset + 1}–{offset + shown} of {total}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary
|
||||
hover:bg-surface-hover focus-visible:outline-solid focus-visible:outline-2
|
||||
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!hasMore}
|
||||
onclick={() => (offset += PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+89
-19
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/svelte';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { AdminMissingResponse } from '#lib/api/types.js';
|
||||
import type { AdminMissingResponse, Want } from '#lib/api/types.js';
|
||||
|
||||
// The page reads the re-acquisition policy to say what happens to these;
|
||||
// the settings themselves are on Admin › Acquisition.
|
||||
@@ -14,9 +14,23 @@ vi.mock('#lib/api/admin.js', () => ({
|
||||
unnameable_albums: 0
|
||||
})
|
||||
}));
|
||||
// A folder's want is acted on here as on the wanted list (M504).
|
||||
vi.mock('#lib/api/wanted.js', () => ({
|
||||
removeWant: vi.fn().mockResolvedValue(undefined),
|
||||
searchWant: vi.fn().mockResolvedValue({
|
||||
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
|
||||
}),
|
||||
createCandidatesQuery: vi.fn(),
|
||||
grabCandidate: vi.fn(),
|
||||
blockCandidate: vi.fn(),
|
||||
unblockRelease: vi.fn()
|
||||
}));
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import AdminMissingFilesPage from './+page.svelte';
|
||||
import MissingPage from './+page.svelte';
|
||||
import { createMissingFilesQuery } from '#lib/api/admin.js';
|
||||
import { removeWant, searchWant } from '#lib/api/wanted.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
|
||||
const DAY = 24 * 3_600_000;
|
||||
|
||||
@@ -71,10 +85,42 @@ function renderWith(data: AdminMissingResponse | undefined, extra = {}) {
|
||||
vi.mocked(createMissingFilesQuery).mockReturnValue(
|
||||
mockQuery({ data, ...extra }) as ReturnType<typeof createMissingFilesQuery>
|
||||
);
|
||||
return render(AdminMissingFilesPage);
|
||||
return render(MissingPage);
|
||||
}
|
||||
|
||||
describe('admin missing files', () => {
|
||||
function want(extra: Partial<Want> = {}): Want {
|
||||
return {
|
||||
id: 'w-1',
|
||||
release_group_mbid: 'rg-1',
|
||||
title: 'Minutes to Midnight',
|
||||
artist_mbid: 'lp',
|
||||
artist_name: 'Linkin Park',
|
||||
primary_type: 'Album',
|
||||
secondary_types: [],
|
||||
year: '2007',
|
||||
reason: 'missing',
|
||||
wanted_at: new Date(Date.now() - 2 * DAY).toISOString(),
|
||||
held_at: null,
|
||||
image_url: '',
|
||||
search: {
|
||||
searched_at: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
outcome: 'nothing',
|
||||
considered: 0,
|
||||
best_release: '',
|
||||
best_verdict: '',
|
||||
error: ''
|
||||
},
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function wantedBack(w: Want | null = want()): AdminMissingResponse {
|
||||
const withState = structuredClone(response);
|
||||
withState.groups[0].reacquisition = { wanted_at: new Date(Date.now() - 2 * DAY).toISOString(), held_at: null, want: w };
|
||||
return withState;
|
||||
}
|
||||
|
||||
describe('/collection/missing', () => {
|
||||
test('says what happens to missing files, and links to where it is set', async () => {
|
||||
renderWith(response);
|
||||
const line = screen.getByTestId('reacquisition-policy');
|
||||
@@ -133,30 +179,54 @@ describe('admin missing files', () => {
|
||||
expect(screen.queryByTestId('reacquisition-state')).toBeNull();
|
||||
});
|
||||
|
||||
test('a wanted album says since when, and links the wanted list', () => {
|
||||
const withState = structuredClone(response);
|
||||
withState.groups[0].reacquisition = {
|
||||
wanted_at: new Date(Date.now() - 2 * DAY).toISOString(),
|
||||
held_at: null
|
||||
};
|
||||
renderWith(withState);
|
||||
const line = text('reacquisition-state');
|
||||
expect(line).toMatch(/on the wanted list since 2d ago/i);
|
||||
expect(
|
||||
within(screen.getByTestId('reacquisition-state')).getByRole('link', { name: /wanted list/i }).getAttribute('href')
|
||||
).toBe(
|
||||
'/collection/missing'
|
||||
test('an album wanted back says since when, and what its last search found', () => {
|
||||
renderWith(wantedBack());
|
||||
expect(text('reacquisition-state')).toMatch(/wanted back since 2d ago/i);
|
||||
expect(text('missing-want-status')).toBe('Searched 1h ago: nothing found');
|
||||
});
|
||||
|
||||
test('its download, when one is running, is the line', () => {
|
||||
renderWith(
|
||||
wantedBack(
|
||||
want({
|
||||
grab: {
|
||||
id: 'g', name: 'LP - MtM [FLAC]', grabbed_at: new Date().toISOString(), origin: 'schedule', quality: 'FLAC',
|
||||
live: true, end_reason: '', progress: 0.42, import_state: 'downloading', import_reason: '', end_detail: '',
|
||||
imported_at: null, imported_files: 0
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(text('missing-want-status')).toBe('Downloading 42% · LP - MtM [FLAC]');
|
||||
expect(screen.getByRole('button', { name: /search for minutes to midnight now/i })).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('Search runs one now and says what it found', async () => {
|
||||
renderWith(wantedBack());
|
||||
await fireEvent.click(screen.getByRole('button', { name: /search for minutes to midnight now/i }));
|
||||
await waitFor(() => expect(searchWant).toHaveBeenCalledWith('w-1'));
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing found for Minutes to Midnight.', 'info'));
|
||||
});
|
||||
|
||||
test('Remove takes it off the wanted list', async () => {
|
||||
renderWith(wantedBack());
|
||||
await fireEvent.click(screen.getByRole('button', { name: /stop wanting minutes to midnight/i }));
|
||||
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w-1'));
|
||||
expect(pushToast).toHaveBeenCalledWith("Minutes to Midnight is off the wanted list, and won't be fetched back.");
|
||||
});
|
||||
|
||||
test('an album held again says so', () => {
|
||||
const withState = structuredClone(response);
|
||||
withState.groups[0].reacquisition = {
|
||||
wanted_at: new Date(Date.now() - 5 * DAY).toISOString(),
|
||||
held_at: new Date(Date.now() - DAY).toISOString()
|
||||
held_at: new Date(Date.now() - DAY).toISOString(),
|
||||
want: want({ held_at: new Date(Date.now() - DAY).toISOString() })
|
||||
};
|
||||
renderWith(withState);
|
||||
expect(text('reacquisition-state')).toMatch(/held again 1d ago/i);
|
||||
// Nothing left to search for.
|
||||
expect(screen.queryByTestId('missing-want-status')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /search for/i })).toBeNull();
|
||||
});
|
||||
|
||||
// Paging only appears when it can do something: a single page of results
|
||||
Reference in New Issue
Block a user