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

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:
2026-10-10 17:16:05 -04:00
co-authored by Claude Opus 5.5
parent 2c677b3184
commit b887ab759b
21 changed files with 1378 additions and 601 deletions
+28 -4
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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;
+64 -18
View File
@@ -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);
+1
View File
@@ -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
+2 -36
View File
@@ -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 {
-16
View File
@@ -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?"
+21
View File
@@ -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": [{
+19
View File
@@ -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 {
+40
View File
@@ -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
}
+19
View File
@@ -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
View File
@@ -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
}
+262
View File
@@ -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")
}
}
+359
View File
@@ -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
}
+8 -132
View File
@@ -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")
}
}
+3 -1
View File
@@ -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 };
+42 -10
View File
@@ -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}
+114 -230
View File
@@ -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');
});
});
+37 -63
View File
@@ -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');
});
});