DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156

Merged
bvandeusen merged 117 commits from dev into main 2026-10-26 12:32:33 -04:00
21 changed files with 1378 additions and 601 deletions
Showing only changes of commit b887ab759b - Show all commits
+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');
});
});