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
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:
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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"}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user