fix: every library album is matched to MusicBrainz before it is listed, by its tags, its artist's discography or a search, and checked against its tracks (M502 #5569)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m44s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m44s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The one-time listing took only albums whose tags already named their release group and artist, and left 340 off the list. The rest are now matched: the release their tags name, else the artist's release groups called what the album is called, else a MusicBrainz search by artist and title. A group found by name is taken only when its tracks agree with the album's (by disc and number, then title; length and title, or recording), so a partial copy still matches and a same-titled other album does not. library_album_matches records each album the listing decided, listed or unmatched with why, so an album taken off the list is never re-listed. The albums no match was found for are named on the Held tab. Album rows with no tracks are no longer counted. The length and title checks move from the importer to release, shared by both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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