feat: the library's albums go on the wanted list as held, once; after that the list is the truth (M502 #5567)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m31s
release / Build signed APK (releases and dev) (push) Successful in 5m35s
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

A one-time job in the wanted worker lists every album in the library as a
held want, one per release group, with a record that it happened
(acquisition_settings.library_listed_at). Albums whose tags name only a
release are named on MusicBrainz first, 120 a pass; what nothing names is
counted and shown on the Held tab.

Re-acquisition now wants back only albums on the list. Taking a held album
off the list leaves its files, and it is never fetched back; albums put in
the library folder by hand afterwards are not added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 16:13:46 -04:00
co-authored by Claude Opus 5.5
parent bc198a9389
commit 2c677b3184
19 changed files with 965 additions and 172 deletions
+21 -1
View File
@@ -86,6 +86,16 @@ type wantsPage struct {
Limit int `json:"limit"` Limit int `json:"limit"`
Offset int `json:"offset"` Offset int `json:"offset"`
Counts wantCounts `json:"counts"` Counts wantCounts `json:"counts"`
// Library is the one-time listing of the library's albums (#5567).
Library libraryListingView `json:"library"`
}
// libraryListingView: whether the library's albums are all on the list
// yet, and how many could not be listed (nothing names them to
// MusicBrainz).
type libraryListingView struct {
ListedAt *string `json:"listed_at"`
Unlisted int32 `json:"unlisted"`
} }
const wantsPageSize = 50 const wantsPageSize = 50
@@ -116,11 +126,21 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
writeErrWithLog(w, h.logger, "admin wanted: count", apierror.Internal(err)) writeErrWithLog(w, h.logger, "admin wanted: count", apierror.Internal(err))
return return
} }
listing, err := q.GetLibraryListing(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: library listing", apierror.Internal(err))
return
}
out := wantsPage{ out := wantsPage{
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset, Library: libraryListingView{Unlisted: listing.LibraryUnlisted},
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset,
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following, Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following,
Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked}, Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked},
} }
if listing.LibraryListedAt.Valid {
at := formatTimestamp(listing.LibraryListedAt)
out.Library.ListedAt = &at
}
switch state { switch state {
case "missing": case "missing":
out.Total = counts.Missing out.Total = counts.Missing
+14 -11
View File
@@ -218,17 +218,20 @@ type AcquisitionService struct {
} }
type AcquisitionSetting struct { type AcquisitionSetting struct {
ID bool ID bool
AutoGrab bool AutoGrab bool
SearchIntervalHours int32 SearchIntervalHours int32
AllowedQualities []string AllowedQualities []string
LastPassAt pgtype.Timestamptz LastPassAt pgtype.Timestamptz
LastPass []byte LastPass []byte
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
ImportRoot string ImportRoot string
SeedRatio float32 SeedRatio float32
SeedDays int32 SeedDays int32
StallHours int32 StallHours int32
LibraryListingStartedAt pgtype.Timestamptz
LibraryListedAt pgtype.Timestamptz
LibraryUnlisted int32
} }
type Album struct { type Album struct {
+6 -1
View File
@@ -109,7 +109,10 @@ SELECT albums.id AS album_id,
AND artists.mbid IS NOT NULL AND artists.mbid IS NOT NULL
GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid, GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid,
artists.name, artists.mbid artists.name, artists.mbid
HAVING NOT EXISTS ( HAVING EXISTS (
SELECT 1 FROM wants w
WHERE w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
AND NOT EXISTS (
SELECT 1 FROM wants w SELECT 1 FROM wants w
WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid) WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since))) AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since)))
@@ -140,6 +143,8 @@ type ListAlbumsDueReacquisitionRow struct {
// re-stamps (#2523), so it is a genuine "gone since" clock; // re-stamps (#2523), so it is a genuine "gone since" clock;
// - MusicBrainz can name it: the album and its artist have MBIDs. Albums // - MusicBrainz can name it: the album and its artist have MBIDs. Albums
// failing this are counted separately (CountAlbumsMissingWithoutMbid); // failing this are counted separately (CountAlbumsMissingWithoutMbid);
// - it is on the wanted list (#5567): an album taken off the list, or
// dropped into the library by hand, is not fetched back;
// - no want already covers it: none open, and none held since its files // - no want already covers it: none open, and none held since its files
// went missing. A want held after the loss means the library has a full // went missing. A want held after the loss means the library has a full
// copy despite the missing rows (#5245), so asking again would loop. // copy despite the missing rows (#5245), so asking again would loop.
+8 -2
View File
@@ -96,7 +96,7 @@ func (q *Queries) EndGrab(ctx context.Context, arg EndGrabParams) error {
const getAcquisitionSettings = `-- name: GetAcquisitionSettings :one const getAcquisitionSettings = `-- name: GetAcquisitionSettings :one
SELECT id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root, seed_ratio, seed_days, stall_hours FROM acquisition_settings WHERE id = true SELECT id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root, seed_ratio, seed_days, stall_hours, library_listing_started_at, library_listed_at, library_unlisted FROM acquisition_settings WHERE id = true
` `
// Searching for wants and grabbing releases (M502 #5521). // Searching for wants and grabbing releases (M502 #5521).
@@ -115,6 +115,9 @@ func (q *Queries) GetAcquisitionSettings(ctx context.Context) (AcquisitionSettin
&i.SeedRatio, &i.SeedRatio,
&i.SeedDays, &i.SeedDays,
&i.StallHours, &i.StallHours,
&i.LibraryListingStartedAt,
&i.LibraryListedAt,
&i.LibraryUnlisted,
) )
return i, err return i, err
} }
@@ -608,7 +611,7 @@ UPDATE acquisition_settings
stall_hours = $7, stall_hours = $7,
updated_at = now() updated_at = now()
WHERE id = true WHERE id = true
RETURNING id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root, seed_ratio, seed_days, stall_hours RETURNING id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root, seed_ratio, seed_days, stall_hours, library_listing_started_at, library_listed_at, library_unlisted
` `
type UpdateAcquisitionSettingsParams struct { type UpdateAcquisitionSettingsParams struct {
@@ -644,6 +647,9 @@ func (q *Queries) UpdateAcquisitionSettings(ctx context.Context, arg UpdateAcqui
&i.SeedRatio, &i.SeedRatio,
&i.SeedDays, &i.SeedDays,
&i.StallHours, &i.StallHours,
&i.LibraryListingStartedAt,
&i.LibraryListedAt,
&i.LibraryUnlisted,
) )
return i, err return i, err
} }
+155
View File
@@ -124,6 +124,33 @@ func (q *Queries) DeleteWant(ctx context.Context, id pgtype.UUID) (int64, error)
return result.RowsAffected(), nil return result.RowsAffected(), nil
} }
const finishLibraryListing = `-- name: FinishLibraryListing :one
UPDATE acquisition_settings s
SET library_listed_at = now(),
library_unlisted = (
SELECT COUNT(*) FROM albums a
WHERE a.created_at <= s.library_listing_started_at
AND NOT EXISTS (SELECT 1 FROM wants w
WHERE w.album_id = a.id
OR (a.release_group_mbid <> '' AND w.release_group_mbid = a.release_group_mbid)))
WHERE s.id = true AND s.library_listed_at IS NULL
RETURNING library_listed_at, library_unlisted
`
type FinishLibraryListingRow struct {
LibraryListedAt pgtype.Timestamptz
LibraryUnlisted int32
}
// The listing is done: count the albums from before the cutoff it could not
// list, because nothing names them to MusicBrainz.
func (q *Queries) FinishLibraryListing(ctx context.Context) (FinishLibraryListingRow, error) {
row := q.db.QueryRow(ctx, finishLibraryListing)
var i FinishLibraryListingRow
err := row.Scan(&i.LibraryListedAt, &i.LibraryUnlisted)
return i, err
}
const fulfillWant = `-- name: FulfillWant :exec const fulfillWant = `-- name: FulfillWant :exec
UPDATE wants UPDATE wants
SET fulfilled_at = now(), SET fulfilled_at = now(),
@@ -199,6 +226,26 @@ func (q *Queries) GetFollow(ctx context.Context, artistMbid string) (GetFollowRo
return i, err return i, err
} }
const getLibraryListing = `-- name: GetLibraryListing :one
SELECT library_listing_started_at, library_listed_at, library_unlisted
FROM acquisition_settings WHERE id = true
`
type GetLibraryListingRow struct {
LibraryListingStartedAt pgtype.Timestamptz
LibraryListedAt pgtype.Timestamptz
LibraryUnlisted int32
}
// The one-time listing of the library (#5567). ---------------------------
func (q *Queries) GetLibraryListing(ctx context.Context) (GetLibraryListingRow, error) {
row := q.db.QueryRow(ctx, getLibraryListing)
var i GetLibraryListingRow
err := row.Scan(&i.LibraryListingStartedAt, &i.LibraryListedAt, &i.LibraryUnlisted)
return i, err
}
const getReleaseTypeProfile = `-- name: GetReleaseTypeProfile :one const getReleaseTypeProfile = `-- name: GetReleaseTypeProfile :one
SELECT id, name, primary_types, secondary_types, is_default, created_at, updated_at FROM release_type_profiles WHERE id = $1 SELECT id, name, primary_types, secondary_types, is_default, created_at, updated_at FROM release_type_profiles WHERE id = $1
` `
@@ -424,6 +471,83 @@ func (q *Queries) ListFollowsDue(ctx context.Context, arg ListFollowsDueParams)
return items, nil return items, nil
} }
const listLibraryAlbums = `-- name: ListLibraryAlbums :execrows
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, wanted_at, fulfilled_at)
SELECT DISTINCT ON (a.release_group_mbid)
a.release_group_mbid, ar.mbid, ar.name, a.title, 'library', a.id, now(),
COALESCE((SELECT MIN(t.missing_since) FROM tracks t WHERE t.album_id = a.id) - interval '1 second', now())
FROM albums a
JOIN artists ar ON ar.id = a.artist_id
WHERE a.release_group_mbid IS NOT NULL AND a.release_group_mbid <> ''
AND ar.mbid IS NOT NULL AND ar.mbid <> ''
AND a.created_at <= $1::timestamptz
AND ($2::uuid IS NULL OR a.id = $2::uuid)
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
ORDER BY a.release_group_mbid,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL) DESC, a.id
ON CONFLICT (release_group_mbid) DO NOTHING
`
type ListLibraryAlbumsParams struct {
Cutoff pgtype.Timestamptz
AlbumID pgtype.UUID
}
// Puts library albums on the list as held: each release group once, by the
// album holding the most tracks. Only albums the library had at the cutoff,
// and with album_id given only that album. A group already on the list is
// left as it is. An album with tracks already missing is held from just
// before the first loss, so re-acquisition still fetches it back.
func (q *Queries) ListLibraryAlbums(ctx context.Context, arg ListLibraryAlbumsParams) (int64, error) {
result, err := q.db.Exec(ctx, listLibraryAlbums, arg.Cutoff, arg.AlbumID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const listLibraryAlbumsToName = `-- name: ListLibraryAlbumsToName :many
SELECT a.id, a.title, a.mbid FROM albums a
WHERE a.release_group_mbid IS NULL AND a.mbid IS NOT NULL AND a.mbid <> ''
AND a.created_at <= $1::timestamptz
ORDER BY a.created_at, a.id
LIMIT $2
`
type ListLibraryAlbumsToNameParams struct {
Cutoff pgtype.Timestamptz
MaxRows int32
}
type ListLibraryAlbumsToNameRow struct {
ID pgtype.UUID
Title string
Mbid *string
}
// Library albums from before the cutoff that carry a release id but no
// release group yet: the listing asks MusicBrainz for each. ” marks one
// MusicBrainz could not name, so it is asked once.
func (q *Queries) ListLibraryAlbumsToName(ctx context.Context, arg ListLibraryAlbumsToNameParams) ([]ListLibraryAlbumsToNameRow, error) {
rows, err := q.db.Query(ctx, listLibraryAlbumsToName, arg.Cutoff, arg.MaxRows)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLibraryAlbumsToNameRow
for rows.Next() {
var i ListLibraryAlbumsToNameRow
if err := rows.Scan(&i.ID, &i.Title, &i.Mbid); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOpenWants = `-- name: ListOpenWants :many const listOpenWants = `-- name: ListOpenWants :many
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error FROM wants SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error FROM wants
WHERE fulfilled_at IS NULL WHERE fulfilled_at IS NULL
@@ -697,6 +821,21 @@ func (q *Queries) RepointWant(ctx context.Context, arg RepointWantParams) error
return err return err
} }
const setAlbumReleaseGroup = `-- name: SetAlbumReleaseGroup :exec
UPDATE albums SET release_group_mbid = $1
WHERE id = $2 AND release_group_mbid IS NULL
`
type SetAlbumReleaseGroupParams struct {
ReleaseGroupMbid *string
ID pgtype.UUID
}
func (q *Queries) SetAlbumReleaseGroup(ctx context.Context, arg SetAlbumReleaseGroupParams) error {
_, err := q.db.Exec(ctx, setAlbumReleaseGroup, arg.ReleaseGroupMbid, arg.ID)
return err
}
const setDefaultReleaseTypeProfile = `-- name: SetDefaultReleaseTypeProfile :execrows const setDefaultReleaseTypeProfile = `-- name: SetDefaultReleaseTypeProfile :execrows
UPDATE release_type_profiles SET is_default = true, updated_at = now() WHERE id = $1 UPDATE release_type_profiles SET is_default = true, updated_at = now() WHERE id = $1
` `
@@ -709,6 +848,22 @@ func (q *Queries) SetDefaultReleaseTypeProfile(ctx context.Context, id pgtype.UU
return result.RowsAffected(), nil return result.RowsAffected(), nil
} }
const startLibraryListing = `-- name: StartLibraryListing :one
UPDATE acquisition_settings
SET library_listing_started_at = COALESCE(library_listing_started_at, now())
WHERE id = true
RETURNING library_listing_started_at
`
// Marks the listing begun, once; its time is the cutoff for which albums
// count as the library's.
func (q *Queries) StartLibraryListing(ctx context.Context) (pgtype.Timestamptz, error) {
row := q.db.QueryRow(ctx, startLibraryListing)
var library_listing_started_at pgtype.Timestamptz
err := row.Scan(&library_listing_started_at)
return library_listing_started_at, err
}
const updateReleaseTypeProfile = `-- name: UpdateReleaseTypeProfile :one const updateReleaseTypeProfile = `-- name: UpdateReleaseTypeProfile :one
UPDATE release_type_profiles UPDATE release_type_profiles
SET name = $2, SET name = $2,
@@ -0,0 +1,8 @@
ALTER TABLE acquisition_settings
DROP COLUMN library_listing_started_at,
DROP COLUMN library_listed_at,
DROP COLUMN library_unlisted;
DELETE FROM wants WHERE reason = 'library';
ALTER TABLE wants DROP CONSTRAINT wants_reason_check;
ALTER TABLE wants ADD CONSTRAINT wants_reason_check CHECK (reason IN ('request', 'follow', 'missing'));
@@ -0,0 +1,23 @@
-- The library's albums go on the wanted list as held, once (M502 #5567,
-- operator 2026-10-10). After that the list is the truth: an album taken
-- off it stays off, and one dropped into the library folder by hand is not
-- added. Re-acquisition fetches back only albums on the list.
--
-- wants.reason library: on the list because the library
-- held it when the list began.
-- acquisition_settings when the one-time listing began (its cutoff:
-- albums added later are not listed), when it
-- finished, and how many albums it could not
-- list because nothing names them to
-- MusicBrainz.
--
-- The listing itself runs in the wanted worker after boot, not here: it may
-- have to ask MusicBrainz for each album's release group.
ALTER TABLE wants DROP CONSTRAINT wants_reason_check;
ALTER TABLE wants ADD CONSTRAINT wants_reason_check
CHECK (reason IN ('request', 'follow', 'missing', 'library'));
ALTER TABLE acquisition_settings
ADD COLUMN library_listing_started_at timestamptz,
ADD COLUMN library_listed_at timestamptz,
ADD COLUMN library_unlisted integer NOT NULL DEFAULT 0;
+6 -1
View File
@@ -23,6 +23,8 @@ RETURNING *;
-- re-stamps (#2523), so it is a genuine "gone since" clock; -- re-stamps (#2523), so it is a genuine "gone since" clock;
-- * MusicBrainz can name it: the album and its artist have MBIDs. Albums -- * MusicBrainz can name it: the album and its artist have MBIDs. Albums
-- failing this are counted separately (CountAlbumsMissingWithoutMbid); -- failing this are counted separately (CountAlbumsMissingWithoutMbid);
-- * it is on the wanted list (#5567): an album taken off the list, or
-- dropped into the library by hand, is not fetched back;
-- * no want already covers it: none open, and none held since its files -- * no want already covers it: none open, and none held since its files
-- went missing. A want held after the loss means the library has a full -- went missing. A want held after the loss means the library has a full
-- copy despite the missing rows (#5245), so asking again would loop. -- copy despite the missing rows (#5245), so asking again would loop.
@@ -43,7 +45,10 @@ SELECT albums.id AS album_id,
AND artists.mbid IS NOT NULL AND artists.mbid IS NOT NULL
GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid, GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid,
artists.name, artists.mbid artists.name, artists.mbid
HAVING NOT EXISTS ( HAVING EXISTS (
SELECT 1 FROM wants w
WHERE w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
AND NOT EXISTS (
SELECT 1 FROM wants w SELECT 1 FROM wants w
WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid) WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since))) AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since)))
+63
View File
@@ -222,3 +222,66 @@ SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::big
(SELECT COUNT(*) FROM release_blocks b (SELECT COUNT(*) FROM release_blocks b
WHERE b.expires_at IS NULL OR b.expires_at > now())::bigint AS blocked WHERE b.expires_at IS NULL OR b.expires_at > now())::bigint AS blocked
FROM wants; FROM wants;
-- The one-time listing of the library (#5567). ---------------------------
-- name: GetLibraryListing :one
SELECT library_listing_started_at, library_listed_at, library_unlisted
FROM acquisition_settings WHERE id = true;
-- name: StartLibraryListing :one
-- Marks the listing begun, once; its time is the cutoff for which albums
-- count as the library's.
UPDATE acquisition_settings
SET library_listing_started_at = COALESCE(library_listing_started_at, now())
WHERE id = true
RETURNING library_listing_started_at;
-- name: ListLibraryAlbums :execrows
-- Puts library albums on the list as held: each release group once, by the
-- album holding the most tracks. Only albums the library had at the cutoff,
-- and with album_id given only that album. A group already on the list is
-- left as it is. An album with tracks already missing is held from just
-- before the first loss, so re-acquisition still fetches it back.
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, wanted_at, fulfilled_at)
SELECT DISTINCT ON (a.release_group_mbid)
a.release_group_mbid, ar.mbid, ar.name, a.title, 'library', a.id, now(),
COALESCE((SELECT MIN(t.missing_since) FROM tracks t WHERE t.album_id = a.id) - interval '1 second', now())
FROM albums a
JOIN artists ar ON ar.id = a.artist_id
WHERE a.release_group_mbid IS NOT NULL AND a.release_group_mbid <> ''
AND ar.mbid IS NOT NULL AND ar.mbid <> ''
AND a.created_at <= sqlc.arg(cutoff)::timestamptz
AND (sqlc.narg(album_id)::uuid IS NULL OR a.id = sqlc.narg(album_id)::uuid)
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
ORDER BY a.release_group_mbid,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL) DESC, a.id
ON CONFLICT (release_group_mbid) DO NOTHING;
-- name: ListLibraryAlbumsToName :many
-- Library albums from before the cutoff that carry a release id but no
-- release group yet: the listing asks MusicBrainz for each. '' marks one
-- MusicBrainz could not name, so it is asked once.
SELECT a.id, a.title, a.mbid FROM albums a
WHERE a.release_group_mbid IS NULL AND a.mbid IS NOT NULL AND a.mbid <> ''
AND a.created_at <= sqlc.arg(cutoff)::timestamptz
ORDER BY a.created_at, a.id
LIMIT sqlc.arg(max_rows);
-- name: SetAlbumReleaseGroup :exec
UPDATE albums SET release_group_mbid = sqlc.arg(release_group_mbid)
WHERE id = sqlc.arg(id) AND release_group_mbid IS NULL;
-- name: FinishLibraryListing :one
-- The listing is done: count the albums from before the cutoff it could not
-- list, because nothing names them to MusicBrainz.
UPDATE acquisition_settings s
SET library_listed_at = now(),
library_unlisted = (
SELECT COUNT(*) FROM albums a
WHERE a.created_at <= s.library_listing_started_at
AND NOT EXISTS (SELECT 1 FROM wants w
WHERE w.album_id = a.id
OR (a.release_group_mbid <> '' AND w.release_group_mbid = a.release_group_mbid)))
WHERE s.id = true AND s.library_listed_at IS NULL
RETURNING library_listed_at, library_unlisted;
+3
View File
@@ -185,10 +185,13 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
t.Fatalf("dbtest.ResetDB reset release-type profiles: %v", err) t.Fatalf("dbtest.ResetDB reset release-type profiles: %v", err)
} }
// Search and grab settings (M502 #5521), a singleton reset the same way. // Search and grab settings (M502 #5521), a singleton reset the same way.
// The library listing (#5567) reads as done, so a test's albums are not
// put on the wanted list behind its back; its own tests clear it.
if _, err := pool.Exec(ctx, ` if _, err := pool.Exec(ctx, `
UPDATE acquisition_settings UPDATE acquisition_settings
SET auto_grab = DEFAULT, search_interval_hours = DEFAULT, allowed_qualities = DEFAULT, import_root = DEFAULT, SET auto_grab = DEFAULT, search_interval_hours = DEFAULT, allowed_qualities = DEFAULT, import_root = DEFAULT,
seed_ratio = DEFAULT, seed_days = DEFAULT, stall_hours = DEFAULT, seed_ratio = DEFAULT, seed_days = DEFAULT, stall_hours = DEFAULT,
library_listing_started_at = now(), library_listed_at = now(), library_unlisted = 0,
last_pass_at = NULL, last_pass = DEFAULT, updated_at = DEFAULT`, last_pass_at = NULL, last_pass = DEFAULT, updated_at = DEFAULT`,
); err != nil { ); err != nil {
t.Fatalf("dbtest.ResetDB reset acquisition settings: %v", err) t.Fatalf("dbtest.ResetDB reset acquisition settings: %v", err)
+30 -15
View File
@@ -69,11 +69,23 @@ func lostAlbum(t *testing.T, pool *pgxpool.Pool, title, release, group string) p
return al.ID return al.ID
} }
// #5241: an album is wanted by its release group, never its release id. A // heldWant puts an album on the wanted list, held since ago.
// tag-supplied group is used as is; otherwise MusicBrainz names it and the func heldWant(t *testing.T, pool *pgxpool.Pool, group string, album pgtype.UUID, ago string) {
// answer is cached onto the album; an album MusicBrainz cannot name is t.Helper()
// skipped and tried again next pass. if _, err := pool.Exec(context.Background(), `
func TestSweep_WantsAlbumsByReleaseGroup_Integration(t *testing.T) { INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, fulfilled_at)
VALUES ($1, 'a', 'A', 'T', 'library', $2, now() - $3::interval)`, group, album, ago); err != nil {
t.Fatal(err)
}
}
// Only an album on the wanted list is fetched back (#5567): one taken off
// it, or dropped into the library by hand, is not. An album on the list is
// wanted by its release group, never its release id (#5241): a tag-supplied
// group is used as is; otherwise MusicBrainz names it and the answer is
// cached onto the album; an album MusicBrainz cannot name is skipped and
// tried again next pass.
func TestSweep_WantsBackOnlyAlbumsOnTheList_Integration(t *testing.T) {
pool := newSweepPool(t) pool := newSweepPool(t)
ctx := context.Background() ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -85,7 +97,11 @@ func TestSweep_WantsAlbumsByReleaseGroup_Integration(t *testing.T) {
} }
tagged := lostAlbum(t, pool, "Tagged", "release-tagged", "group-tagged") tagged := lostAlbum(t, pool, "Tagged", "release-tagged", "group-tagged")
resolved := lostAlbum(t, pool, "Resolved", "release-resolved", "") resolved := lostAlbum(t, pool, "Resolved", "release-resolved", "")
_ = lostAlbum(t, pool, "Unknown", "release-unknown", "") unknown := lostAlbum(t, pool, "Unknown", "release-unknown", "")
_ = lostAlbum(t, pool, "Off The List", "release-off", "group-off")
heldWant(t, pool, "group-tagged", tagged, "3 days")
heldWant(t, pool, "group-resolved", resolved, "3 days")
heldWant(t, pool, "group-unknown", unknown, "3 days")
s := NewSweeper(pool, settings, logger) s := NewSweeper(pool, settings, logger)
asked := map[string]int{} asked := map[string]int{}
@@ -112,8 +128,14 @@ func TestSweep_WantsAlbumsByReleaseGroup_Integration(t *testing.T) {
} }
wantFor("group-tagged", tagged) wantFor("group-tagged", tagged)
wantFor("group-resolved", resolved) wantFor("group-resolved", resolved)
if asked["release-tagged"] != 0 { if w, _ := q.GetWantByReleaseGroup(ctx, "group-unknown"); !w.FulfilledAt.Valid {
t.Error("MusicBrainz asked about an album whose tags named its group") t.Error("an album MusicBrainz cannot name was reopened under the wrong group")
}
if _, err := q.GetWantByReleaseGroup(ctx, "group-off"); err == nil {
t.Error("an album not on the list was wanted back")
}
if asked["release-tagged"] != 0 || asked["release-off"] != 0 {
t.Errorf("MusicBrainz asked %v: not about a tagged album, nor one off the list", asked)
} }
var cached *string var cached *string
if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE id = $1", resolved).Scan(&cached); err != nil { if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE id = $1", resolved).Scan(&cached); err != nil {
@@ -122,13 +144,6 @@ func TestSweep_WantsAlbumsByReleaseGroup_Integration(t *testing.T) {
if cached == nil || *cached != "group-resolved" { if cached == nil || *cached != "group-resolved" {
t.Errorf("resolved group not cached on the album: %v", cached) t.Errorf("resolved group not cached on the album: %v", cached)
} }
var n int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM wants").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("%d wants, want 2: the unresolvable album is skipped", n)
}
// The next pass finds both already wanted and asks nothing new. // The next pass finds both already wanted and asks nothing new.
asked = map[string]int{} asked = map[string]int{}
+12 -3
View File
@@ -3,6 +3,7 @@ package tags
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"sync/atomic" "sync/atomic"
@@ -172,15 +173,20 @@ func (p *musicbrainzProvider) TestConnection(ctx context.Context) error {
func ReleaseGroupForRelease(ctx context.Context, releaseMBID string) (string, error) { func ReleaseGroupForRelease(ctx context.Context, releaseMBID string) (string, error) {
p, err := ProviderByID("musicbrainz") p, err := ProviderByID("musicbrainz")
if err != nil { if err != nil {
return "", ErrNotFound return "", ErrSwitchedOff
} }
mb, ok := p.(*musicbrainzProvider) mb, ok := p.(*musicbrainzProvider)
if !ok { if !ok {
return "", ErrNotFound return "", ErrSwitchedOff
} }
return mb.releaseGroupForRelease(ctx, releaseMBID) return mb.releaseGroupForRelease(ctx, releaseMBID)
} }
// ErrSwitchedOff is ReleaseGroupForRelease's answer while MusicBrainz is
// switched off. It is an ErrNotFound, so callers that only skip still do; a
// caller that would record "MusicBrainz has none" can tell it apart.
var ErrSwitchedOff = fmt.Errorf("%w: MusicBrainz is switched off", ErrNotFound)
// mbReleaseResponse models the subset of /ws/2/release/{mbid}?inc=release-groups // mbReleaseResponse models the subset of /ws/2/release/{mbid}?inc=release-groups
// we read. // we read.
type mbReleaseResponse struct { type mbReleaseResponse struct {
@@ -190,7 +196,10 @@ type mbReleaseResponse struct {
} }
func (p *musicbrainzProvider) releaseGroupForRelease(ctx context.Context, releaseMBID string) (string, error) { func (p *musicbrainzProvider) releaseGroupForRelease(ctx context.Context, releaseMBID string) (string, error) {
if !p.enabled.Load() || releaseMBID == "" { if !p.enabled.Load() {
return "", ErrSwitchedOff
}
if releaseMBID == "" {
return "", ErrNotFound return "", ErrNotFound
} }
q := url.Values{"inc": {"release-groups"}, "fmt": {"json"}} q := url.Values{"inc": {"release-groups"}, "fmt": {"json"}}
+117
View File
@@ -0,0 +1,117 @@
package wanted
import (
"context"
"errors"
"fmt"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// ReasonLibrary: on the list because the library held it when the list
// began (#5567).
const ReasonLibrary = "library"
// libraryNamesPerPass bounds the MusicBrainz lookups one pass makes for
// albums whose tags named a release but not its group: about two minutes at
// MusicBrainz's one request a second, so the pass still settles its wants.
const libraryNamesPerPass = 120
// listLibrary puts the library's albums on the wanted list as held, once
// (operator, 2026-10-10: "make a record saying that the import has
// happened so later changes like unwanting hold and dont auto import from
// the file directory"). It runs a step each pass until done:
//
// - the first step stamps when the listing began, which is its cutoff:
// an album the library gains afterwards, dropped in by hand, is never
// listed;
// - every album whose release group is known goes on the list at once;
// - albums that name a release but not its group are looked up on
// MusicBrainz, a bounded number a pass, and listed as each is named;
// - once none is left to name, the listing is stamped done, with a count
// of the albums nothing names to MusicBrainz.
//
// After that it never runs again, so an album taken off the list stays off.
func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
state, err := q.GetLibraryListing(ctx)
if err != nil {
return fmt.Errorf("wanted: read the library listing: %w", err)
}
if state.LibraryListedAt.Valid {
return nil
}
cutoff := state.LibraryListingStartedAt
if !cutoff.Valid {
if cutoff, err = q.StartLibraryListing(ctx); err != nil {
return fmt.Errorf("wanted: start the library listing: %w", err)
}
n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff})
if err != nil {
return fmt.Errorf("wanted: list the library: %w", err)
}
res.Listed += int(n)
s.logger.Info("wanted: listing the library's albums as held", "listed", n)
}
for {
albums, err := q.ListLibraryAlbumsToName(ctx, dbq.ListLibraryAlbumsToNameParams{Cutoff: cutoff, MaxRows: libraryNamesPerPass})
if err != nil {
return fmt.Errorf("wanted: albums to name: %w", err)
}
if len(albums) == 0 {
break
}
for _, a := range albums {
if ctx.Err() != nil {
return ctx.Err()
}
group, err := s.releaseGroup(ctx, deref(a.Mbid))
switch {
case errors.Is(err, tags.ErrSwitchedOff):
// Nothing can be named while MusicBrainz is off: the rest are
// counted as unlisted rather than left waiting for ever.
return s.finishLibrary(ctx, q, res)
case errors.Is(err, tags.ErrNotFound):
group = "" // MusicBrainz has none: asked once
case err != nil:
// Unreachable or refusing for now: the next pass goes on.
s.logger.Info("wanted: naming the library's albums paused", "err", err)
return nil
}
if err := q.SetAlbumReleaseGroup(ctx, dbq.SetAlbumReleaseGroupParams{ID: a.ID, ReleaseGroupMbid: &group}); err != nil {
return fmt.Errorf("wanted: name album %s: %w", a.Title, err)
}
if group == "" {
continue
}
n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff, AlbumID: a.ID})
if err != nil {
return fmt.Errorf("wanted: list album %s: %w", a.Title, err)
}
res.Listed += int(n)
}
if len(albums) == libraryNamesPerPass {
return nil // the rest next pass
}
}
return s.finishLibrary(ctx, q, res)
}
func (s *Service) finishLibrary(ctx context.Context, q *dbq.Queries, res *PassResult) error {
done, err := q.FinishLibraryListing(ctx)
if err != nil {
return fmt.Errorf("wanted: finish the library listing: %w", err)
}
s.logger.Info("wanted: the library's albums are on the list", "listed_this_pass", res.Listed,
"unlisted", done.LibraryUnlisted)
return nil
}
func deref(p *string) string {
if p == nil {
return ""
}
return *p
}
+11 -3
View File
@@ -2,7 +2,9 @@
// artists, each on a release-type profile, and wanted release groups. A want // artists, each on a release-type profile, and wanted release groups. A want
// comes from an approved request, a followed artist's new release, or an // comes from an approved request, a followed artist's new release, or an
// album whose files went missing; it is held once the library has a full // album whose files went missing; it is held once the library has a full
// copy. Searching for open wants is #5521's. // copy. The library's own albums were put on the list as held once (#5567),
// so the list is the record of what the library keeps. Searching for open
// wants is #5521's.
package wanted package wanted
import ( import (
@@ -172,9 +174,9 @@ func (s *Service) Run(ctx context.Context) {
switch { switch {
case err != nil && ctx.Err() == nil: case err != nil && ctx.Err() == nil:
s.logger.Warn("wanted: pass stopped", "err", err, "follows", res.Follows, "wanted", res.Wanted, "held", res.Held) s.logger.Warn("wanted: pass stopped", "err", err, "follows", res.Follows, "wanted", res.Wanted, "held", res.Held)
case res.Wanted > 0 || res.Held > 0 || res.Baselined > 0: case res.Wanted > 0 || res.Held > 0 || res.Baselined > 0 || res.Listed > 0:
s.logger.Info("wanted: pass complete", "follows", res.Follows, "baselined", res.Baselined, s.logger.Info("wanted: pass complete", "follows", res.Follows, "baselined", res.Baselined,
"wanted", res.Wanted, "held", res.Held) "wanted", res.Wanted, "held", res.Held, "listed", res.Listed)
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -190,6 +192,7 @@ type PassResult struct {
Baselined int // of those, first reads Baselined int // of those, first reads
Wanted int // wants recorded from follows Wanted int // wants recorded from follows
Held int // wants the library now answers Held int // wants the library now answers
Listed int // library albums put on the list as held (#5567)
} }
// Pass applies the follows due and settles open wants. An unreachable // Pass applies the follows due and settles open wants. An unreachable
@@ -201,6 +204,11 @@ func (s *Service) Pass(ctx context.Context) (PassResult, error) {
if ctx.Err() != nil { if ctx.Err() != nil {
return res, ctx.Err() return res, ctx.Err()
} }
// The one-time listing of the library (#5567); a failure waits for the
// next pass and does not hold back settling.
if err := s.listLibrary(ctx, &res); err != nil && ctx.Err() == nil {
s.logger.Warn("wanted: list the library", "err", err)
}
if err := s.settleWants(ctx, &res); err != nil { if err := s.settleWants(ctx, &res); err != nil {
return res, err return res, err
} }
+132
View File
@@ -455,3 +455,135 @@ func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
} }
return u.ID return u.ID
} }
// startListing clears the reset's "already listed", as a fresh install has it.
func startListing(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
if _, err := pool.Exec(context.Background(), `
UPDATE acquisition_settings SET library_listing_started_at = NULL, library_listed_at = NULL, library_unlisted = 0`); err != nil {
t.Fatal(err)
}
}
// The library's albums go on the list as held, once (#5567). An album the
// tags name by release only is named through MusicBrainz first; one nothing
// names is counted, not listed. Taken off the list, an album stays off, and
// one added to the library afterwards is never listed.
func TestListLibrary_OnceAndThenTheListIsTheTruth_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
s := newService(t, pool, newFakeMB(t))
s.releaseGroup = func(_ context.Context, release string) (string, error) {
if release == "rel-parklife" {
return "rg-parklife", nil
}
return "", tags.ErrNotFound
}
blurAlbum := seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 3)
parklife := seedHeld(t, q, "Parklife", "rel-parklife", "", 2)
_ = seedHeld(t, q, "Bootleg", "rel-bootleg", "", 1)
// An album with a track gone: held from before the loss, so
// re-acquisition still fetches it back.
lost := seedHeld(t, q, "13", "rel-13", "rg-13", 2)
if _, err := pool.Exec(ctx, `UPDATE tracks SET missing_since = now() - interval '1 day'
WHERE album_id = $1 AND track_number = 1`, lost.ID); err != nil {
t.Fatal(err)
}
startListing(t, pool)
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
t.Fatal(err)
}
for group, album := range map[string]pgtype.UUID{"rg-blur": blurAlbum.ID, "rg-parklife": parklife.ID, "rg-13": lost.ID} {
w, err := q.GetWantByReleaseGroup(ctx, group)
if err != nil {
t.Fatalf("%s not listed: %v", group, err)
}
if w.Reason != ReasonLibrary || !w.FulfilledAt.Valid || w.AlbumID != album {
t.Errorf("%s = %+v: a held library want about the album", group, w)
}
}
if w, _ := q.GetWantByReleaseGroup(ctx, "rg-13"); !w.FulfilledAt.Time.Before(time.Now().Add(-23 * time.Hour)) {
t.Errorf("an album missing a track is held from %v, not from before the loss", w.FulfilledAt.Time)
}
state, err := q.GetLibraryListing(ctx)
if err != nil || !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
t.Fatalf("listing = %+v %v: done, with the bootleg unlisted", state, err)
}
// Off the list stays off; an album added later is never listed.
w, _ := q.GetWantByReleaseGroup(ctx, "rg-blur")
if _, err := s.RemoveWant(ctx, w.ID); err != nil {
t.Fatal(err)
}
_ = seedHeld(t, q, "Think Tank", "rel-think", "rg-think", 2)
if _, err := s.Pass(ctx); err != nil {
t.Fatal(err)
}
for _, group := range []string{"rg-blur", "rg-think"} {
if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil {
t.Errorf("%s is on the list", group)
}
}
}
// While MusicBrainz is switched off nothing can be named, so the listing
// finishes with what it has rather than waiting for ever.
func TestListLibrary_MusicBrainzOffFinishesWithWhatIsNamed_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
s := newService(t, pool, newFakeMB(t))
s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrSwitchedOff }
_ = seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 1)
_ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
startListing(t, pool)
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
t.Fatal(err)
}
state, _ := q.GetLibraryListing(ctx)
if !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
t.Errorf("listing = %+v", state)
}
var named *string
if err := pool.QueryRow(ctx, `SELECT release_group_mbid FROM albums WHERE title = 'Parklife'`).Scan(&named); err != nil {
t.Fatal(err)
}
if named != nil {
t.Errorf("an album was marked unnameable while MusicBrainz was off: %q", *named)
}
}
// A MusicBrainz that does not answer pauses the naming; the next pass goes on.
func TestListLibrary_UnreachablePausesTheNaming_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
s := newService(t, pool, newFakeMB(t))
down := true
s.releaseGroup = func(context.Context, string) (string, error) {
if down {
return "", tags.ErrTransient
}
return "rg-parklife", nil
}
_ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
startListing(t, pool)
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
t.Fatal(err)
}
if state, _ := q.GetLibraryListing(ctx); state.LibraryListedAt.Valid {
t.Fatal("finished while an album was still to be named")
}
down = false
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, "rg-parklife"); err != nil {
t.Errorf("not listed once named: %v", err)
}
if state, _ := q.GetLibraryListing(ctx); !state.LibraryListedAt.Valid {
t.Error("not finished")
}
}
+8 -2
View File
@@ -569,7 +569,8 @@ export type ResolvedDuplicatesResponse = {
// Wanted list (M502 #5520) ------------------------------------------------ // Wanted list (M502 #5520) ------------------------------------------------
export type WantReason = 'request' | 'follow' | 'missing'; // library: in the library when the list began (#5567).
export type WantReason = 'request' | 'follow' | 'missing' | 'library';
export type WantState = 'wanted' | 'missing' | 'held'; export type WantState = 'wanted' | 'missing' | 'held';
// One release group on the wanted list. held_at is set once the library // One release group on the wanted list. held_at is set once the library
@@ -811,7 +812,12 @@ export type ReleaseBlock = {
export type ReleaseBlocksPage = { items: ReleaseBlock[]; limit: number; offset: number }; export type ReleaseBlocksPage = { items: ReleaseBlock[]; limit: number; offset: number };
export type WantsPage = Page<Want> & { counts: WantCounts }; // The one-time listing of the library's albums as held (#5567): when it
// finished (null while it runs), and how many albums nothing names to
// MusicBrainz, so they could not be listed.
export type LibraryListing = { listed_at: string | null; unlisted: number };
export type WantsPage = Page<Want> & { counts: WantCounts; library: LibraryListing };
export type Follow = { export type Follow = {
artist_mbid: string; artist_mbid: string;
+37 -2
View File
@@ -13,7 +13,9 @@
// One tab of the Wanted view (M502 #5520–#5522): the albums still wanted, // One tab of the Wanted view (M502 #5520–#5522): the albums still wanted,
// the missing albums wanted back, or the ones held. Each open want says // the missing albums wanted back, or the ones held. Each open want says
// what its last search found and how its download is getting on; Releases // what its last search found and how its download is getting on; Releases
// lists every candidate with its verdict, and Search runs one now. // lists every candidate with its verdict, and Search runs one now. The
// library's albums were put on the list as held once (#5567); taking one
// off leaves its files and stops it being fetched back if they go.
let { list }: { list: WantState } = $props(); let { list }: { list: WantState } = $props();
@@ -21,6 +23,7 @@
const wantsStore = $derived(createWantsQuery(list)); const wantsStore = $derived(createWantsQuery(list));
const wants = $derived($wantsStore); const wants = $derived($wantsStore);
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]); const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
const library = $derived(wants.data?.pages[0]?.library);
let busy = $state<string | null>(null); let busy = $state<string | null>(null);
let searching = $state<string | null>(null); let searching = $state<string | null>(null);
@@ -30,7 +33,11 @@
busy = w.id; busy = w.id;
try { try {
await removeWant(w.id); await removeWant(w.id);
pushToast(`${w.title} is off the wanted list.`); pushToast(
w.held_at
? `${w.title} is off the list. Its files stay, and it won't be fetched back if they go missing.`
: `${w.title} is off the wanted list.`
);
await client.invalidateQueries({ queryKey: ['adminWants'] }); await client.invalidateQueries({ queryKey: ['adminWants'] });
} catch (e) { } catch (e) {
pushToast(errMessage(e), 'error'); pushToast(errMessage(e), 'error');
@@ -137,6 +144,7 @@
function why(w: Want): string { function why(w: Want): string {
if (w.reason === 'request') return w.requested_by ? `Requested by ${w.requested_by}` : 'Requested'; if (w.reason === 'request') return w.requested_by ? `Requested by ${w.requested_by}` : 'Requested';
if (w.reason === 'follow') return 'New from a followed artist'; if (w.reason === 'follow') return 'New from a followed artist';
if (w.reason === 'library') return 'In your library when the list began';
return 'Its files went missing'; return 'Its files went missing';
} }
@@ -151,6 +159,22 @@
} }
</script> </script>
{#if list === 'held' && library}
{#if !library.listed_at}
<p class="mb-3 text-sm text-text-secondary" data-testid="library-listing">
Your library's albums are being added to this list as held. Albums whose tags name only a
release are looked up on MusicBrainz first, about one a second.
</p>
{:else if library.unlisted > 0}
<p class="mb-3 text-sm text-text-secondary" data-testid="library-listing">
{library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
{library.unlisted === 1 ? "isn't" : "aren't"} on this list: nothing in {library.unlisted === 1 ? 'its' : 'their'}
tags names {library.unlisted === 1 ? 'it' : 'them'} to MusicBrainz. Those are never fetched back if
their files go missing.
</p>
{/if}
{/if}
{#if wants.isPending} {#if wants.isPending}
<p class="text-text-secondary">Reading the wanted list…</p> <p class="text-text-secondary">Reading the wanted list…</p>
{:else if wants.isError && !wants.isFetchNextPageError} {:else if wants.isError && !wants.isFetchNextPageError}
@@ -216,6 +240,17 @@
<div class="truncate text-xs text-text-secondary" data-testid="want-arrived">{arrived(w)}</div> <div class="truncate text-xs text-text-secondary" data-testid="want-arrived">{arrived(w)}</div>
{/if} {/if}
</div> </div>
{#if w.held_at}
<button
type="button"
onclick={() => onRemove(w)}
disabled={busy === w.id}
aria-label="Take {w.title} off the list"
class="inline-flex shrink-0 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>
{/if}
{#if !w.held_at} {#if !w.held_at}
<div class="flex shrink-0 flex-wrap justify-end gap-1.5"> <div class="flex shrink-0 flex-wrap justify-end gap-1.5">
<button <button
+240 -93
View File
@@ -1,25 +1,39 @@
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
import { mockInfiniteQuery, mockQuery } from '../../../test-utils/query'; import { mockInfiniteQuery, mockQuery } from "../../../test-utils/query";
import type { Want, WantGrab, WantsPage } from '#lib/api/types.js'; import type {
LibraryListing,
Want,
WantGrab,
WantsPage,
} from "#lib/api/types.js";
vi.mock('#lib/api/wanted.js', () => ({ vi.mock("#lib/api/wanted.js", () => ({
createWantsQuery: vi.fn(), createWantsQuery: vi.fn(),
removeWant: vi.fn().mockResolvedValue(undefined), removeWant: vi.fn().mockResolvedValue(undefined),
searchWant: vi.fn().mockResolvedValue({ searchWant: vi.fn().mockResolvedValue({
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: [] outcome: "nothing",
considered: 0,
best_release: "",
best_verdict: "",
narrowed: [],
}), }),
createCandidatesQuery: vi.fn(), createCandidatesQuery: vi.fn(),
grabCandidate: vi.fn(), grabCandidate: vi.fn(),
blockCandidate: vi.fn(), blockCandidate: vi.fn(),
unblockRelease: vi.fn() unblockRelease: vi.fn(),
})); }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() })); vi.mock("#lib/stores/toast.svelte.js", () => ({ pushToast: vi.fn() }));
import WantList from './WantList.svelte'; import WantList from "./WantList.svelte";
import { pushToast } from '#lib/stores/toast.svelte.js'; import { pushToast } from "#lib/stores/toast.svelte.js";
import { createCandidatesQuery, createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js'; import {
import type { WantState } from '#lib/api/types.js'; createCandidatesQuery,
createWantsQuery,
removeWant,
searchWant,
} from "#lib/api/wanted.js";
import type { WantState } from "#lib/api/types.js";
const DAY = 24 * 3_600_000; const DAY = 24 * 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
@@ -28,172 +42,305 @@ function want(id: string, extra: Partial<Want> = {}): Want {
return { return {
id, id,
release_group_mbid: `rg-${id}`, release_group_mbid: `rg-${id}`,
title: 'The Ballad of Darren', title: "The Ballad of Darren",
artist_mbid: 'blur', artist_mbid: "blur",
artist_name: 'Blur', artist_name: "Blur",
artist_id: 'art-1', artist_id: "art-1",
primary_type: 'Album', primary_type: "Album",
secondary_types: [], secondary_types: [],
year: '2023', year: "2023",
reason: 'follow', reason: "follow",
wanted_at: ago(2 * DAY), wanted_at: ago(2 * DAY),
held_at: null, held_at: null,
image_url: '', image_url: "",
...extra ...extra,
}; };
} }
function page(items: Want[]): WantsPage { function page(
items: Want[],
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0 },
): WantsPage {
return { return {
items, items,
total: items.length, total: items.length,
limit: 50, limit: 50,
offset: 0, offset: 0,
counts: { wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 } counts: {
wanted: 2,
missing: 1,
held: 4,
following: 1,
activity: 1,
attention: 0,
blocked: 0,
},
library,
}; };
} }
function grab(extra: Partial<WantGrab> = {}): WantGrab { function grab(extra: Partial<WantGrab> = {}): WantGrab {
return { return {
id: 'g1', id: "g1",
name: 'Blur - Blur (1997) [FLAC]', name: "Blur - Blur (1997) [FLAC]",
grabbed_at: ago(60_000), grabbed_at: ago(60_000),
origin: 'schedule', origin: "schedule",
quality: 'lossless', quality: "lossless",
live: true, live: true,
end_reason: '', end_reason: "",
progress: 0, progress: 0,
import_state: '', import_state: "",
import_reason: '', import_reason: "",
end_detail: '', end_detail: "",
imported_at: null, imported_at: null,
imported_files: 0, imported_files: 0,
...extra ...extra,
}; };
} }
afterEach(() => vi.clearAllMocks()); afterEach(() => vi.clearAllMocks());
function setup( function setup(
items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })], items: Want[] = [
state: WantState = 'wanted' want("w1"),
want("w2", { reason: "request", requested_by: "sam", title: "Parklife" }),
],
state: WantState = "wanted",
library?: LibraryListing,
) { ) {
vi.mocked(createWantsQuery).mockReturnValue( vi.mocked(createWantsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page(items)] }) as unknown as ReturnType<typeof createWantsQuery> mockInfiniteQuery({
pages: [page(items, library)],
}) as unknown as ReturnType<typeof createWantsQuery>,
); );
vi.mocked(createCandidatesQuery).mockReturnValue( vi.mocked(createCandidatesQuery).mockReturnValue(
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery> mockQuery({ data: [] }) as unknown as ReturnType<
typeof createCandidatesQuery
>,
); );
return render(WantList, { list: state }); return render(WantList, { list: state });
} }
describe('WantList', () => { describe("WantList", () => {
test('asks for its own state', () => { test("asks for its own state", () => {
setup([], 'missing'); setup([], "missing");
expect(createWantsQuery).toHaveBeenCalledWith('missing'); expect(createWantsQuery).toHaveBeenCalledWith("missing");
expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy(); expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy();
}); });
test('a want says why it is wanted', () => { test("a want says why it is wanted", () => {
setup(); setup();
const why = screen.getAllByTestId('want-why').map((e) => e.textContent ?? ''); const why = screen
.getAllByTestId("want-why")
.map((e) => e.textContent ?? "");
expect(why[0]).toMatch(/new from a followed artist/i); expect(why[0]).toMatch(/new from a followed artist/i);
expect(why[1]).toMatch(/requested by sam/i); expect(why[1]).toMatch(/requested by sam/i);
}); });
test('remove takes a want off the list', async () => { test("remove takes a want off the list", async () => {
setup(); setup();
await fireEvent.click(screen.getByRole('button', { name: /stop wanting parklife/i })); await fireEvent.click(
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w2')); screen.getByRole("button", { name: /stop wanting parklife/i }),
);
await waitFor(() => expect(removeWant).toHaveBeenCalledWith("w2"));
}); });
test('a held want has no remove button', () => { test("a held album can be taken off the list, and is not searched for", async () => {
setup([want('w1', { held_at: ago(DAY), album_id: 'al-1' })]); setup(
expect(screen.queryByRole('button', { name: /stop wanting/i })).toBeNull(); [want("w1", { held_at: ago(DAY), album_id: "al-1", reason: "library" })],
expect(screen.getByRole('link', { name: /the ballad of darren/i }).getAttribute('href')).toBe('/albums/al-1'); "held",
);
expect(
screen.queryByRole("button", { name: /stop wanting|search for/i }),
).toBeNull();
expect(
screen
.getByRole("link", { name: /the ballad of darren/i })
.getAttribute("href"),
).toBe("/albums/al-1");
expect(screen.getByTestId("want-why").textContent).toMatch(
/in your library when the list began/i,
);
await fireEvent.click(
screen.getByRole("button", {
name: /take the ballad of darren off the list/i,
}),
);
await waitFor(() => expect(removeWant).toHaveBeenCalledWith("w1"));
}); });
test('an empty list explains what lands there', () => { test("the held tab says while the library is being listed, and what could not be", () => {
setup([], "held", { listed_at: null, unlisted: 0 });
expect(screen.getByTestId("library-listing").textContent).toMatch(
/being added to this list/i,
);
});
test("the held tab names how many albums nothing names to MusicBrainz", () => {
setup([want("w1", { held_at: ago(DAY) })], "held", {
listed_at: ago(DAY),
unlisted: 12,
});
expect(screen.getByTestId("library-listing").textContent).toMatch(
/12 albums in your library\s+aren't on this list/i,
);
});
test("an empty list explains what lands there", () => {
setup([]); setup([]);
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy(); expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
}); });
test('each open want says what its last search found', () => { test("each open want says what its last search found", () => {
setup([ setup([
want('w1'), want("w1"),
want('w2', { want("w2", {
title: 'Parklife', title: "Parklife",
search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 4, best_release: 'x', best_verdict: 'rejected', error: '' } search: {
searched_at: ago(3_600_000),
outcome: "nothing",
considered: 4,
best_release: "x",
best_verdict: "rejected",
error: "",
},
}),
want("w3", { title: "Blur", grab: grab() }),
want("w4", {
title: "13",
grab: grab({ import_state: "downloading", progress: 0.42 }),
}), }),
want('w3', { title: 'Blur', grab: grab() }),
want('w4', { title: '13', grab: grab({ import_state: 'downloading', progress: 0.42 }) })
]); ]);
const lines = screen.getAllByTestId('want-search').map((e) => e.textContent ?? ''); const lines = screen
.getAllByTestId("want-search")
.map((e) => e.textContent ?? "");
expect(lines[0]).toMatch(/not searched yet/i); expect(lines[0]).toMatch(/not searched yet/i);
expect(lines[1]).toMatch(/4 found, none of them this album/i); expect(lines[1]).toMatch(/4 found, none of them this album/i);
expect(lines[2]).toMatch(/downloading blur - blur \(1997\) \[flac\], taken by the schedule/i); expect(lines[2]).toMatch(
/downloading blur - blur \(1997\) \[flac\], taken by the schedule/i,
);
expect(lines[3]).toMatch(/downloading 42%/i); expect(lines[3]).toMatch(/downloading 42%/i);
}); });
test('a download waiting on a person says why and points to Activity', () => { test("a download waiting on a person says why and points to Activity", () => {
setup([ setup([
want('w1', { grab: grab({ import_state: 'review', import_reason: 'Only the lengths agree. A person decides.' }) }) want("w1", {
grab: grab({
import_state: "review",
import_reason: "Only the lengths agree. A person decides.",
}),
}),
]); ]);
const line = screen.getByTestId('want-search'); const line = screen.getByTestId("want-search");
expect(line.textContent).toMatch(/needs you: only the lengths agree/i); expect(line.textContent).toMatch(/needs you: only the lengths agree/i);
expect(screen.getByRole('link', { name: /decide on activity/i }).getAttribute('href')).toBe('/wanted/activity'); expect(
screen
.getByRole("link", { name: /decide on activity/i })
.getAttribute("href"),
).toBe("/wanted/activity");
// A live download can't be searched for again. // A live download can't be searched for again.
expect((screen.getByRole('button', { name: /search for the ballad of darren now/i }) as HTMLButtonElement).disabled).toBe(true); expect(
(
screen.getByRole("button", {
name: /search for the ballad of darren now/i,
}) as HTMLButtonElement
).disabled,
).toBe(true);
}); });
test('a refused download is named beside the search that follows it', () => { test("a refused download is named beside the search that follows it", () => {
setup([ setup([
want('w1', { want("w1", {
search: { searched_at: ago(3_600_000), outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', error: '' }, search: {
grab: grab({ live: false, end_reason: 'refused', import_state: 'refused', import_reason: 'Another album.' }) searched_at: ago(3_600_000),
}) outcome: "nothing",
considered: 0,
best_release: "",
best_verdict: "",
error: "",
},
grab: grab({
live: false,
end_reason: "refused",
import_state: "refused",
import_reason: "Another album.",
}),
}),
]); ]);
expect(screen.getByTestId('want-search').textContent).toMatch(/last download refused: another album/i); expect(screen.getByTestId("want-search").textContent).toMatch(
/last download refused: another album/i,
);
}); });
test('a download given up on is named beside the search that follows it', () => { test("a download given up on is named beside the search that follows it", () => {
setup([ setup([
want('w1', { want("w1", {
search: { searched_at: ago(3_600_000), outcome: 'waiting', considered: 4, best_release: '', best_verdict: '', error: '' }, search: {
grab: grab({ live: false, end_reason: 'stalled', import_state: 'downloading', end_detail: 'Nobody was sharing it.' }) searched_at: ago(3_600_000),
}) outcome: "waiting",
considered: 4,
best_release: "",
best_verdict: "",
error: "",
},
grab: grab({
live: false,
end_reason: "stalled",
import_state: "downloading",
end_detail: "Nobody was sharing it.",
}),
}),
]); ]);
expect(screen.getByTestId('want-search').textContent).toMatch(/last download given up: nobody was sharing it/i); expect(screen.getByTestId("want-search").textContent).toMatch(
/last download given up: nobody was sharing it/i,
);
}); });
test('a held album says how it arrived', () => { test("a held album says how it arrived", () => {
setup( setup(
[ [
want('w1', { want("w1", {
held_at: ago(DAY), held_at: ago(DAY),
album_id: 'al-1', album_id: "al-1",
grab: grab({ live: false, end_reason: 'imported', import_state: 'imported', imported_at: ago(DAY), imported_files: 11 }) grab: grab({
}) live: false,
end_reason: "imported",
import_state: "imported",
imported_at: ago(DAY),
imported_files: 11,
}),
}),
], ],
'held' "held",
);
expect(screen.getByTestId("want-arrived").textContent).toMatch(
/imported 11 tracks .* from blur - blur/i,
); );
expect(screen.getByTestId('want-arrived').textContent).toMatch(/imported 11 tracks .* from blur - blur/i);
}); });
test('Search runs one now and says what came of it', async () => { test("Search runs one now and says what came of it", async () => {
setup(); setup();
await fireEvent.click(screen.getByRole('button', { name: /search for parklife now/i })); await fireEvent.click(
await waitFor(() => expect(searchWant).toHaveBeenCalledWith('w2')); screen.getByRole("button", { name: /search for parklife now/i }),
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing found for Parklife.', 'info')); );
await waitFor(() => expect(searchWant).toHaveBeenCalledWith("w2"));
await waitFor(() =>
expect(pushToast).toHaveBeenCalledWith(
"Nothing found for Parklife.",
"info",
),
);
}); });
test('Releases opens what the last search found', async () => { test("Releases opens what the last search found", async () => {
setup(); setup();
expect(screen.queryByTestId('want-candidates')).toBeNull(); expect(screen.queryByTestId("want-candidates")).toBeNull();
const toggle = screen.getByRole('button', { name: /releases found for parklife/i }); const toggle = screen.getByRole("button", {
name: /releases found for parklife/i,
});
await fireEvent.click(toggle); await fireEvent.click(toggle);
expect(toggle.getAttribute('aria-expanded')).toBe('true'); expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByTestId('want-candidates')).toBeTruthy(); expect(screen.getByTestId("want-candidates")).toBeTruthy();
expect(createCandidatesQuery).toHaveBeenCalledWith('w2'); expect(createCandidatesQuery).toHaveBeenCalledWith("w2");
}); });
}); });
+71 -38
View File
@@ -1,61 +1,94 @@
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from "vitest";
import { render, screen } from '@testing-library/svelte'; import { render, screen } from "@testing-library/svelte";
import { createRawSnippet } from 'svelte'; import { createRawSnippet } from "svelte";
import { mockInfiniteQuery } from '../../test-utils/query'; import { mockInfiniteQuery } from "../../test-utils/query";
import { pageUrlModule } from '#test-utils/mocks/appState.js'; import { pageUrlModule } from "#test-utils/mocks/appState.js";
import type { WantCounts, WantsPage } from '#lib/api/types.js'; import type { WantCounts, WantsPage } from "#lib/api/types.js";
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/wanted') })); const state = vi.hoisted(() => ({
vi.mock('$app/state', () => pageUrlModule(state)); pageUrl: new URL("http://localhost/wanted"),
vi.mock('#lib/api/wanted.js', () => ({ createWantsQuery: vi.fn() })); }));
vi.mock("$app/state", () => pageUrlModule(state));
vi.mock("#lib/api/wanted.js", () => ({ createWantsQuery: vi.fn() }));
import WantedLayout from './+layout.svelte'; import WantedLayout from "./+layout.svelte";
import { createWantsQuery } from '#lib/api/wanted.js'; import { createWantsQuery } from "#lib/api/wanted.js";
const counts: WantCounts = { wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4 }; const counts: WantCounts = {
const children = createRawSnippet(() => ({ render: () => `<p data-testid="tab-body">body</p>` })); wanted: 5,
missing: 1,
held: 9,
following: 2,
activity: 3,
attention: 0,
blocked: 4,
};
const children = createRawSnippet(() => ({
render: () => `<p data-testid="tab-body">body</p>`,
}));
afterEach(() => vi.clearAllMocks()); afterEach(() => vi.clearAllMocks());
function setup(c: WantCounts = counts, path = '/wanted') { function setup(c: WantCounts = counts, path = "/wanted") {
state.pageUrl = new URL(`http://localhost${path}`); state.pageUrl = new URL(`http://localhost${path}`);
const page: WantsPage = { items: [], total: 0, limit: 50, offset: 0, counts: c }; const page: WantsPage = {
items: [],
total: 0,
limit: 50,
offset: 0,
counts: c,
library: { listed_at: null, unlisted: 0 },
};
vi.mocked(createWantsQuery).mockReturnValue( vi.mocked(createWantsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<typeof createWantsQuery> mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<
typeof createWantsQuery
>,
); );
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return render(WantedLayout, { children } as any); return render(WantedLayout, { children } as any);
} }
describe('/wanted layout', () => { describe("/wanted layout", () => {
test('tabs are links carrying their counts, the current one marked', () => { test("tabs are links carrying their counts, the current one marked", () => {
setup(counts, '/wanted/activity'); setup(counts, "/wanted/activity");
const nav = screen.getByRole('navigation', { name: /wanted sections/i }); const nav = screen.getByRole("navigation", { name: /wanted sections/i });
const links = Array.from(nav.querySelectorAll('a')).map((a) => [a.getAttribute('href'), a.textContent?.replace(/\s+/g, ' ').trim()]); const links = Array.from(nav.querySelectorAll("a")).map((a) => [
expect(links).toEqual([ a.getAttribute("href"),
['/wanted', 'Wanted 5'], a.textContent?.replace(/\s+/g, " ").trim(),
['/wanted/activity', 'Activity 3'],
['/wanted/missing', 'Missing files 1'],
['/wanted/held', 'Held 9'],
['/wanted/following', 'Following 2'],
['/wanted/blocked', 'Blocked 4']
]); ]);
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page'); expect(links).toEqual([
expect(screen.getByRole('link', { name: /^wanted/i })).not.toHaveAttribute('aria-current'); ["/wanted", "Wanted 5"],
expect(screen.getByTestId('tab-body')).toBeTruthy(); ["/wanted/activity", "Activity 3"],
["/wanted/missing", "Missing files 1"],
["/wanted/held", "Held 9"],
["/wanted/following", "Following 2"],
["/wanted/blocked", "Blocked 4"],
]);
expect(screen.getByRole("link", { name: /activity/i })).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.getByRole("link", { name: /^wanted/i })).not.toHaveAttribute(
"aria-current",
);
expect(screen.getByTestId("tab-body")).toBeTruthy();
}); });
test('when nothing needs a person the verdict says so', () => { test("when nothing needs a person the verdict says so", () => {
setup(); setup();
expect(screen.getByTestId('wanted-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./); expect(screen.getByTestId("wanted-verdict").textContent).toMatch(
expect(screen.queryByTestId('tab-attention')).toBeNull(); /5 albums wanted, 3 downloading\. Nothing needs you\./,
);
expect(screen.queryByTestId("tab-attention")).toBeNull();
}); });
test('downloads waiting on a person lead the page and mark Activity', () => { test("downloads waiting on a person lead the page and mark Activity", () => {
setup({ ...counts, attention: 2 }); setup({ ...counts, attention: 2 });
const verdict = screen.getByTestId('wanted-verdict'); const verdict = screen.getByTestId("wanted-verdict");
expect(verdict.textContent).toMatch(/2 downloads need you/); expect(verdict.textContent).toMatch(/2 downloads need you/);
expect(screen.getByRole('link', { name: /see activity/i }).getAttribute('href')).toBe('/wanted/activity'); expect(
expect(screen.getByTestId('tab-attention').textContent).toBe('2'); screen.getByRole("link", { name: /see activity/i }).getAttribute("href"),
).toBe("/wanted/activity");
expect(screen.getByTestId("tab-attention").textContent).toBe("2");
}); });
}); });