DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156
@@ -91,13 +91,24 @@ type wantsPage struct {
|
||||
}
|
||||
|
||||
// libraryListingView: whether the library's albums are all on the list
|
||||
// yet, and how many could not be listed (nothing names them to
|
||||
// MusicBrainz).
|
||||
// yet, and which could not be matched to MusicBrainz (#5569). Unmatched is
|
||||
// filled on the first page of the held list only.
|
||||
type libraryListingView struct {
|
||||
ListedAt *string `json:"listed_at"`
|
||||
Unlisted int32 `json:"unlisted"`
|
||||
ListedAt *string `json:"listed_at"`
|
||||
Unlisted int32 `json:"unlisted"`
|
||||
Unmatched []unmatchedAlbumView `json:"unmatched"`
|
||||
}
|
||||
|
||||
type unmatchedAlbumView struct {
|
||||
AlbumID string `json:"album_id"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// unmatchedShown is how many unmatched albums the held list names.
|
||||
const unmatchedShown = 200
|
||||
|
||||
const wantsPageSize = 50
|
||||
|
||||
// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&offset=.
|
||||
@@ -137,10 +148,23 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
||||
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following,
|
||||
Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked},
|
||||
}
|
||||
out.Library.Unmatched = []unmatchedAlbumView{}
|
||||
if listing.LibraryListedAt.Valid {
|
||||
at := formatTimestamp(listing.LibraryListedAt)
|
||||
out.Library.ListedAt = &at
|
||||
}
|
||||
if state == "held" && offset == 0 && listing.LibraryUnlisted > 0 {
|
||||
unmatched, err := q.ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin wanted: unmatched albums", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
for _, u := range unmatched {
|
||||
out.Library.Unmatched = append(out.Library.Unmatched, unmatchedAlbumView{
|
||||
AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
switch state {
|
||||
case "missing":
|
||||
out.Total = counts.Missing
|
||||
|
||||
@@ -487,6 +487,15 @@ type Grab struct {
|
||||
SeedReleasedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type LibraryAlbumMatch struct {
|
||||
AlbumID pgtype.UUID
|
||||
Outcome string
|
||||
Method string
|
||||
ReleaseGroupMbid string
|
||||
Detail string
|
||||
DecidedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type LibraryChange struct {
|
||||
ID int64
|
||||
EntityType string
|
||||
|
||||
+231
-54
@@ -125,15 +125,10 @@ func (q *Queries) DeleteWant(ctx context.Context, id pgtype.UUID) (int64, error)
|
||||
}
|
||||
|
||||
const finishLibraryListing = `-- name: FinishLibraryListing :one
|
||||
UPDATE acquisition_settings s
|
||||
UPDATE acquisition_settings
|
||||
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
|
||||
library_unlisted = (SELECT COUNT(*) FROM library_album_matches WHERE outcome = 'unmatched')
|
||||
WHERE id = true AND library_listed_at IS NULL
|
||||
RETURNING library_listed_at, library_unlisted
|
||||
`
|
||||
|
||||
@@ -142,8 +137,7 @@ type FinishLibraryListingRow struct {
|
||||
LibraryUnlisted int32
|
||||
}
|
||||
|
||||
// The listing is done: count the albums from before the cutoff it could not
|
||||
// list, because nothing names them to MusicBrainz.
|
||||
// The listing is done: count the albums no match was found for.
|
||||
func (q *Queries) FinishLibraryListing(ctx context.Context) (FinishLibraryListingRow, error) {
|
||||
row := q.db.QueryRow(ctx, finishLibraryListing)
|
||||
var i FinishLibraryListingRow
|
||||
@@ -297,6 +291,44 @@ func (q *Queries) GetWantByReleaseGroup(ctx context.Context, releaseGroupMbid st
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertLibraryWant = `-- name: InsertLibraryWant :execrows
|
||||
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date,
|
||||
reason, album_id, wanted_at, fulfilled_at)
|
||||
VALUES ($1, $2, $3, $4,
|
||||
$5, $6, 'library', $7::uuid, now(),
|
||||
COALESCE((SELECT MIN(t.missing_since) FROM tracks t WHERE t.album_id = $7::uuid)
|
||||
- interval '1 second', now()))
|
||||
ON CONFLICT (release_group_mbid) DO NOTHING
|
||||
`
|
||||
|
||||
type InsertLibraryWantParams struct {
|
||||
ReleaseGroupMbid string
|
||||
ArtistMbid string
|
||||
ArtistName string
|
||||
Title string
|
||||
PrimaryType string
|
||||
FirstReleaseDate string
|
||||
AlbumID pgtype.UUID
|
||||
}
|
||||
|
||||
// Puts one matched album on the list as held, from just before its first
|
||||
// lost track if it has one. A group already on the list is left as it is.
|
||||
func (q *Queries) InsertLibraryWant(ctx context.Context, arg InsertLibraryWantParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, insertLibraryWant,
|
||||
arg.ReleaseGroupMbid,
|
||||
arg.ArtistMbid,
|
||||
arg.ArtistName,
|
||||
arg.Title,
|
||||
arg.PrimaryType,
|
||||
arg.FirstReleaseDate,
|
||||
arg.AlbumID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const libraryHoldsReleaseGroup = `-- name: LibraryHoldsReleaseGroup :one
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM albums a
|
||||
@@ -325,6 +357,103 @@ func (q *Queries) LibraryHoldsReleaseGroup(ctx context.Context, arg LibraryHolds
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const listAlbumTracksToMatch = `-- name: ListAlbumTracksToMatch :many
|
||||
SELECT title, disc_number, track_number, duration_ms, mbid
|
||||
FROM tracks
|
||||
WHERE album_id = $1
|
||||
ORDER BY disc_number NULLS FIRST, track_number NULLS LAST, title
|
||||
`
|
||||
|
||||
type ListAlbumTracksToMatchRow struct {
|
||||
Title string
|
||||
DiscNumber *int32
|
||||
TrackNumber *int32
|
||||
DurationMs int32
|
||||
Mbid *string
|
||||
}
|
||||
|
||||
// What an album's tracks say about themselves, to check a match against.
|
||||
func (q *Queries) ListAlbumTracksToMatch(ctx context.Context, albumID pgtype.UUID) ([]ListAlbumTracksToMatchRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAlbumTracksToMatch, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListAlbumTracksToMatchRow
|
||||
for rows.Next() {
|
||||
var i ListAlbumTracksToMatchRow
|
||||
if err := rows.Scan(
|
||||
&i.Title,
|
||||
&i.DiscNumber,
|
||||
&i.TrackNumber,
|
||||
&i.DurationMs,
|
||||
&i.Mbid,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAlbumsToMatch = `-- name: ListAlbumsToMatch :many
|
||||
SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.mbid AS artist_mbid
|
||||
FROM albums a
|
||||
JOIN artists ar ON ar.id = a.artist_id
|
||||
WHERE a.created_at <= $1::timestamptz
|
||||
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM library_album_matches m WHERE m.album_id = a.id)
|
||||
ORDER BY a.created_at, a.id
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListAlbumsToMatchParams struct {
|
||||
Cutoff pgtype.Timestamptz
|
||||
MaxRows int32
|
||||
}
|
||||
|
||||
type ListAlbumsToMatchRow struct {
|
||||
ID pgtype.UUID
|
||||
Title string
|
||||
Mbid *string
|
||||
ReleaseGroupMbid *string
|
||||
ArtistName string
|
||||
ArtistMbid *string
|
||||
}
|
||||
|
||||
// Library albums from before the cutoff that the listing has not decided:
|
||||
// each is matched to MusicBrainz (#5569). An album row with no tracks is
|
||||
// not an album in the library.
|
||||
func (q *Queries) ListAlbumsToMatch(ctx context.Context, arg ListAlbumsToMatchParams) ([]ListAlbumsToMatchRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAlbumsToMatch, arg.Cutoff, arg.MaxRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListAlbumsToMatchRow
|
||||
for rows.Next() {
|
||||
var i ListAlbumsToMatchRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Mbid,
|
||||
&i.ReleaseGroupMbid,
|
||||
&i.ArtistName,
|
||||
&i.ArtistMbid,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listFollowGroups = `-- name: ListFollowGroups :many
|
||||
SELECT g.mbid, g.title, g.primary_type, g.secondary_types, g.first_release_date,
|
||||
EXISTS (SELECT 1 FROM follow_seen_groups s
|
||||
@@ -506,48 +635,6 @@ func (q *Queries) ListLibraryAlbums(ctx context.Context, arg ListLibraryAlbumsPa
|
||||
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
|
||||
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
|
||||
@@ -645,6 +732,49 @@ func (q *Queries) ListReleaseTypeProfiles(ctx context.Context) ([]ListReleaseTyp
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listUnmatchedLibraryAlbums = `-- name: ListUnmatchedLibraryAlbums :many
|
||||
SELECT m.album_id, a.title, ar.name AS artist_name, m.detail
|
||||
FROM library_album_matches m
|
||||
JOIN albums a ON a.id = m.album_id
|
||||
JOIN artists ar ON ar.id = a.artist_id
|
||||
WHERE m.outcome = 'unmatched'
|
||||
ORDER BY ar.name, a.title
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type ListUnmatchedLibraryAlbumsRow struct {
|
||||
AlbumID pgtype.UUID
|
||||
Title string
|
||||
ArtistName string
|
||||
Detail string
|
||||
}
|
||||
|
||||
// The albums the listing could not match, for the Held tab.
|
||||
func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32) ([]ListUnmatchedLibraryAlbumsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listUnmatchedLibraryAlbums, maxRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUnmatchedLibraryAlbumsRow
|
||||
for rows.Next() {
|
||||
var i ListUnmatchedLibraryAlbumsRow
|
||||
if err := rows.Scan(
|
||||
&i.AlbumID,
|
||||
&i.Title,
|
||||
&i.ArtistName,
|
||||
&i.Detail,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listWants = `-- name: ListWants :many
|
||||
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
|
||||
w.album_id, w.wanted_at, w.fulfilled_at,
|
||||
@@ -806,6 +936,52 @@ func (q *Queries) MarkGroupSeen(ctx context.Context, arg MarkGroupSeenParams) er
|
||||
return err
|
||||
}
|
||||
|
||||
const recordAlbumMatch = `-- name: RecordAlbumMatch :exec
|
||||
INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid, detail)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (album_id) DO UPDATE
|
||||
SET outcome = EXCLUDED.outcome, method = EXCLUDED.method,
|
||||
release_group_mbid = EXCLUDED.release_group_mbid, detail = EXCLUDED.detail, decided_at = now()
|
||||
`
|
||||
|
||||
type RecordAlbumMatchParams struct {
|
||||
AlbumID pgtype.UUID
|
||||
Outcome string
|
||||
Method string
|
||||
ReleaseGroupMbid string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (q *Queries) RecordAlbumMatch(ctx context.Context, arg RecordAlbumMatchParams) error {
|
||||
_, err := q.db.Exec(ctx, recordAlbumMatch,
|
||||
arg.AlbumID,
|
||||
arg.Outcome,
|
||||
arg.Method,
|
||||
arg.ReleaseGroupMbid,
|
||||
arg.Detail,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const recordListedAlbums = `-- name: RecordListedAlbums :exec
|
||||
INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid)
|
||||
SELECT a.id, 'listed', 'tags', a.release_group_mbid
|
||||
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 EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
|
||||
ON CONFLICT (album_id) DO NOTHING
|
||||
`
|
||||
|
||||
// The albums ListLibraryAlbums takes are decided as listed by their tags,
|
||||
// so the matching that follows leaves them alone (#5569).
|
||||
func (q *Queries) RecordListedAlbums(ctx context.Context, cutoff pgtype.Timestamptz) error {
|
||||
_, err := q.db.Exec(ctx, recordListedAlbums, cutoff)
|
||||
return err
|
||||
}
|
||||
|
||||
const repointWant = `-- name: RepointWant :exec
|
||||
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1
|
||||
`
|
||||
@@ -822,8 +998,8 @@ func (q *Queries) RepointWant(ctx context.Context, arg RepointWantParams) error
|
||||
}
|
||||
|
||||
const setAlbumReleaseGroup = `-- name: SetAlbumReleaseGroup :exec
|
||||
UPDATE albums SET release_group_mbid = $1
|
||||
WHERE id = $2 AND release_group_mbid IS NULL
|
||||
UPDATE albums SET release_group_mbid = $1, updated_at = now()
|
||||
WHERE id = $2 AND (release_group_mbid IS NULL OR release_group_mbid = '')
|
||||
`
|
||||
|
||||
type SetAlbumReleaseGroupParams struct {
|
||||
@@ -831,6 +1007,7 @@ type SetAlbumReleaseGroupParams struct {
|
||||
ID pgtype.UUID
|
||||
}
|
||||
|
||||
// Names a matched album's release group, filling only what nothing named.
|
||||
func (q *Queries) SetAlbumReleaseGroup(ctx context.Context, arg SetAlbumReleaseGroupParams) error {
|
||||
_, err := q.db.Exec(ctx, setAlbumReleaseGroup, arg.ReleaseGroupMbid, arg.ID)
|
||||
return err
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE library_album_matches;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Every library album is matched to MusicBrainz before it goes on the list
|
||||
-- (#5569, operator 2026-10-10: "they should all exist in music brainz we
|
||||
-- need to use their meta data or a search system to link them up"). The
|
||||
-- listing in 0085 took only albums whose tags already named their release
|
||||
-- group and artist; the rest are now matched by the release their tags
|
||||
-- name, the artist's discography, or a search, and checked against their
|
||||
-- own tracks.
|
||||
--
|
||||
-- library_album_matches one row per album the listing has decided:
|
||||
-- listed (with how it was matched) or unmatched
|
||||
-- (with why). It is the record that the listing
|
||||
-- saw the album, so an album taken off the list
|
||||
-- is never listed again.
|
||||
CREATE TABLE library_album_matches (
|
||||
album_id uuid PRIMARY KEY REFERENCES albums (id) ON DELETE CASCADE,
|
||||
outcome text NOT NULL
|
||||
CONSTRAINT library_album_matches_outcome_check CHECK (outcome IN ('listed', 'unmatched')),
|
||||
-- tags its tags named the release group.
|
||||
-- release its tags named a release; MusicBrainz named the group.
|
||||
-- discography its title is one of the artist's release groups.
|
||||
-- search MusicBrainz's search found it by artist and title.
|
||||
method text NOT NULL DEFAULT ''
|
||||
CONSTRAINT library_album_matches_method_check CHECK (method IN ('', 'tags', 'release', 'discography', 'search')),
|
||||
release_group_mbid text NOT NULL DEFAULT '',
|
||||
detail text NOT NULL DEFAULT '',
|
||||
decided_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX library_album_matches_unmatched_idx ON library_album_matches (decided_at) WHERE outcome = 'unmatched';
|
||||
|
||||
-- The albums 0085's listing already took, by its own test, are decided:
|
||||
-- one taken off the list since stays off.
|
||||
INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid)
|
||||
SELECT a.id, 'listed', 'tags', a.release_group_mbid
|
||||
FROM albums a
|
||||
JOIN artists ar ON ar.id = a.artist_id
|
||||
JOIN acquisition_settings s ON s.id = true
|
||||
WHERE s.library_listing_started_at IS NOT NULL
|
||||
AND a.created_at <= s.library_listing_started_at
|
||||
AND a.release_group_mbid IS NOT NULL AND a.release_group_mbid <> ''
|
||||
AND ar.mbid IS NOT NULL AND ar.mbid <> ''
|
||||
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id);
|
||||
|
||||
-- The rest are matched by the wanted worker after boot: the listing resumes.
|
||||
UPDATE acquisition_settings
|
||||
SET library_listed_at = NULL, library_unlisted = 0
|
||||
WHERE library_listing_started_at IS NOT NULL;
|
||||
@@ -258,30 +258,76 @@ SELECT DISTINCT ON (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 <> ''
|
||||
-- name: RecordListedAlbums :exec
|
||||
-- The albums ListLibraryAlbums takes are decided as listed by their tags,
|
||||
-- so the matching that follows leaves them alone (#5569).
|
||||
INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid)
|
||||
SELECT a.id, 'listed', 'tags', a.release_group_mbid
|
||||
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 EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
|
||||
ON CONFLICT (album_id) DO NOTHING;
|
||||
|
||||
-- name: ListAlbumsToMatch :many
|
||||
-- Library albums from before the cutoff that the listing has not decided:
|
||||
-- each is matched to MusicBrainz (#5569). An album row with no tracks is
|
||||
-- not an album in the library.
|
||||
SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.mbid AS artist_mbid
|
||||
FROM albums a
|
||||
JOIN artists ar ON ar.id = a.artist_id
|
||||
WHERE a.created_at <= sqlc.arg(cutoff)::timestamptz
|
||||
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM library_album_matches m WHERE m.album_id = a.id)
|
||||
ORDER BY a.created_at, a.id
|
||||
LIMIT sqlc.arg(max_rows);
|
||||
|
||||
-- name: ListAlbumTracksToMatch :many
|
||||
-- What an album's tracks say about themselves, to check a match against.
|
||||
SELECT title, disc_number, track_number, duration_ms, mbid
|
||||
FROM tracks
|
||||
WHERE album_id = $1
|
||||
ORDER BY disc_number NULLS FIRST, track_number NULLS LAST, title;
|
||||
|
||||
-- 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;
|
||||
-- Names a matched album's release group, filling only what nothing named.
|
||||
UPDATE albums SET release_group_mbid = sqlc.arg(release_group_mbid), updated_at = now()
|
||||
WHERE id = sqlc.arg(id) AND (release_group_mbid IS NULL OR release_group_mbid = '');
|
||||
|
||||
-- name: InsertLibraryWant :execrows
|
||||
-- Puts one matched album on the list as held, from just before its first
|
||||
-- lost track if it has one. A group already on the list is left as it is.
|
||||
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date,
|
||||
reason, album_id, wanted_at, fulfilled_at)
|
||||
VALUES (sqlc.arg(release_group_mbid), sqlc.arg(artist_mbid), sqlc.arg(artist_name), sqlc.arg(title),
|
||||
sqlc.arg(primary_type), sqlc.arg(first_release_date), 'library', sqlc.arg(album_id)::uuid, now(),
|
||||
COALESCE((SELECT MIN(t.missing_since) FROM tracks t WHERE t.album_id = sqlc.arg(album_id)::uuid)
|
||||
- interval '1 second', now()))
|
||||
ON CONFLICT (release_group_mbid) DO NOTHING;
|
||||
|
||||
-- name: RecordAlbumMatch :exec
|
||||
INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid, detail)
|
||||
VALUES (sqlc.arg(album_id), sqlc.arg(outcome), sqlc.arg(method), sqlc.arg(release_group_mbid), sqlc.arg(detail))
|
||||
ON CONFLICT (album_id) DO UPDATE
|
||||
SET outcome = EXCLUDED.outcome, method = EXCLUDED.method,
|
||||
release_group_mbid = EXCLUDED.release_group_mbid, detail = EXCLUDED.detail, decided_at = now();
|
||||
|
||||
-- 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
|
||||
-- The listing is done: count the albums no match was found for.
|
||||
UPDATE acquisition_settings
|
||||
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
|
||||
library_unlisted = (SELECT COUNT(*) FROM library_album_matches WHERE outcome = 'unmatched')
|
||||
WHERE id = true AND library_listed_at IS NULL
|
||||
RETURNING library_listed_at, library_unlisted;
|
||||
|
||||
-- name: ListUnmatchedLibraryAlbums :many
|
||||
-- The albums the listing could not match, for the Held tab.
|
||||
SELECT m.album_id, a.title, ar.name AS artist_name, m.detail
|
||||
FROM library_album_matches m
|
||||
JOIN albums a ON a.id = m.album_id
|
||||
JOIN artists ar ON ar.id = a.artist_id
|
||||
WHERE m.outcome = 'unmatched'
|
||||
ORDER BY ar.name, a.title
|
||||
LIMIT sqlc.arg(max_rows);
|
||||
|
||||
@@ -110,6 +110,7 @@ var dataTables = []string{
|
||||
"mb_artists", // M502 #5519
|
||||
"requests", // M502 #5520 (was lidarr_requests)
|
||||
"download_interventions", // M502 #5523
|
||||
"library_album_matches", // #5569
|
||||
"release_blocks", // M502 #5521
|
||||
"grabs", // M502 #5521
|
||||
"want_candidates", // M502 #5521
|
||||
|
||||
@@ -351,8 +351,8 @@ func pairUp(files []File, tracks []Track) []Pair {
|
||||
File: order[i],
|
||||
Track: i,
|
||||
Recording: t.RecordingMBID != "" && (f.RecordingMBID == t.RecordingMBID || slices.Contains(f.Recordings, t.RecordingMBID)),
|
||||
Length: lengthFits(f.LengthMs, t.LengthMs),
|
||||
Title: titleAgrees(f.Title, t.Title),
|
||||
Length: release.LengthFits(f.LengthMs, t.LengthMs),
|
||||
Title: release.TitleAgrees(f.Title, t.Title),
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
@@ -372,40 +372,6 @@ func numbered(files []File) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// lengthFits allows five seconds or 3%, whichever is more: remasters and
|
||||
// rips differ by a second or two, a different recording by far more. A
|
||||
// track MusicBrainz has no length for says nothing against the file.
|
||||
func lengthFits(fileMs, trackMs int) bool {
|
||||
if trackMs <= 0 {
|
||||
return true
|
||||
}
|
||||
if fileMs <= 0 {
|
||||
return false
|
||||
}
|
||||
tol := max(5000, trackMs*3/100)
|
||||
d := fileMs - trackMs
|
||||
return d <= tol && -d <= tol
|
||||
}
|
||||
|
||||
// titleAgrees: the same words, or one title holding all of the other's
|
||||
// ("Get Lucky (Radio Edit)" holds "Get Lucky").
|
||||
func titleAgrees(a, b string) bool {
|
||||
ta, tb := release.Tokens(a), release.Tokens(b)
|
||||
if len(ta) == 0 || len(tb) == 0 {
|
||||
return false
|
||||
}
|
||||
return subset(ta, tb) || subset(tb, ta)
|
||||
}
|
||||
|
||||
func subset(small, big []string) bool {
|
||||
for _, w := range small {
|
||||
if !slices.Contains(big, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// score weighs recordings above lengths above titles: a fingerprint is the
|
||||
// audio itself, a length is the audio's shape, a title is only a name.
|
||||
func score(pairs []Pair) int {
|
||||
|
||||
@@ -161,22 +161,6 @@ func TestVerify_WithoutAcoustIDTheReasonSaysSo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLengthFits(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
file, track int
|
||||
ok bool
|
||||
}{
|
||||
// Short tracks get the 5s floor; longer ones 3% of the track.
|
||||
{120000, 125000, true}, {120000, 126000, false},
|
||||
{240000, 246000, true}, {240000, 248000, false}, {600000, 615000, true}, {600000, 620000, false},
|
||||
{0, 240000, false}, {240000, 0, true},
|
||||
} {
|
||||
if got := lengthFits(c.file, c.track); got != c.ok {
|
||||
t.Errorf("lengthFits(%d, %d) = %v", c.file, c.track, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlan_LidarrsNaming(t *testing.T) {
|
||||
e := edition("rel-cd", 2)
|
||||
e.Tracks[1].Title = "Within: Part 2/3?"
|
||||
|
||||
@@ -58,6 +58,27 @@ func TestSearchReleaseGroups_AsksTitleAndArtistAndReadsCredits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchReleaseGroupsBy_AsksForBothTitleAndArtist(t *testing.T) {
|
||||
var query atomic.Value
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
query.Store(r.URL.Query().Get("query"))
|
||||
_, _ = w.Write([]byte(`{"release-groups": [{"id": "rg1", "title": "Highway to Hell",
|
||||
"artist-credit": [{"name": "AC/DC", "artist": {"id": "a1"}}]}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := testClient(srv).SearchReleaseGroupsBy(context.Background(), " AC/DC ", "Highway to Hell", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q := query.Load(); q != `releasegroup:(Highway to Hell) AND artist:(AC\/DC)` {
|
||||
t.Errorf("query = %q", q)
|
||||
}
|
||||
if len(got) != 1 || got[0].MBID != "rg1" || got[0].ArtistMBIDs[0] != "a1" {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchRecordings_FilesASongUnderItsAlbum(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"recordings": [{
|
||||
|
||||
@@ -252,6 +252,25 @@ func (c *Client) SearchReleaseGroups(ctx context.Context, text string, limit int
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SearchReleaseGroupsBy finds an artist's albums by title: both must
|
||||
// match, where SearchReleaseGroups takes either. It is for naming an album
|
||||
// already in hand, not for a person's search box.
|
||||
func (c *Client) SearchReleaseGroupsBy(ctx context.Context, artist, title string, limit int) ([]ReleaseGroup, error) {
|
||||
var resp struct {
|
||||
ReleaseGroups []wireReleaseGroup `json:"release-groups"`
|
||||
}
|
||||
query := "releasegroup:(" + Escape(strings.TrimSpace(title)) + ") AND artist:(" + Escape(strings.TrimSpace(artist)) + ")"
|
||||
q := url.Values{"query": {query}, "limit": {searchLimit(limit)}}
|
||||
if err := c.get(ctx, "/release-group", q, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ReleaseGroup, 0, len(resp.ReleaseGroups))
|
||||
for _, g := range resp.ReleaseGroups {
|
||||
out = append(out, g.group())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SearchRecordings finds songs by title or by artist.
|
||||
func (c *Client) SearchRecordings(ctx context.Context, text string, limit int) ([]Recording, error) {
|
||||
var resp struct {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package release
|
||||
|
||||
import "slices"
|
||||
|
||||
// Comparing a file against a MusicBrainz track: shared by the importer's
|
||||
// check of a download and the library listing's match of an album (#5569).
|
||||
|
||||
// LengthFits allows five seconds or 3%, whichever is more: remasters and
|
||||
// rips differ by a second or two, a different recording by far more. A
|
||||
// track MusicBrainz has no length for says nothing against the file.
|
||||
func LengthFits(fileMs, trackMs int) bool {
|
||||
if trackMs <= 0 {
|
||||
return true
|
||||
}
|
||||
if fileMs <= 0 {
|
||||
return false
|
||||
}
|
||||
tol := max(5000, trackMs*3/100)
|
||||
d := fileMs - trackMs
|
||||
return d <= tol && -d <= tol
|
||||
}
|
||||
|
||||
// TitleAgrees: the same words, or one title holding all of the other's
|
||||
// ("Get Lucky (Radio Edit)" holds "Get Lucky").
|
||||
func TitleAgrees(a, b string) bool {
|
||||
ta, tb := Tokens(a), Tokens(b)
|
||||
if len(ta) == 0 || len(tb) == 0 {
|
||||
return false
|
||||
}
|
||||
return subset(ta, tb) || subset(tb, ta)
|
||||
}
|
||||
|
||||
func subset(small, big []string) bool {
|
||||
for _, w := range small {
|
||||
if !slices.Contains(big, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package release
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLengthFits(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
file, track int
|
||||
ok bool
|
||||
}{
|
||||
// Short tracks get the 5s floor; longer ones 3% of the track.
|
||||
{120000, 125000, true}, {120000, 126000, false},
|
||||
{240000, 246000, true}, {240000, 248000, false}, {600000, 615000, true}, {600000, 620000, false},
|
||||
{0, 240000, false}, {240000, 0, true},
|
||||
} {
|
||||
if got := LengthFits(c.file, c.track); got != c.ok {
|
||||
t.Errorf("LengthFits(%d, %d) = %v", c.file, c.track, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
-37
@@ -4,19 +4,24 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"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
|
||||
// libraryMatchBudget bounds the time one pass spends matching albums to
|
||||
// MusicBrainz, at its one request a second, so the pass still settles its
|
||||
// wants; the rest wait for the next pass.
|
||||
const libraryMatchBudget = 2 * time.Minute
|
||||
|
||||
// libraryMatchBatch is how many albums one query hands the matching.
|
||||
const libraryMatchBatch = 25
|
||||
|
||||
// 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
|
||||
@@ -26,13 +31,15 @@ const libraryNamesPerPass = 120
|
||||
// - 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.
|
||||
// - every album whose tags name its release group and artist goes on the
|
||||
// list at once;
|
||||
// - every other album is matched to MusicBrainz (#5569), within a time
|
||||
// budget a pass, and listed as it is matched;
|
||||
// - once none is left to match, the listing is stamped done, with a count
|
||||
// of the albums no match was found for.
|
||||
//
|
||||
// After that it never runs again, so an album taken off the list stays off.
|
||||
// Each album decided is recorded in library_album_matches, so an album taken
|
||||
// off the list stays off, and once done the listing never runs again.
|
||||
func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
|
||||
q := dbq.New(s.pool)
|
||||
state, err := q.GetLibraryListing(ctx)
|
||||
@@ -47,6 +54,9 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
|
||||
if cutoff, err = q.StartLibraryListing(ctx); err != nil {
|
||||
return fmt.Errorf("wanted: start the library listing: %w", err)
|
||||
}
|
||||
if err := q.RecordListedAlbums(ctx, cutoff); err != nil {
|
||||
return fmt.Errorf("wanted: record the library: %w", err)
|
||||
}
|
||||
n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wanted: list the library: %w", err)
|
||||
@@ -55,10 +65,11 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
|
||||
s.logger.Info("wanted: listing the library's albums as held", "listed", n)
|
||||
}
|
||||
|
||||
deadline := s.now().Add(libraryMatchBudget)
|
||||
for {
|
||||
albums, err := q.ListLibraryAlbumsToName(ctx, dbq.ListLibraryAlbumsToNameParams{Cutoff: cutoff, MaxRows: libraryNamesPerPass})
|
||||
albums, err := q.ListAlbumsToMatch(ctx, dbq.ListAlbumsToMatchParams{Cutoff: cutoff, MaxRows: libraryMatchBatch})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wanted: albums to name: %w", err)
|
||||
return fmt.Errorf("wanted: albums to match: %w", err)
|
||||
}
|
||||
if len(albums) == 0 {
|
||||
break
|
||||
@@ -67,45 +78,68 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
|
||||
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)
|
||||
if s.now().After(deadline) {
|
||||
return nil // the rest next pass
|
||||
}
|
||||
n, err := s.listAlbum(ctx, q, a)
|
||||
if errors.Is(err, errMusicBrainzAway) {
|
||||
s.logger.Info("wanted: matching 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)
|
||||
return err
|
||||
}
|
||||
res.Listed += int(n)
|
||||
}
|
||||
if len(albums) == libraryNamesPerPass {
|
||||
return nil // the rest next pass
|
||||
res.Listed += n
|
||||
}
|
||||
}
|
||||
return s.finishLibrary(ctx, q, res)
|
||||
}
|
||||
|
||||
// listAlbum matches one album and lists it, or records why it could not.
|
||||
func (s *Service) listAlbum(ctx context.Context, q *dbq.Queries, a dbq.ListAlbumsToMatchRow) (int, error) {
|
||||
rows, err := q.ListAlbumTracksToMatch(ctx, a.ID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("wanted: read %s's tracks: %w", a.Title, err)
|
||||
}
|
||||
m, err := s.matchAlbum(ctx, a, tracksToMatch(rows))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !m.OK {
|
||||
s.logger.Info("wanted: no MusicBrainz match for a library album", "album", a.Title, "artist", a.ArtistName, "why", m.Detail)
|
||||
return 0, q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{AlbumID: a.ID, Outcome: "unmatched", Detail: m.Detail})
|
||||
}
|
||||
var listed int64
|
||||
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
tq := dbq.New(tx)
|
||||
if err := tq.SetAlbumReleaseGroup(ctx, dbq.SetAlbumReleaseGroupParams{ID: a.ID, ReleaseGroupMbid: &m.Group}); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := tq.InsertLibraryWant(ctx, dbq.InsertLibraryWantParams{
|
||||
ReleaseGroupMbid: m.Group, ArtistMbid: m.ArtistMBID, ArtistName: m.ArtistName, Title: m.Title,
|
||||
PrimaryType: m.PrimaryType, FirstReleaseDate: m.FirstReleaseDate, AlbumID: a.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listed = n
|
||||
return tq.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
|
||||
AlbumID: a.ID, Outcome: "listed", Method: m.Method, ReleaseGroupMbid: m.Group,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("wanted: list album %s: %w", a.Title, err)
|
||||
}
|
||||
return int(listed), nil
|
||||
}
|
||||
|
||||
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)
|
||||
"unmatched", done.LibraryUnlisted)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package wanted
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// seedAlbum puts an album in the library whose tracks are titled titles,
|
||||
// numbered by numbers (from 1 when none are given). An artistMBID or
|
||||
// release of "" leaves that id out, as untagged files do.
|
||||
func seedAlbum(t *testing.T, q *dbq.Queries, artistMBID, title, rel string, titles []string, numbers ...int32) dbq.Album {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
ap := dbq.UpsertArtistParams{Name: "Blur", SortName: "Blur"}
|
||||
if artistMBID != "" {
|
||||
ap.Mbid = &artistMBID
|
||||
}
|
||||
artist, err := q.UpsertArtist(ctx, ap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID}
|
||||
if rel != "" {
|
||||
params.Mbid = &rel
|
||||
}
|
||||
album, err := q.UpsertAlbum(ctx, params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, name := range titles {
|
||||
num := int32(i + 1)
|
||||
if i < len(numbers) {
|
||||
num = numbers[i]
|
||||
}
|
||||
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||
Title: name, AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num, DurationMs: 180000,
|
||||
FilePath: fmt.Sprintf("/music/%s/%02d.flac", strings.ReplaceAll(title, " ", "_"), num), FileSize: 1, FileFormat: "flac",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return album
|
||||
}
|
||||
|
||||
func matchOf(t *testing.T, pool *pgxpool.Pool, album pgtype.UUID) (outcome, method, group, detail string) {
|
||||
t.Helper()
|
||||
if err := pool.QueryRow(context.Background(), `
|
||||
SELECT outcome, method, release_group_mbid, detail FROM library_album_matches WHERE album_id = $1`, album,
|
||||
).Scan(&outcome, &method, &group, &detail); err != nil {
|
||||
t.Fatalf("no match recorded: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The library's albums go on the list as held, once (#5567). Tags that name
|
||||
// the group list it at once; a release id is named through MusicBrainz;
|
||||
// what nothing matches is recorded with why. 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-ballad" {
|
||||
return theBallad, nil
|
||||
}
|
||||
return "", tags.ErrNotFound
|
||||
}
|
||||
blurAlbum := seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 3)
|
||||
ballad := seedAlbum(t, q, blur, "The Ballad of Darren", "rel-ballad", []string{"The Ballad", "St. Charles Square"})
|
||||
bootleg := seedAlbum(t, q, blur, "Bootleg", "rel-bootleg", []string{"Intro"})
|
||||
// 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)
|
||||
}
|
||||
// An album row with no tracks is not an album in the library.
|
||||
if _, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Empty", SortTitle: "Empty", ArtistID: blurAlbum.ArtistID}); 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, theBallad: ballad.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 || w.ArtistMbid != blur {
|
||||
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)
|
||||
}
|
||||
if outcome, method, _, _ := matchOf(t, pool, ballad.ID); outcome != "listed" || method != matchRelease {
|
||||
t.Errorf("ballad decided %s by %s", outcome, method)
|
||||
}
|
||||
if outcome, _, _, detail := matchOf(t, pool, bootleg.ID); outcome != "unmatched" || !strings.Contains(detail, `no album called "Bootleg"`) {
|
||||
t.Errorf("bootleg decided %s: %q", outcome, detail)
|
||||
}
|
||||
state, err := q.GetLibraryListing(ctx)
|
||||
if err != nil || !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
|
||||
t.Fatalf("listing = %+v %v: done, with only the bootleg unmatched", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An album whose tags name nothing is matched by its title among the
|
||||
// artist's release groups, and taken only when its tracks agree: a partial
|
||||
// copy agrees on the tracks it has. One whose title matches but whose
|
||||
// tracks do not is left unmatched, with why.
|
||||
func TestListLibrary_MatchesByDiscographyWhenTheTracksAgree_Integration(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
q := dbq.New(pool)
|
||||
s := newService(t, pool, newFakeMB(t))
|
||||
// MusicBrainz switched off for tags does not stop matching by name.
|
||||
s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrSwitchedOff }
|
||||
partial := seedAlbum(t, q, blur, "The Ballad of Darren (Deluxe)", "rel-ballad", []string{"St Charles Square"}, 2)
|
||||
wrong := seedAlbum(t, q, blur, "Parklife", "", []string{"Girls & Boys", "Tracy Jacks"})
|
||||
startListing(t, pool)
|
||||
|
||||
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if outcome, method, group, _ := matchOf(t, pool, partial.ID); outcome != "listed" || method != matchDiscography || group != theBallad {
|
||||
t.Errorf("partial copy decided %s by %s as %s", outcome, method, group)
|
||||
}
|
||||
var named *string
|
||||
if err := pool.QueryRow(ctx, `SELECT release_group_mbid FROM albums WHERE id = $1`, partial.ID).Scan(&named); err != nil || named == nil || *named != theBallad {
|
||||
t.Errorf("the album's release group = %v %v", named, err)
|
||||
}
|
||||
if outcome, _, _, detail := matchOf(t, pool, wrong.ID); outcome != "unmatched" || !strings.Contains(detail, "none has its tracks") {
|
||||
t.Errorf("Parklife without Parklife's tracks decided %s: %q", outcome, detail)
|
||||
}
|
||||
if _, err := q.GetWantByReleaseGroup(ctx, parklife); err == nil {
|
||||
t.Error("Parklife listed on its title alone")
|
||||
}
|
||||
}
|
||||
|
||||
// An artist the tags give no id is found by MusicBrainz's search on artist
|
||||
// and title, and the want is filed under the artist MusicBrainz credits.
|
||||
func TestListLibrary_MatchesBySearchWhenTheArtistHasNoID_Integration(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
q := dbq.New(pool)
|
||||
s := newService(t, pool, newFakeMB(t))
|
||||
album := seedAlbum(t, q, "", "The Ballad of Darren", "", []string{"The Ballad", "St. Charles Square"})
|
||||
startListing(t, pool)
|
||||
|
||||
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if outcome, method, _, _ := matchOf(t, pool, album.ID); outcome != "listed" || method != matchSearch {
|
||||
t.Errorf("decided %s by %s", outcome, method)
|
||||
}
|
||||
w, err := q.GetWantByReleaseGroup(ctx, theBallad)
|
||||
if err != nil || w.ArtistMbid != blur || w.Reason != ReasonLibrary || !w.FulfilledAt.Valid {
|
||||
t.Errorf("want = %+v %v", w, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A MusicBrainz that does not answer pauses the matching; the album is not
|
||||
// decided on half the evidence, and the next pass goes on.
|
||||
func TestListLibrary_UnreachablePausesTheMatching_Integration(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
q := dbq.New(pool)
|
||||
f := newFakeMB(t)
|
||||
s := newService(t, pool, f)
|
||||
album := seedAlbum(t, q, blur, "The Ballad of Darren", "", []string{"The Ballad", "St. Charles Square"})
|
||||
startListing(t, pool)
|
||||
|
||||
f.down.Store(true)
|
||||
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 matched")
|
||||
}
|
||||
var n int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_album_matches WHERE album_id = $1`, album.ID).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("decided while MusicBrainz was away: %d %v", n, err)
|
||||
}
|
||||
f.down.Store(false)
|
||||
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil {
|
||||
t.Errorf("not listed once MusicBrainz answered: %v", err)
|
||||
}
|
||||
if state, _ := q.GetLibraryListing(ctx); !state.LibraryListedAt.Valid {
|
||||
t.Error("not finished")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackAgreement(t *testing.T) {
|
||||
edition := []editionTrack{
|
||||
{Medium: 1, Position: 1, Title: "Intro", LengthMs: 60000, Recording: "rec-1"},
|
||||
{Medium: 1, Position: 2, Title: "Song", LengthMs: 200000},
|
||||
{Medium: 2, Position: 1, Title: "Encore", LengthMs: 300000},
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
tracks []libraryTrack
|
||||
want int
|
||||
}{
|
||||
{"all, by position", []libraryTrack{
|
||||
{Disc: 1, Number: 1, Title: "Intro", LengthMs: 60000}, {Number: 2, Title: "Song", LengthMs: 201000},
|
||||
{Disc: 2, Number: 1, Title: "Encore", LengthMs: 300000}}, 3},
|
||||
{"a partial copy", []libraryTrack{{Disc: 2, Number: 1, Title: "Encore", LengthMs: 300500}}, 1},
|
||||
{"unnumbered, by title", []libraryTrack{{Title: "Song", LengthMs: 200000}, {Title: "Encore", LengthMs: 299000}}, 2},
|
||||
{"the recording, whatever its title", []libraryTrack{{Number: 1, Title: "Untitled", LengthMs: 1, Recording: "REC-1"}}, 1},
|
||||
{"the right title at the wrong length", []libraryTrack{{Number: 2, Title: "Song", LengthMs: 400000}}, 0},
|
||||
{"a track counted once", []libraryTrack{{Title: "Song", LengthMs: 200000}, {Title: "Song", LengthMs: 200000}}, 1},
|
||||
} {
|
||||
if got := trackAgreement(c.tracks, edition); got != c.want {
|
||||
t.Errorf("%s: %d agree, want %d", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
if agrees(3, 4) || !agrees(4, 5) || agrees(0, 0) {
|
||||
t.Error("agrees: 80% of the album's tracks, and an album has tracks")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package wanted
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
)
|
||||
|
||||
// Matching a library album to its MusicBrainz release group (#5569).
|
||||
//
|
||||
// Everything in the library came from a service that matched it against
|
||||
// MusicBrainz (operator, 2026-10-10: "they should all exist in music brainz
|
||||
// we need to use their meta data or a search system to link them up"), so
|
||||
// every album should have a group. The ids its tags carry are taken as
|
||||
// they are. Without them, the groups called what the album is called, from
|
||||
// the artist's discography and then from a search, are checked against the
|
||||
// album's own tracks, and the one its tracks agree with is taken.
|
||||
|
||||
// Match methods, as library_album_matches records them.
|
||||
const (
|
||||
matchTags = "tags"
|
||||
matchRelease = "release"
|
||||
matchDiscography = "discography"
|
||||
matchSearch = "search"
|
||||
)
|
||||
|
||||
// matchAgreement is the share of an album's tracks, in tenths, an edition
|
||||
// must agree with: most, not all, since a rip's track may carry a typo or a
|
||||
// hidden track's length.
|
||||
const matchAgreement = 8
|
||||
|
||||
// matchCandidates bounds how many groups one album is checked against: each
|
||||
// costs a MusicBrainz read the first time.
|
||||
const matchCandidates = 8
|
||||
|
||||
// matchDiscographyAge is how old a held discography may be and still be
|
||||
// read for matching: a name does not change, and a new release is not in
|
||||
// the library.
|
||||
const matchDiscographyAge = 90 * 24 * time.Hour
|
||||
|
||||
// errMusicBrainzAway: MusicBrainz did not answer, so the album is left for
|
||||
// the next pass rather than decided on half the evidence.
|
||||
var errMusicBrainzAway = errors.New("wanted: MusicBrainz did not answer")
|
||||
|
||||
// libraryTrack is what one of an album's tracks says about itself.
|
||||
type libraryTrack struct {
|
||||
Disc, Number int
|
||||
Title string
|
||||
LengthMs int
|
||||
Recording string
|
||||
}
|
||||
|
||||
// editionTrack is one track of a MusicBrainz edition.
|
||||
type editionTrack struct {
|
||||
Medium, Position int
|
||||
Title string
|
||||
LengthMs int
|
||||
Recording string
|
||||
}
|
||||
|
||||
// candidate is a release group an album may be.
|
||||
type candidate struct {
|
||||
Group string
|
||||
Title string
|
||||
PrimaryType string
|
||||
FirstReleaseDate string
|
||||
ArtistMBID string
|
||||
ArtistName string
|
||||
Method string
|
||||
}
|
||||
|
||||
// albumMatch is what matching decided: a group, or why there is none.
|
||||
type albumMatch struct {
|
||||
candidate
|
||||
OK bool
|
||||
Detail string
|
||||
}
|
||||
|
||||
func tracksToMatch(rows []dbq.ListAlbumTracksToMatchRow) []libraryTrack {
|
||||
out := make([]libraryTrack, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
t := libraryTrack{Title: r.Title, LengthMs: int(r.DurationMs), Recording: deref(r.Mbid)}
|
||||
if r.DiscNumber != nil {
|
||||
t.Disc = int(*r.DiscNumber)
|
||||
}
|
||||
if r.TrackNumber != nil {
|
||||
t.Number = int(*r.TrackNumber)
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trackAgreement counts the album's tracks an edition agrees with: the same
|
||||
// recording, or a length that fits and a title that agrees. A track is
|
||||
// paired by its disc and number first, then by title, so an album missing
|
||||
// some tracks still pairs the ones it has.
|
||||
func trackAgreement(tracks []libraryTrack, edition []editionTrack) int {
|
||||
byPos := make(map[[2]int]int, len(edition))
|
||||
for i, e := range edition {
|
||||
byPos[[2]int{max(e.Medium, 1), e.Position}] = i
|
||||
}
|
||||
agree := func(t libraryTrack, e editionTrack) bool {
|
||||
if t.Recording != "" && strings.EqualFold(t.Recording, e.Recording) {
|
||||
return true
|
||||
}
|
||||
return release.LengthFits(t.LengthMs, e.LengthMs) && release.TitleAgrees(t.Title, e.Title)
|
||||
}
|
||||
used := make([]bool, len(edition))
|
||||
n := 0
|
||||
for _, t := range tracks {
|
||||
if t.Number > 0 {
|
||||
if i, ok := byPos[[2]int{max(t.Disc, 1), t.Number}]; ok && !used[i] && agree(t, edition[i]) {
|
||||
used[i] = true
|
||||
n++
|
||||
continue
|
||||
}
|
||||
}
|
||||
for i, e := range edition {
|
||||
if !used[i] && agree(t, e) {
|
||||
used[i] = true
|
||||
n++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// agrees: enough of the album's tracks agree for the edition to be it.
|
||||
func agrees(n, tracks int) bool {
|
||||
return tracks > 0 && n*10 >= tracks*matchAgreement
|
||||
}
|
||||
|
||||
// sameTitle: the same words, not merely one holding the other.
|
||||
func sameTitle(a, b string) bool {
|
||||
return slices.Equal(release.Tokens(a), release.Tokens(b))
|
||||
}
|
||||
|
||||
// matchAlbum finds the release group a library album is. A MusicBrainz
|
||||
// that does not answer is errMusicBrainzAway; anything else MusicBrainz
|
||||
// says is evidence, and an album with none is an albumMatch that is not OK.
|
||||
func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tracks []libraryTrack) (albumMatch, error) {
|
||||
// The ids the tags carry name it.
|
||||
if group := deref(a.ReleaseGroupMbid); group != "" {
|
||||
if c, err := s.groupCandidate(ctx, group, matchTags, a); err != nil || c.Group != "" {
|
||||
return albumMatch{candidate: c, OK: err == nil}, err
|
||||
}
|
||||
}
|
||||
if rel := deref(a.Mbid); rel != "" {
|
||||
group, err := s.releaseGroup(ctx, rel)
|
||||
switch {
|
||||
case errors.Is(err, tags.ErrNotFound): // includes MusicBrainz switched off for tags
|
||||
case err != nil:
|
||||
return albumMatch{}, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
||||
default:
|
||||
if c, err := s.groupCandidate(ctx, group, matchRelease, a); err != nil || c.Group != "" {
|
||||
return albumMatch{candidate: c, OK: err == nil}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise its name, checked against its tracks: the artist's own
|
||||
// discography first, then a search.
|
||||
tried := map[string]bool{}
|
||||
var best albumMatch
|
||||
bestN, bestEdition, considered := -1, 0, 0
|
||||
check := func(cands []candidate) error {
|
||||
for _, c := range cands {
|
||||
if tried[c.Group] || len(tried) >= matchCandidates {
|
||||
continue
|
||||
}
|
||||
tried[c.Group] = true
|
||||
n, size, err := s.agreementWith(ctx, c.Group, tracks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
considered++
|
||||
// More tracks agreeing wins; then the same title over a longer
|
||||
// one; then the edition nearest the album's length.
|
||||
st, bt := sameTitle(c.Title, a.Title), sameTitle(best.Title, a.Title)
|
||||
if n > bestN || (n == bestN && ((st && !bt) || (st == bt && abs(size-len(tracks)) < abs(bestEdition-len(tracks))))) {
|
||||
best, bestN, bestEdition = albumMatch{candidate: c}, n, size
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
discography, err := s.discographyCandidates(ctx, a)
|
||||
if err != nil {
|
||||
return albumMatch{}, err
|
||||
}
|
||||
if err := check(discography); err != nil {
|
||||
return albumMatch{}, err
|
||||
}
|
||||
if !agrees(bestN, len(tracks)) {
|
||||
found, err := s.searchCandidates(ctx, a)
|
||||
if err != nil {
|
||||
return albumMatch{}, err
|
||||
}
|
||||
if err := check(found); err != nil {
|
||||
return albumMatch{}, err
|
||||
}
|
||||
}
|
||||
if agrees(bestN, len(tracks)) {
|
||||
best.OK = true
|
||||
return best, nil
|
||||
}
|
||||
if considered == 0 {
|
||||
return albumMatch{Detail: fmt.Sprintf("MusicBrainz has no album called %q by %s.", a.Title, a.ArtistName)}, nil
|
||||
}
|
||||
return albumMatch{Detail: fmt.Sprintf(
|
||||
"MusicBrainz has %d %s called %q by %s, but none has its tracks: the closest, %q, agrees on %d of its %d.",
|
||||
considered, plural(considered, "album", "albums"), a.Title, a.ArtistName, best.Title, max(bestN, 0), len(tracks))}, nil
|
||||
}
|
||||
|
||||
// groupCandidate reads a group the tags name. A group MusicBrainz does not
|
||||
// have is a zero candidate, so the album is matched by name instead.
|
||||
func (s *Service) groupCandidate(ctx context.Context, group, method string, a dbq.ListAlbumsToMatchRow) (candidate, error) {
|
||||
g, err := s.cat.MusicBrainz().ReleaseGroup(ctx, group)
|
||||
switch {
|
||||
case errors.Is(err, musicbrainz.ErrNotFound):
|
||||
return candidate{}, nil
|
||||
case err != nil:
|
||||
return candidate{}, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
||||
}
|
||||
artist := creditedArtist(g.ArtistMBIDs, deref(a.ArtistMbid))
|
||||
if artist == "" {
|
||||
return candidate{}, nil
|
||||
}
|
||||
return candidate{Group: g.MBID, Title: g.Title, PrimaryType: g.PrimaryType, FirstReleaseDate: g.FirstReleaseDate,
|
||||
ArtistMBID: artist, ArtistName: g.ArtistCredit, Method: method}, nil
|
||||
}
|
||||
|
||||
// discographyCandidates are the artist's release groups called what the
|
||||
// album is called.
|
||||
func (s *Service) discographyCandidates(ctx context.Context, a dbq.ListAlbumsToMatchRow) ([]candidate, error) {
|
||||
artist := deref(a.ArtistMbid)
|
||||
if !Followable(artist, a.ArtistName) {
|
||||
return nil, nil // no artist, or Various Artists: thousands of groups
|
||||
}
|
||||
d, err := s.cat.DiscographyWithin(ctx, artist, matchDiscographyAge)
|
||||
switch {
|
||||
case errors.Is(err, catalogue.ErrNotFound):
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
||||
}
|
||||
var out []candidate
|
||||
for _, g := range d.Groups {
|
||||
if release.TitleAgrees(g.Title, a.Title) {
|
||||
out = append(out, candidate{Group: g.Mbid, Title: g.Title, PrimaryType: g.PrimaryType,
|
||||
FirstReleaseDate: g.FirstReleaseDate, ArtistMBID: artist, ArtistName: d.Artist.Name, Method: matchDiscography})
|
||||
}
|
||||
}
|
||||
sortSameTitleFirst(out, a.Title)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// searchCandidates are the groups MusicBrainz's search finds by the album's
|
||||
// artist and title, whose title and artist both agree.
|
||||
func (s *Service) searchCandidates(ctx context.Context, a dbq.ListAlbumsToMatchRow) ([]candidate, error) {
|
||||
found, err := s.cat.MusicBrainz().SearchReleaseGroupsBy(ctx, a.ArtistName, a.Title, 10)
|
||||
switch {
|
||||
case errors.Is(err, musicbrainz.ErrNotFound):
|
||||
return nil, nil
|
||||
case errors.Is(err, musicbrainz.ErrBadResponse):
|
||||
// A query MusicBrainz will not read is not a reason to wait.
|
||||
s.logger.Warn("wanted: MusicBrainz refused a search", "album", a.Title, "artist", a.ArtistName, "err", err)
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
||||
}
|
||||
var out []candidate
|
||||
for _, g := range found {
|
||||
artist := creditedArtist(g.ArtistMBIDs, deref(a.ArtistMbid))
|
||||
if artist == "" || !release.TitleAgrees(g.Title, a.Title) {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(g.ArtistMBIDs, deref(a.ArtistMbid)) && !release.TitleAgrees(g.ArtistCredit, a.ArtistName) {
|
||||
continue
|
||||
}
|
||||
out = append(out, candidate{Group: g.MBID, Title: g.Title, PrimaryType: g.PrimaryType,
|
||||
FirstReleaseDate: g.FirstReleaseDate, ArtistMBID: artist, ArtistName: g.ArtistCredit, Method: matchSearch})
|
||||
}
|
||||
sortSameTitleFirst(out, a.Title)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// agreementWith is how many of the album's tracks the group's best edition
|
||||
// agrees with, and that edition's length. A group MusicBrainz no longer has
|
||||
// agrees with none.
|
||||
func (s *Service) agreementWith(ctx context.Context, group string, tracks []libraryTrack) (n, size int, err error) {
|
||||
eds, err := s.cat.Releases(ctx, group)
|
||||
switch {
|
||||
case errors.Is(err, catalogue.ErrNotFound):
|
||||
return -1, 0, nil
|
||||
case err != nil:
|
||||
return 0, 0, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
||||
}
|
||||
n = -1
|
||||
for _, e := range eds.Editions {
|
||||
edition := make([]editionTrack, 0, len(e.Tracks))
|
||||
for _, t := range e.Tracks {
|
||||
edition = append(edition, editionTrack{Medium: int(t.MediumPosition), Position: int(t.Position),
|
||||
Title: t.Title, LengthMs: int(t.LengthMs), Recording: t.RecordingMbid})
|
||||
}
|
||||
if got := trackAgreement(tracks, edition); got > n || (got == n && abs(len(edition)-len(tracks)) < abs(size-len(tracks))) {
|
||||
n, size = got, len(edition)
|
||||
}
|
||||
}
|
||||
return n, size, nil
|
||||
}
|
||||
|
||||
// creditedArtist is the artist a want is filed under: the library's own
|
||||
// artist when MusicBrainz credits them, else the first credited.
|
||||
func creditedArtist(credited []string, own string) string {
|
||||
if own != "" && (len(credited) == 0 || slices.Contains(credited, own)) {
|
||||
return own
|
||||
}
|
||||
if len(credited) > 0 {
|
||||
return credited[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortSameTitleFirst(cs []candidate, title string) {
|
||||
slices.SortStableFunc(cs, func(x, y candidate) int {
|
||||
switch sx, sy := sameTitle(x.Title, title), sameTitle(y.Title, title); {
|
||||
case sx && !sy:
|
||||
return -1
|
||||
case sy && !sx:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func plural(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return one
|
||||
}
|
||||
return many
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -226,6 +227,13 @@ func newFakeMB(t *testing.T) *fakeMB {
|
||||
{"position": 3, "number": "3", "title": "Bonus", "recording": {"id": "rec-3"}}]}]}]}`)
|
||||
case r.URL.Path == "/release":
|
||||
_, _ = fmt.Fprint(w, `{"release-count": 0, "releases": []}`)
|
||||
case r.URL.Path == "/release-group" && strings.Contains(r.URL.Query().Get("query"), "releasegroup:"):
|
||||
// The library listing's search by artist and title (#5569).
|
||||
found := ""
|
||||
if strings.Contains(r.URL.Query().Get("query"), "Ballad") {
|
||||
found = group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21")
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"release-groups": [%s]}`, found)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
@@ -455,135 +463,3 @@ func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -815,7 +815,9 @@ export type ReleaseBlocksPage = { items: ReleaseBlock[]; limit: number; offset:
|
||||
// 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 UnmatchedAlbum = { album_id: string; title: string; artist: string; detail: string };
|
||||
|
||||
export type LibraryListing = { listed_at: string | null; unlisted: number; unmatched: UnmatchedAlbum[] };
|
||||
|
||||
export type WantsPage = Page<Want> & { counts: WantCounts; library: LibraryListing };
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
// 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
|
||||
// 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.
|
||||
// library's albums were put on the list as held once (#5567), each matched to
|
||||
// MusicBrainz (#5569); taking one off leaves its files and stops it being
|
||||
// fetched back if they go. The ones no match was found for are named here.
|
||||
|
||||
let { list }: { list: WantState } = $props();
|
||||
|
||||
@@ -26,6 +27,7 @@
|
||||
const library = $derived(wants.data?.pages[0]?.library);
|
||||
|
||||
let busy = $state<string | null>(null);
|
||||
let showUnmatched = $state(false);
|
||||
let searching = $state<string | null>(null);
|
||||
let open = $state<string | null>(null);
|
||||
|
||||
@@ -162,16 +164,46 @@
|
||||
{#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.
|
||||
Your library's albums are being added to this list as held. Albums whose tags don't name them
|
||||
are matched on MusicBrainz first, by their artist, title and tracks, 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>
|
||||
<div class="mb-3 rounded-lg border border-border bg-surface p-3 text-sm" data-testid="library-listing">
|
||||
<p class="text-text-primary">
|
||||
{library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
|
||||
{library.unlisted === 1 ? "isn't" : "aren't"} on this list: no MusicBrainz album matched
|
||||
{library.unlisted === 1 ? 'its' : 'their'} artist, title and tracks. Until
|
||||
{library.unlisted === 1 ? "it's" : "they're"} matched, {library.unlisted === 1 ? "it isn't" : "they aren't"}
|
||||
fetched back if {library.unlisted === 1 ? 'its' : 'their'} files go missing.
|
||||
</p>
|
||||
{#if library.unmatched.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 inline-flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary"
|
||||
aria-expanded={showUnmatched}
|
||||
onclick={() => (showUnmatched = !showUnmatched)}
|
||||
>
|
||||
<ChevronDown size={12} strokeWidth={2} class={showUnmatched ? 'rotate-180' : ''} />
|
||||
{showUnmatched ? 'Hide them' : 'Show them'}
|
||||
</button>
|
||||
{#if showUnmatched}
|
||||
<ul class="mt-2 space-y-2" data-testid="unmatched-albums">
|
||||
{#each library.unmatched as u (u.album_id)}
|
||||
<li>
|
||||
<a href="/albums/{u.album_id}" class="text-text-primary hover:underline">{u.title}</a>
|
||||
<span class="text-text-secondary">· {u.artist}</span>
|
||||
<div class="text-xs text-text-muted">{u.detail}</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if library.unmatched.length < library.unlisted}
|
||||
<p class="mt-2 text-xs text-text-muted">
|
||||
The first {library.unmatched.length} of {library.unlisted}.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { mockInfiniteQuery, mockQuery } from "../../../test-utils/query";
|
||||
import type {
|
||||
LibraryListing,
|
||||
Want,
|
||||
WantGrab,
|
||||
WantsPage,
|
||||
} from "#lib/api/types.js";
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { mockInfiniteQuery, mockQuery } from '../../../test-utils/query';
|
||||
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(),
|
||||
removeWant: vi.fn().mockResolvedValue(undefined),
|
||||
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(),
|
||||
grabCandidate: 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 { pushToast } from "#lib/stores/toast.svelte.js";
|
||||
import {
|
||||
createCandidatesQuery,
|
||||
createWantsQuery,
|
||||
removeWant,
|
||||
searchWant,
|
||||
} from "#lib/api/wanted.js";
|
||||
import type { WantState } from "#lib/api/types.js";
|
||||
import WantList from './WantList.svelte';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { createCandidatesQuery, createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
|
||||
import type { WantState } from '#lib/api/types.js';
|
||||
|
||||
const DAY = 24 * 3_600_000;
|
||||
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
|
||||
@@ -42,305 +28,203 @@ function want(id: string, extra: Partial<Want> = {}): Want {
|
||||
return {
|
||||
id,
|
||||
release_group_mbid: `rg-${id}`,
|
||||
title: "The Ballad of Darren",
|
||||
artist_mbid: "blur",
|
||||
artist_name: "Blur",
|
||||
artist_id: "art-1",
|
||||
primary_type: "Album",
|
||||
title: 'The Ballad of Darren',
|
||||
artist_mbid: 'blur',
|
||||
artist_name: 'Blur',
|
||||
artist_id: 'art-1',
|
||||
primary_type: 'Album',
|
||||
secondary_types: [],
|
||||
year: "2023",
|
||||
reason: "follow",
|
||||
year: '2023',
|
||||
reason: 'follow',
|
||||
wanted_at: ago(2 * DAY),
|
||||
held_at: null,
|
||||
image_url: "",
|
||||
...extra,
|
||||
image_url: '',
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function page(
|
||||
items: Want[],
|
||||
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0 },
|
||||
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] }
|
||||
): WantsPage {
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
counts: {
|
||||
wanted: 2,
|
||||
missing: 1,
|
||||
held: 4,
|
||||
following: 1,
|
||||
activity: 1,
|
||||
attention: 0,
|
||||
blocked: 0,
|
||||
},
|
||||
library,
|
||||
counts: { wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 },
|
||||
library
|
||||
};
|
||||
}
|
||||
|
||||
function grab(extra: Partial<WantGrab> = {}): WantGrab {
|
||||
return {
|
||||
id: "g1",
|
||||
name: "Blur - Blur (1997) [FLAC]",
|
||||
id: 'g1',
|
||||
name: 'Blur - Blur (1997) [FLAC]',
|
||||
grabbed_at: ago(60_000),
|
||||
origin: "schedule",
|
||||
quality: "lossless",
|
||||
origin: 'schedule',
|
||||
quality: 'lossless',
|
||||
live: true,
|
||||
end_reason: "",
|
||||
end_reason: '',
|
||||
progress: 0,
|
||||
import_state: "",
|
||||
import_reason: "",
|
||||
end_detail: "",
|
||||
import_state: '',
|
||||
import_reason: '',
|
||||
end_detail: '',
|
||||
imported_at: null,
|
||||
imported_files: 0,
|
||||
...extra,
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(
|
||||
items: Want[] = [
|
||||
want("w1"),
|
||||
want("w2", { reason: "request", requested_by: "sam", title: "Parklife" }),
|
||||
],
|
||||
state: WantState = "wanted",
|
||||
library?: LibraryListing,
|
||||
items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })],
|
||||
state: WantState = 'wanted',
|
||||
library?: LibraryListing
|
||||
) {
|
||||
vi.mocked(createWantsQuery).mockReturnValue(
|
||||
mockInfiniteQuery({
|
||||
pages: [page(items, library)],
|
||||
}) as unknown as ReturnType<typeof createWantsQuery>,
|
||||
mockInfiniteQuery({ pages: [page(items, library)] }) as unknown as ReturnType<typeof createWantsQuery>
|
||||
);
|
||||
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 });
|
||||
}
|
||||
|
||||
describe("WantList", () => {
|
||||
test("asks for its own state", () => {
|
||||
setup([], "missing");
|
||||
expect(createWantsQuery).toHaveBeenCalledWith("missing");
|
||||
describe('WantList', () => {
|
||||
test('asks for its own state', () => {
|
||||
setup([], 'missing');
|
||||
expect(createWantsQuery).toHaveBeenCalledWith('missing');
|
||||
expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a want says why it is wanted", () => {
|
||||
test('a want says why it is wanted', () => {
|
||||
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[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();
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /stop wanting parklife/i }),
|
||||
);
|
||||
await waitFor(() => expect(removeWant).toHaveBeenCalledWith("w2"));
|
||||
await fireEvent.click(screen.getByRole('button', { name: /stop wanting parklife/i }));
|
||||
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w2'));
|
||||
});
|
||||
|
||||
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", reason: "library" })],
|
||||
"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('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', reason: 'library' })], '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("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 says while the library is being listed', () => {
|
||||
setup([], 'held', { listed_at: null, unlisted: 0, unmatched: [] });
|
||||
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", {
|
||||
test('the held tab names the albums no MusicBrainz album matched, on request', async () => {
|
||||
setup([want('w1', { held_at: ago(DAY) })], 'held', {
|
||||
listed_at: ago(DAY),
|
||||
unlisted: 12,
|
||||
unmatched: [
|
||||
{ album_id: 'al-9', title: 'Demo Tape', artist: 'Blur', detail: 'MusicBrainz has no album called "Demo Tape" by Blur.' }
|
||||
]
|
||||
});
|
||||
expect(screen.getByTestId("library-listing").textContent).toMatch(
|
||||
/12 albums in your library\s+aren't on this list/i,
|
||||
);
|
||||
expect(screen.getByTestId('library-listing').textContent).toMatch(/12 albums in your library\s+aren't on this list/i);
|
||||
expect(screen.queryByTestId('unmatched-albums')).toBeNull();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /show them/i }));
|
||||
const list = screen.getByTestId('unmatched-albums');
|
||||
expect(list.textContent).toMatch(/Demo Tape/);
|
||||
expect(list.textContent).toMatch(/has no album called/);
|
||||
expect(screen.getByRole('link', { name: 'Demo Tape' }).getAttribute('href')).toBe('/albums/al-9');
|
||||
expect(screen.getByText(/the first 1 of 12/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an empty list explains what lands there", () => {
|
||||
test('an empty list explains what lands there', () => {
|
||||
setup([]);
|
||||
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([
|
||||
want("w1"),
|
||||
want("w2", {
|
||||
title: "Parklife",
|
||||
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('w1'),
|
||||
want('w2', {
|
||||
title: 'Parklife',
|
||||
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 }) })
|
||||
]);
|
||||
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[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);
|
||||
});
|
||||
|
||||
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([
|
||||
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(
|
||||
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.
|
||||
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([
|
||||
want("w1", {
|
||||
search: {
|
||||
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.",
|
||||
}),
|
||||
}),
|
||||
want('w1', {
|
||||
search: { 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([
|
||||
want("w1", {
|
||||
search: {
|
||||
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.",
|
||||
}),
|
||||
}),
|
||||
want('w1', {
|
||||
search: { 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(
|
||||
[
|
||||
want("w1", {
|
||||
want('w1', {
|
||||
held_at: ago(DAY),
|
||||
album_id: "al-1",
|
||||
grab: grab({
|
||||
live: false,
|
||||
end_reason: "imported",
|
||||
import_state: "imported",
|
||||
imported_at: ago(DAY),
|
||||
imported_files: 11,
|
||||
}),
|
||||
}),
|
||||
album_id: 'al-1',
|
||||
grab: grab({ live: false, end_reason: 'imported', import_state: 'imported', imported_at: ago(DAY), imported_files: 11 })
|
||||
})
|
||||
],
|
||||
"held",
|
||||
);
|
||||
expect(screen.getByTestId("want-arrived").textContent).toMatch(
|
||||
/imported 11 tracks .* from blur - blur/i,
|
||||
'held'
|
||||
);
|
||||
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();
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /search for parklife now/i }),
|
||||
);
|
||||
await waitFor(() => expect(searchWant).toHaveBeenCalledWith("w2"));
|
||||
await waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith(
|
||||
"Nothing found for Parklife.",
|
||||
"info",
|
||||
),
|
||||
);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /search for parklife now/i }));
|
||||
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();
|
||||
expect(screen.queryByTestId("want-candidates")).toBeNull();
|
||||
const toggle = screen.getByRole("button", {
|
||||
name: /releases found for parklife/i,
|
||||
});
|
||||
expect(screen.queryByTestId('want-candidates')).toBeNull();
|
||||
const toggle = screen.getByRole('button', { name: /releases found for parklife/i });
|
||||
await fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(screen.getByTestId("want-candidates")).toBeTruthy();
|
||||
expect(createCandidatesQuery).toHaveBeenCalledWith("w2");
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(screen.getByTestId('want-candidates')).toBeTruthy();
|
||||
expect(createCandidatesQuery).toHaveBeenCalledWith('w2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import { createRawSnippet } from "svelte";
|
||||
import { mockInfiniteQuery } from "../../test-utils/query";
|
||||
import { pageUrlModule } from "#test-utils/mocks/appState.js";
|
||||
import type { WantCounts, WantsPage } from "#lib/api/types.js";
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import { mockInfiniteQuery } from '../../test-utils/query';
|
||||
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||
import type { WantCounts, WantsPage } from '#lib/api/types.js';
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pageUrl: new URL("http://localhost/wanted"),
|
||||
}));
|
||||
vi.mock("$app/state", () => pageUrlModule(state));
|
||||
vi.mock("#lib/api/wanted.js", () => ({ createWantsQuery: vi.fn() }));
|
||||
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/wanted') }));
|
||||
vi.mock('$app/state', () => pageUrlModule(state));
|
||||
vi.mock('#lib/api/wanted.js', () => ({ createWantsQuery: vi.fn() }));
|
||||
|
||||
import WantedLayout from "./+layout.svelte";
|
||||
import { createWantsQuery } from "#lib/api/wanted.js";
|
||||
import WantedLayout from './+layout.svelte';
|
||||
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 children = createRawSnippet(() => ({
|
||||
render: () => `<p data-testid="tab-body">body</p>`,
|
||||
}));
|
||||
const counts: WantCounts = { 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());
|
||||
|
||||
function setup(c: WantCounts = counts, path = "/wanted") {
|
||||
function setup(c: WantCounts = counts, path = '/wanted') {
|
||||
state.pageUrl = new URL(`http://localhost${path}`);
|
||||
const page: WantsPage = {
|
||||
items: [],
|
||||
@@ -37,58 +25,44 @@ function setup(c: WantCounts = counts, path = "/wanted") {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
counts: c,
|
||||
library: { listed_at: null, unlisted: 0 },
|
||||
library: { listed_at: null, unlisted: 0, unmatched: [] }
|
||||
};
|
||||
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
|
||||
return render(WantedLayout, { children } as any);
|
||||
}
|
||||
|
||||
describe("/wanted layout", () => {
|
||||
test("tabs are links carrying their counts, the current one marked", () => {
|
||||
setup(counts, "/wanted/activity");
|
||||
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(),
|
||||
]);
|
||||
describe('/wanted layout', () => {
|
||||
test('tabs are links carrying their counts, the current one marked', () => {
|
||||
setup(counts, '/wanted/activity');
|
||||
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()]);
|
||||
expect(links).toEqual([
|
||||
["/wanted", "Wanted 5"],
|
||||
["/wanted/activity", "Activity 3"],
|
||||
["/wanted/missing", "Missing files 1"],
|
||||
["/wanted/held", "Held 9"],
|
||||
["/wanted/following", "Following 2"],
|
||||
["/wanted/blocked", "Blocked 4"],
|
||||
['/wanted', 'Wanted 5'],
|
||||
['/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();
|
||||
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();
|
||||
expect(screen.getByTestId("wanted-verdict").textContent).toMatch(
|
||||
/5 albums wanted, 3 downloading\. Nothing needs you\./,
|
||||
);
|
||||
expect(screen.queryByTestId("tab-attention")).toBeNull();
|
||||
expect(screen.getByTestId('wanted-verdict').textContent).toMatch(/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 });
|
||||
const verdict = screen.getByTestId("wanted-verdict");
|
||||
const verdict = screen.getByTestId('wanted-verdict');
|
||||
expect(verdict.textContent).toMatch(/2 downloads need you/);
|
||||
expect(
|
||||
screen.getByRole("link", { name: /see activity/i }).getAttribute("href"),
|
||||
).toBe("/wanted/activity");
|
||||
expect(screen.getByTestId("tab-attention").textContent).toBe("2");
|
||||
expect(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