feat: Collection › Unmatched lists the library albums no MusicBrainz album matched, each matched by hand from a search or a link, or left off (M502 #5579)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m34s
release / integration (push) Failing after 5m34s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m44s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Like Lidarr's Unmapped Files. A local band's album, which MusicBrainz will
never have, is left off: never matched again and not counted. A re-tagged
album is matched again on the next listing. The Collection's verdict now
says when library albums need matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 22:12:30 -04:00
co-authored by Claude Opus 5.5
parent 10be068dcc
commit a8786abace
23 changed files with 1224 additions and 135 deletions
+124 -16
View File
@@ -20,14 +20,29 @@ func (q *Queries) ClearDefaultReleaseTypeProfile(ctx context.Context) error {
return err
}
const countAlbumsToRematch = `-- name: CountAlbumsToRematch :one
SELECT COUNT(*) FROM library_album_matches
WHERE outcome = 'unmatched' AND matcher < $1::int
const countAlbumsToMatch = `-- name: CountAlbumsToMatch :one
SELECT COUNT(*)
FROM albums a
WHERE (a.created_at <= $1::timestamptz
OR EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.added_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
AND (m.outcome IN ('listed', 'dismissed')
OR (m.matcher >= $2::int
AND NOT EXISTS (SELECT 1 FROM tracks t
WHERE t.album_id = a.id AND t.updated_at > m.decided_at))))
`
// Refusals by an older matcher, which a finished listing goes back to.
func (q *Queries) CountAlbumsToRematch(ctx context.Context, matcher int32) (int64, error) {
row := q.db.QueryRow(ctx, countAlbumsToRematch, matcher)
type CountAlbumsToMatchParams struct {
Cutoff pgtype.Timestamptz
Matcher int32
}
// Whether a finished listing has albums to go back to: those
// ListAlbumsToMatch would hand it.
func (q *Queries) CountAlbumsToMatch(ctx context.Context, arg CountAlbumsToMatchParams) (int64, error) {
row := q.db.QueryRow(ctx, countAlbumsToMatch, arg.Cutoff, arg.Matcher)
var count int64
err := row.Scan(&count)
return count, err
@@ -38,6 +53,10 @@ SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::big
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
COUNT(DISTINCT artist_mbid)::bigint AS artists,
-- Library albums waiting to be matched by hand (#5579).
(SELECT COUNT(*) FROM library_album_matches m
WHERE m.outcome = 'unmatched'
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))::bigint AS unmatched,
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- waiting on a person, and releases set aside.
@@ -54,6 +73,7 @@ type CountWantsRow struct {
Missing int64
Held int64
Artists int64
Unmatched int64
Following int64
Activity int64
Attention int64
@@ -68,6 +88,7 @@ func (q *Queries) CountWants(ctx context.Context) (CountWantsRow, error) {
&i.Missing,
&i.Held,
&i.Artists,
&i.Unmatched,
&i.Following,
&i.Activity,
&i.Attention,
@@ -143,7 +164,9 @@ func (q *Queries) DeleteWant(ctx context.Context, id pgtype.UUID) (int64, error)
const finishLibraryListing = `-- name: FinishLibraryListing :one
UPDATE acquisition_settings
SET library_listed_at = COALESCE(library_listed_at, now()),
library_unlisted = (SELECT COUNT(*) FROM library_album_matches WHERE outcome = 'unmatched')
library_unlisted = (SELECT COUNT(*) FROM library_album_matches m
WHERE m.outcome = 'unmatched'
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))
WHERE id = true
RETURNING library_listed_at, library_unlisted
`
@@ -180,6 +203,38 @@ func (q *Queries) FulfillWant(ctx context.Context, arg FulfillWantParams) error
return err
}
const getAlbumToMatch = `-- name: GetAlbumToMatch :one
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
JOIN library_album_matches m ON m.album_id = a.id AND m.outcome IN ('unmatched', 'dismissed')
WHERE a.id = $1
`
type GetAlbumToMatchRow struct {
ID pgtype.UUID
Title string
Mbid *string
ReleaseGroupMbid *string
ArtistName string
ArtistMbid *string
}
// One album a person is matching by hand, as the matcher reads albums.
func (q *Queries) GetAlbumToMatch(ctx context.Context, id pgtype.UUID) (GetAlbumToMatchRow, error) {
row := q.db.QueryRow(ctx, getAlbumToMatch, id)
var i GetAlbumToMatchRow
err := row.Scan(
&i.ID,
&i.Title,
&i.Mbid,
&i.ReleaseGroupMbid,
&i.ArtistName,
&i.ArtistMbid,
)
return i, err
}
const getDefaultReleaseTypeProfile = `-- name: GetDefaultReleaseTypeProfile :one
SELECT id, name, primary_types, secondary_types, is_default, created_at, updated_at FROM release_type_profiles WHERE is_default
`
@@ -438,10 +493,15 @@ 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
WHERE (a.created_at <= $1::timestamptz
OR EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.added_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 AND (m.outcome = 'listed' OR m.matcher >= $2::int))
WHERE m.album_id = a.id
AND (m.outcome IN ('listed', 'dismissed')
OR (m.matcher >= $2::int
AND NOT EXISTS (SELECT 1 FROM tracks t
WHERE t.album_id = a.id AND t.updated_at > m.decided_at))))
ORDER BY a.created_at, a.id
LIMIT $3
`
@@ -461,9 +521,12 @@ type ListAlbumsToMatchRow struct {
ArtistMbid *string
}
// Library albums from before the cutoff that the listing has not decided,
// or that an older matcher refused: each is matched to MusicBrainz (#5569).
// An album row with no tracks is not an album in the library.
// Library albums the listing has not decided, or that an older matcher
// refused, or whose tracks changed since they were refused: each is matched
// to MusicBrainz (#5569). A library album is one from before the cutoff, or
// one whose tracks were: a re-tag files a track under a new album row but
// keeps the track (#5579). An album row with no tracks is not an album in
// the library; one a person left off stays off.
func (q *Queries) ListAlbumsToMatch(ctx context.Context, arg ListAlbumsToMatchParams) ([]ListAlbumsToMatchRow, error) {
rows, err := q.db.Query(ctx, listAlbumsToMatch, arg.Cutoff, arg.Matcher, arg.MaxRows)
if err != nil {
@@ -813,12 +876,14 @@ func (q *Queries) ListReleaseTypeProfiles(ctx context.Context) ([]ListReleaseTyp
}
const listUnmatchedLibraryAlbums = `-- name: ListUnmatchedLibraryAlbums :many
SELECT m.album_id, a.title, ar.name AS artist_name, m.detail
SELECT m.album_id, a.title, ar.name AS artist_name, m.detail, m.outcome, m.decided_at,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)::int AS tracks
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
WHERE m.outcome IN ('unmatched', 'dismissed')
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id)
ORDER BY m.outcome DESC, lower(ar.name), lower(a.title)
LIMIT $1
`
@@ -827,9 +892,14 @@ type ListUnmatchedLibraryAlbumsRow struct {
Title string
ArtistName string
Detail string
Outcome string
DecidedAt pgtype.Timestamptz
Tracks int32
}
// The albums the listing could not match, for the Held tab.
// The Collection's Unmatched section (#5579): the albums the listing could
// not match, and below them the ones a person left off. An album whose
// tracks have all moved to another row is gone, not unmatched.
func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32) ([]ListUnmatchedLibraryAlbumsRow, error) {
rows, err := q.db.Query(ctx, listUnmatchedLibraryAlbums, maxRows)
if err != nil {
@@ -844,6 +914,9 @@ func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32)
&i.Title,
&i.ArtistName,
&i.Detail,
&i.Outcome,
&i.DecidedAt,
&i.Tracks,
); err != nil {
return nil, err
}
@@ -1179,6 +1252,21 @@ func (q *Queries) RecordListedAlbums(ctx context.Context, cutoff pgtype.Timestam
return err
}
const recountLibraryUnlisted = `-- name: RecountLibraryUnlisted :exec
UPDATE acquisition_settings
SET library_unlisted = (SELECT COUNT(*) FROM library_album_matches m
WHERE m.outcome = 'unmatched'
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))
WHERE id = true
`
// After a person matches or leaves off an album, the count the catalogue
// shows.
func (q *Queries) RecountLibraryUnlisted(ctx context.Context) error {
_, err := q.db.Exec(ctx, recountLibraryUnlisted)
return err
}
const repointWant = `-- name: RepointWant :exec
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1
`
@@ -1194,6 +1282,26 @@ func (q *Queries) RepointWant(ctx context.Context, arg RepointWantParams) error
return err
}
const setAlbumMatchOutcome = `-- name: SetAlbumMatchOutcome :execrows
UPDATE library_album_matches SET outcome = $1, decided_at = now()
WHERE album_id = $2 AND outcome = $3
`
type SetAlbumMatchOutcomeParams struct {
Outcome string
AlbumID pgtype.UUID
Was string
}
// Leave an unmatched album off the list, or put a left-off one back.
func (q *Queries) SetAlbumMatchOutcome(ctx context.Context, arg SetAlbumMatchOutcomeParams) (int64, error) {
result, err := q.db.Exec(ctx, setAlbumMatchOutcome, arg.Outcome, arg.AlbumID, arg.Was)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const setAlbumReleaseGroup = `-- name: SetAlbumReleaseGroup :exec
UPDATE albums SET release_group_mbid = $1, updated_at = now()
WHERE id = $2 AND (release_group_mbid IS NULL OR release_group_mbid = '')
@@ -0,0 +1,8 @@
UPDATE library_album_matches SET outcome = 'unmatched' WHERE outcome = 'dismissed';
UPDATE library_album_matches SET method = 'search' WHERE method = 'manual';
ALTER TABLE library_album_matches DROP CONSTRAINT library_album_matches_outcome_check;
ALTER TABLE library_album_matches ADD CONSTRAINT library_album_matches_outcome_check
CHECK (outcome IN ('listed', 'unmatched'));
ALTER TABLE library_album_matches DROP CONSTRAINT library_album_matches_method_check;
ALTER TABLE library_album_matches ADD CONSTRAINT library_album_matches_method_check
CHECK (method IN ('', 'tags', 'release', 'discography', 'search'));
@@ -0,0 +1,10 @@
-- The Collection's Unmatched section (#5579): a person matches an album the
-- listing could not, by a MusicBrainz link or a search (method 'manual'),
-- or leaves it off the list for good (outcome 'dismissed': a local band's
-- album MusicBrainz will never have, a mix that is no album).
ALTER TABLE library_album_matches DROP CONSTRAINT library_album_matches_outcome_check;
ALTER TABLE library_album_matches ADD CONSTRAINT library_album_matches_outcome_check
CHECK (outcome IN ('listed', 'unmatched', 'dismissed'));
ALTER TABLE library_album_matches DROP CONSTRAINT library_album_matches_method_check;
ALTER TABLE library_album_matches ADD CONSTRAINT library_album_matches_method_check
CHECK (method IN ('', 'tags', 'release', 'discography', 'search', 'manual'));
+65 -14
View File
@@ -271,6 +271,10 @@ SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::big
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
COUNT(DISTINCT artist_mbid)::bigint AS artists,
-- Library albums waiting to be matched by hand (#5579).
(SELECT COUNT(*) FROM library_album_matches m
WHERE m.outcome = 'unmatched'
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))::bigint AS unmatched,
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- waiting on a person, and releases set aside.
@@ -330,23 +334,41 @@ SELECT a.id, 'listed', 'tags', a.release_group_mbid
ON CONFLICT (album_id) DO NOTHING;
-- name: ListAlbumsToMatch :many
-- Library albums from before the cutoff that the listing has not decided,
-- or that an older matcher refused: each is matched to MusicBrainz (#5569).
-- An album row with no tracks is not an album in the library.
-- Library albums the listing has not decided, or that an older matcher
-- refused, or whose tracks changed since they were refused: each is matched
-- to MusicBrainz (#5569). A library album is one from before the cutoff, or
-- one whose tracks were: a re-tag files a track under a new album row but
-- keeps the track (#5579). An album row with no tracks is not an album in
-- the library; one a person left off stays off.
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
WHERE (a.created_at <= sqlc.arg(cutoff)::timestamptz
OR EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.added_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 AND (m.outcome = 'listed' OR m.matcher >= sqlc.arg(matcher)::int))
WHERE m.album_id = a.id
AND (m.outcome IN ('listed', 'dismissed')
OR (m.matcher >= sqlc.arg(matcher)::int
AND NOT EXISTS (SELECT 1 FROM tracks t
WHERE t.album_id = a.id AND t.updated_at > m.decided_at))))
ORDER BY a.created_at, a.id
LIMIT sqlc.arg(max_rows);
-- name: CountAlbumsToRematch :one
-- Refusals by an older matcher, which a finished listing goes back to.
SELECT COUNT(*) FROM library_album_matches
WHERE outcome = 'unmatched' AND matcher < sqlc.arg(matcher)::int;
-- name: CountAlbumsToMatch :one
-- Whether a finished listing has albums to go back to: those
-- ListAlbumsToMatch would hand it.
SELECT COUNT(*)
FROM albums a
WHERE (a.created_at <= sqlc.arg(cutoff)::timestamptz
OR EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.added_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
AND (m.outcome IN ('listed', 'dismissed')
OR (m.matcher >= sqlc.arg(matcher)::int
AND NOT EXISTS (SELECT 1 FROM tracks t
WHERE t.album_id = a.id AND t.updated_at > m.decided_at))));
-- name: ListAlbumTracksToMatch :many
-- What an album's tracks say about themselves, to check a match against.
@@ -384,16 +406,45 @@ ON CONFLICT (album_id) DO UPDATE
-- count the albums no match was found for. The first finish is kept.
UPDATE acquisition_settings
SET library_listed_at = COALESCE(library_listed_at, now()),
library_unlisted = (SELECT COUNT(*) FROM library_album_matches WHERE outcome = 'unmatched')
library_unlisted = (SELECT COUNT(*) FROM library_album_matches m
WHERE m.outcome = 'unmatched'
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))
WHERE id = true
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
-- The Collection's Unmatched section (#5579): the albums the listing could
-- not match, and below them the ones a person left off. An album whose
-- tracks have all moved to another row is gone, not unmatched.
SELECT m.album_id, a.title, ar.name AS artist_name, m.detail, m.outcome, m.decided_at,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)::int AS tracks
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
WHERE m.outcome IN ('unmatched', 'dismissed')
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id)
ORDER BY m.outcome DESC, lower(ar.name), lower(a.title)
LIMIT sqlc.arg(max_rows);
-- name: RecountLibraryUnlisted :exec
-- After a person matches or leaves off an album, the count the catalogue
-- shows.
UPDATE acquisition_settings
SET library_unlisted = (SELECT COUNT(*) FROM library_album_matches m
WHERE m.outcome = 'unmatched'
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))
WHERE id = true;
-- name: GetAlbumToMatch :one
-- One album a person is matching by hand, as the matcher reads albums.
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
JOIN library_album_matches m ON m.album_id = a.id AND m.outcome IN ('unmatched', 'dismissed')
WHERE a.id = $1;
-- name: SetAlbumMatchOutcome :execrows
-- Leave an unmatched album off the list, or put a left-off one back.
UPDATE library_album_matches SET outcome = sqlc.arg(outcome), decided_at = now()
WHERE album_id = sqlc.arg(album_id) AND outcome = sqlc.arg(was);