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
+9 -1
View File
@@ -21,8 +21,14 @@
}
const parts = [counts.wanted === 1 ? '1 album wanted' : `${counts.wanted} albums wanted`];
if (counts.activity) parts.push(`${counts.activity} downloading`);
// Library albums no MusicBrainz album matched wait on a person too (#5579).
if (counts.unmatched) {
const n = counts.unmatched === 1 ? '1 album in your library needs' : `${counts.unmatched} albums in your library need`;
return parts.join(', ') + `. ${n} matching.`;
}
return parts.join(', ') + '. Nothing needs you.';
});
const needsYou = $derived(!!counts?.attention || !!counts?.unmatched);
</script>
<div class="space-y-4">
@@ -36,10 +42,12 @@
<a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
</p>
{#if verdict}
<p class="text-sm {counts?.attention ? 'text-text-primary' : 'text-text-secondary'}" data-testid="collection-verdict">
<p class="text-sm {needsYou ? 'text-text-primary' : 'text-text-secondary'}" data-testid="collection-verdict">
{verdict}
{#if counts?.attention}
<a href="/collection/activity" class="ml-1 text-accent-fg hover:underline">See Activity</a>
{:else if counts?.unmatched}
<a href="/collection/unmatched" class="ml-1 text-accent-fg hover:underline">See Unmatched</a>
{/if}
</p>
{/if}
+13 -4
View File
@@ -12,7 +12,7 @@ vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
import WantedLayout from './+layout.svelte';
import { createWantArtistsQuery } from '#lib/api/wanted.js';
const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4 };
const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4, unmatched: 3 };
const children = createRawSnippet(() => ({ render: () => `<p data-testid="tab-body">body</p>` }));
afterEach(() => vi.clearAllMocks());
@@ -25,7 +25,7 @@ function setup(c: WantCounts = counts, path = '/collection') {
limit: 50,
offset: 0,
counts: c,
library: { listed_at: null, unlisted: 0, unmatched: [] }
library: { listed_at: null, unlisted: 0 }
};
vi.mocked(createWantArtistsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<typeof createWantArtistsQuery>
@@ -45,7 +45,8 @@ describe('/collection layout', () => {
['/collection/missing', 'Missing files 1'],
['/collection/activity', 'Activity 3'],
['/collection/following', 'Following 2'],
['/collection/blocked', 'Blocked 4']
['/collection/blocked', 'Blocked 4'],
['/collection/unmatched', 'Unmatched 3']
]);
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('link', { name: /^catalogue/i })).not.toHaveAttribute('aria-current');
@@ -53,7 +54,7 @@ describe('/collection layout', () => {
});
test('when nothing needs a person the verdict says so', () => {
setup();
setup({ ...counts, unmatched: 0 });
expect(screen.getByTestId('collection-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
expect(screen.queryByTestId('tab-attention')).toBeNull();
});
@@ -66,6 +67,14 @@ describe('/collection layout', () => {
expect(screen.getByTestId('tab-attention').textContent).toBe('2');
});
test('library albums waiting to be matched need a person, and link to Unmatched', () => {
setup();
expect(screen.getByTestId('collection-verdict').textContent).toMatch(
/5 albums wanted, 3 downloading\. 3 albums in your library need matching\./
);
expect(screen.getByRole('link', { name: /see unmatched/i }).getAttribute('href')).toBe('/collection/unmatched');
});
test("an artist's page sits under Catalogue", () => {
setup(counts, '/collection/artist/blur');
expect(screen.getByRole('link', { name: /^catalogue/i })).toHaveAttribute('aria-current', 'page');
@@ -0,0 +1,8 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import UnmatchedList from '#lib/components/wanted/UnmatchedList.svelte';
</script>
<svelte:head><title>{pageTitle('Collection · Unmatched')}</title></svelte:head>
<UnmatchedList />