fix: every library album is matched to MusicBrainz before it is listed, by its tags, its artist's discography or a search, and checked against its tracks (M502 #5569)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m44s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped

The one-time listing took only albums whose tags already named their
release group and artist, and left 340 off the list. The rest are now
matched: the release their tags name, else the artist's release groups
called what the album is called, else a MusicBrainz search by artist and
title. A group found by name is taken only when its tracks agree with the
album's (by disc and number, then title; length and title, or recording),
so a partial copy still matches and a same-titled other album does not.

library_album_matches records each album the listing decided, listed or
unmatched with why, so an album taken off the list is never re-listed. The
albums no match was found for are named on the Held tab. Album rows with
no tracks are no longer counted.

The length and title checks move from the importer to release, shared by
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 17:16:05 -04:00
co-authored by Claude Opus 5.5
parent 2c677b3184
commit b887ab759b
21 changed files with 1378 additions and 601 deletions
+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}