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
+2
View File
@@ -45,6 +45,8 @@ export const qk = {
['adminWants', { state, q, artist }] as const,
wantArtists: (q: string = '') => ['adminWants', 'artists', { q }] as const,
wantTracks: (id: string) => ['wantTracks', id] as const,
// The Collection's Unmatched section (#5579).
unmatched: () => ['adminWants', 'unmatched'] as const,
adminFollows: () => ['adminFollows'] as const,
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
releaseProfiles: () => ['releaseProfiles'] as const,
+19 -2
View File
@@ -733,6 +733,9 @@ export type WantCounts = {
activity: number;
attention: number;
blocked: number;
// Library albums no MusicBrainz album matched, waiting to be matched by
// hand or left off (#5579).
unmatched: number;
};
// What an import's check counted (#5522).
@@ -818,9 +821,23 @@ 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 UnmatchedAlbum = { album_id: string; title: string; artist: string; detail: string };
// One album in the Collection's Unmatched section (#5579): why no
// MusicBrainz album matched it, or that a person left it off.
export type UnmatchedAlbum = {
album_id: string;
title: string;
artist: string;
detail: string;
tracks: number;
left_off: boolean;
decided_at: string;
};
export type LibraryListing = { listed_at: string | null; unlisted: number; unmatched: UnmatchedAlbum[] };
// What matching an album by hand did: the MusicBrainz album it is now, and
// how many of its tracks agree with that album's closest edition.
export type ManualMatch = { release_group_mbid: string; title: string; artist: string; agree: number; tracks: number };
export type LibraryListing = { listed_at: string | null; unlisted: number };
// artist heads the list when it is narrowed to one artist's page.
export type WantsPage = Page<Want> & { counts: WantCounts; library: LibraryListing; artist?: WantArtist };
+33
View File
@@ -11,8 +11,10 @@ import type {
ReleaseProfileInput,
ReleaseProfilesResponse,
GrabResult,
ManualMatch,
SearchResult,
SearchSettings,
UnmatchedAlbum,
WantArtistsPage,
WantState,
WantTracks,
@@ -92,6 +94,37 @@ export function createWantTracksQuery(id: string) {
});
}
// The Collection's Unmatched section (#5579): library albums no MusicBrainz
// album matched, and after them the ones a person left off.
export async function listUnmatched(): Promise<UnmatchedAlbum[]> {
const out = await api.get<{ items: UnmatchedAlbum[] }>('/api/admin/wanted/unmatched');
return out.items;
}
export function createUnmatchedQuery() {
return createQuery({
queryKey: qk.unmatched(),
queryFn: listUnmatched,
staleTime: 30_000
});
}
// Puts an unmatched album on the list as held, as the MusicBrainz album ref
// names: a release group's or a release's link, or a bare id.
export async function matchUnmatched(albumId: string, ref: string): Promise<ManualMatch> {
return api.post<ManualMatch>(`/api/admin/wanted/unmatched/${albumId}/match`, { ref });
}
// Leaves an album off for good (a local band's, which MusicBrainz will
// never have), or puts one left off back.
export async function leaveUnmatchedOff(albumId: string): Promise<void> {
await api.post<void>(`/api/admin/wanted/unmatched/${albumId}/leave-off`, {});
}
export async function putUnmatchedBack(albumId: string): Promise<void> {
await api.post<void>(`/api/admin/wanted/unmatched/${albumId}/put-back`, {});
}
export async function removeWant(id: string): Promise<void> {
await api.del(`/api/admin/wanted/${id}`);
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { ChevronDown, ListChecks } from 'lucide-svelte';
import { ListChecks } from 'lucide-svelte';
import { createWantArtistsQuery } from '#lib/api/wanted.js';
import AlphabeticalGrid from '#lib/components/AlphabeticalGrid.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte';
@@ -12,7 +12,7 @@
// artist on the list A–Z, each card showing how many of their albums are
// held. An artist opens on their albums. The library's albums were put on
// the list as held once (#5567), each matched to MusicBrainz (#5569); the
// ones no match was found for are named here.
// ones no match was found for are listed under Unmatched (#5579).
let filter = $state('');
const q = $derived(filter.trim());
@@ -20,8 +20,6 @@
const artists = $derived($artistsStore);
const items = $derived((artists.data?.pages ?? []).flatMap((p) => p.items) as WantArtist[]);
const library = $derived(artists.data?.pages[0]?.library);
let showUnmatched = $state(false);
</script>
{#if library && !library.listed_at}
@@ -29,41 +27,6 @@
Your library's albums are being added to the catalogue. 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 && library.unlisted > 0}
<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"} in the catalogue: 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 !artists.isPending && (items.length > 0 || q)}
@@ -32,14 +32,14 @@ function setup(
artist('blur', 'Blur', { following: true, downloading: 1 }),
artist('beatles', 'The Beatles', { sort_name: 'Beatles, The', albums: 13, held: 13, wanted: 0 })
],
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] }
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0 }
) {
const page: WantArtistsPage = {
items,
total: items.length,
limit: 96,
offset: 0,
counts: { artists: items.length, wanted: 1, missing: 0, held: 16, following: 1, activity: 1, attention: 0, blocked: 0 },
counts: { artists: items.length, wanted: 1, missing: 0, held: 16, following: 1, activity: 1, attention: 0, blocked: 0, unmatched: 0 },
library
};
vi.mocked(createWantArtistsQuery).mockReturnValue(
@@ -80,24 +80,7 @@ describe('CatalogueGrid', () => {
});
test('says while the library is being listed', () => {
setup([], { listed_at: null, unlisted: 0, unmatched: [] });
setup([], { listed_at: null, unlisted: 0 });
expect(screen.getByTestId('library-listing').textContent).toMatch(/being added to the catalogue/i);
});
test('names the albums no MusicBrainz album matched, on request', async () => {
setup(undefined, {
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 in the catalogue/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(screen.getByRole('link', { name: 'Demo Tape' }).getAttribute('href')).toBe('/albums/al-9');
expect(screen.getByText(/the first 1 of 12/i)).toBeTruthy();
});
});
@@ -5,7 +5,9 @@
// The Collection's sections, down the left like Lidarr's (#5572): the
// catalogue of artists first, then the sections that show state. On a
// narrow screen the rail runs across the top. Activity carries a second,
// accented count when downloads are waiting on a person.
// accented count when downloads are waiting on a person. Unmatched, like
// Lidarr's Unmapped Files, holds the library albums no MusicBrainz album
// matched (#5579).
let { counts }: { counts?: WantCounts } = $props();
@@ -17,7 +19,8 @@
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
{ href: '/collection/following', label: 'Following', count: counts?.following },
{ href: '/collection/blocked', label: 'Blocked', count: counts?.blocked }
{ href: '/collection/blocked', label: 'Blocked', count: counts?.blocked },
{ href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched }
]);
// An artist's page belongs to the catalogue.
@@ -0,0 +1,150 @@
<script lang="ts">
import { CircleCheck } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createUnmatchedQuery, leaveUnmatchedOff, matchUnmatched, putUnmatchedBack } from '#lib/api/wanted.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import UnmatchedMatch from './UnmatchedMatch.svelte';
import type { UnmatchedAlbum } from '#lib/api/types.js';
// The Collection's Unmatched section (#5579), like Lidarr's Unmapped
// Files: the library albums no MusicBrainz album matched by artist, title
// and tracks. Each can be matched by hand, to a search result or a pasted
// link, and goes on the list as held; or left off, as a local band's album
// MusicBrainz will never have. Until then an album isn't fetched back if
// its files go missing.
const client = useQueryClient();
const unmatchedStore = createUnmatchedQuery();
const unmatched = $derived($unmatchedStore);
const all = $derived((unmatched.data ?? []) as UnmatchedAlbum[]);
const toMatch = $derived(all.filter((u) => !u.left_off));
const leftOff = $derived(all.filter((u) => u.left_off));
let open = $state<string | null>(null);
let busy = $state<string | null>(null);
async function act(u: UnmatchedAlbum, run: () => Promise<string>) {
busy = u.album_id;
try {
pushToast(await run());
open = null;
await client.invalidateQueries({ queryKey: ['adminWants'] });
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
const onMatch = (u: UnmatchedAlbum, ref: string) =>
act(u, async () => {
const m = await matchUnmatched(u.album_id, ref);
const agree = m.tracks ? ` ${m.agree} of ${m.tracks} tracks agree.` : '';
return `${u.title} is now ${m.title} by ${m.artist}, held.${agree}`;
});
const onLeaveOff = (u: UnmatchedAlbum) =>
act(u, async () => {
await leaveUnmatchedOff(u.album_id);
return `${u.title} is left off the list.`;
});
const onPutBack = (u: UnmatchedAlbum) =>
act(u, async () => {
await putUnmatchedBack(u.album_id);
return `${u.title} is back to be matched.`;
});
const tracks = (n: number) => (n === 1 ? '1 track' : `${n} tracks`);
</script>
{#if unmatched.isPending}
<p class="text-text-secondary">Reading the unmatched albums…</p>
{:else if unmatched.isError}
<p class="text-error-fg">Couldn't load the unmatched albums.</p>
{:else}
{#if toMatch.length === 0}
<div class="rounded-lg border border-border bg-surface p-6 text-center">
<CircleCheck size={28} strokeWidth={1} class="mx-auto text-text-muted" />
<p class="mt-3 text-text-primary">Every album in your library is matched.</p>
<p class="mt-1 text-sm text-text-secondary">
An album no MusicBrainz album matches by its artist, title and tracks lands here, to be
matched by hand or left off.
</p>
</div>
{:else}
<p class="mb-3 text-sm text-text-secondary">
No MusicBrainz album matched {toMatch.length === 1 ? 'this album' : `these ${toMatch.length} albums`}
by artist, title and tracks, so {toMatch.length === 1 ? "it isn't" : "they aren't"} fetched back if
{toMatch.length === 1 ? 'its' : 'their'} files go missing. Match each to its MusicBrainz album, or
leave it off if MusicBrainz doesn't have it.
</p>
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each toMatch as u (u.album_id)}
<li class="p-3" data-testid="unmatched-row">
<div class="flex flex-wrap items-start gap-3">
<div class="min-w-0 flex-1 space-y-0.5">
<a href="/albums/{u.album_id}" class="block truncate text-sm font-medium text-text-primary hover:underline"
>{u.title}</a
>
<div class="truncate text-xs text-text-secondary">{u.artist} · {tracks(u.tracks)}</div>
<p class="text-xs text-text-muted">{u.detail}</p>
</div>
<div class="flex shrink-0 gap-2">
<button
type="button"
aria-expanded={open === u.album_id}
onclick={() => (open = open === u.album_id ? null : u.album_id)}
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-primary hover:bg-surface-hover"
>
Match…
</button>
<button
type="button"
disabled={busy === u.album_id}
onclick={() => onLeaveOff(u)}
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
Leave it off
</button>
</div>
</div>
{#if open === u.album_id}
<UnmatchedMatch album={u} busy={busy === u.album_id} onMatch={(ref) => onMatch(u, ref)} />
{/if}
</li>
{/each}
</ul>
{/if}
{#if leftOff.length > 0}
<section class="mt-6" data-testid="left-off">
<h2 class="mb-2 text-sm font-medium text-text-primary">Left off</h2>
<p class="mb-3 text-xs text-text-secondary">
Kept in the library, never matched again, and not fetched back if their files go missing.
</p>
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each leftOff as u (u.album_id)}
<li class="flex flex-wrap items-center gap-3 p-3" data-testid="left-off-row">
<div class="min-w-0 flex-1 space-y-0.5">
<a href="/albums/{u.album_id}" class="block truncate text-sm text-text-primary hover:underline">{u.title}</a>
<div class="truncate text-xs text-text-secondary">
{u.artist} · {tracks(u.tracks)} · left off {relativeTime(u.decided_at)}
</div>
</div>
<button
type="button"
disabled={busy === u.album_id}
onclick={() => onPutBack(u)}
class="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
Put back
</button>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import type { CatalogueSearchResult, UnmatchedAlbum } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', async () => {
const { mockQuery } = await import('../../../test-utils/query');
return {
createUnmatchedQuery: vi.fn(() => mockQuery({ data: [] })),
matchUnmatched: vi.fn().mockResolvedValue({
release_group_mbid: 'rg-1',
title: 'Songs for the Deaf',
artist: 'Queens of the Stone Age',
agree: 9,
tracks: 10
}),
leaveUnmatchedOff: vi.fn().mockResolvedValue(undefined),
putUnmatchedBack: vi.fn().mockResolvedValue(undefined)
};
});
vi.mock('#lib/api/catalogue.js', () => ({ createCatalogueSearchQuery: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import UnmatchedList from './UnmatchedList.svelte';
import { mockQuery } from '../../../test-utils/query';
import { createUnmatchedQuery, leaveUnmatchedOff, matchUnmatched, putUnmatchedBack } from '#lib/api/wanted.js';
import { createCatalogueSearchQuery } from '#lib/api/catalogue.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
const DAY = 24 * 3_600_000;
function album(id: string, title: string, artist: string, extra: Partial<UnmatchedAlbum> = {}): UnmatchedAlbum {
return {
album_id: id,
title,
artist,
detail: `MusicBrainz has no album called "${title}" by ${artist}.`,
tracks: 10,
left_off: false,
decided_at: new Date(Date.now() - 2 * DAY).toISOString(),
...extra
};
}
const result: CatalogueSearchResult = {
mbid: 'rg-1',
name: 'Songs for the Deaf',
secondary_text: 'Queens of the Stone Age · 2002 · Album',
image_url: '',
artist_mbid: 'qotsa',
album_mbid: '',
in_library: false,
requested: false
};
afterEach(() => vi.clearAllMocks());
function setup(items: UnmatchedAlbum[], results: CatalogueSearchResult[] = [result]) {
vi.mocked(createUnmatchedQuery).mockReturnValue(
mockQuery({ data: items }) as unknown as ReturnType<typeof createUnmatchedQuery>
);
vi.mocked(createCatalogueSearchQuery).mockReturnValue(
mockQuery({ data: results }) as unknown as ReturnType<typeof createCatalogueSearchQuery>
);
return render(UnmatchedList);
}
describe('UnmatchedList', () => {
test('each unmatched album says why, with its tracks, and links to the album', () => {
setup([album('al-1', 'Dial Tone', 'Annadale', { tracks: 1 })]);
const row = screen.getByTestId('unmatched-row');
expect(row.textContent).toMatch(/Annadale · 1 track/);
expect(row.textContent).toMatch(/no album called "Dial Tone"/);
expect(screen.getByRole('link', { name: 'Dial Tone' }).getAttribute('href')).toBe('/albums/al-1');
});
test('Match searches MusicBrainz for the album by its own title and artist, and matches a result', async () => {
setup([album('al-2', 'Songs for the Deaf', 'Queens of the Stone Age')]);
await fireEvent.click(screen.getByRole('button', { name: /match…/i }));
expect(createCatalogueSearchQuery).toHaveBeenLastCalledWith('Songs for the Deaf Queens of the Stone Age', 'album');
await fireEvent.click(screen.getByRole('button', { name: /match to songs for the deaf/i }));
await waitFor(() => expect(matchUnmatched).toHaveBeenCalledWith('al-2', 'rg-1'));
await waitFor(() => expect(pushToast).toHaveBeenCalledWith(expect.stringMatching(/9 of 10 tracks agree/)));
});
test('a pasted MusicBrainz link matches the album', async () => {
setup([album('al-3', '+++', 'DROELOE')]);
await fireEvent.click(screen.getByRole('button', { name: /match…/i }));
const link = 'https://musicbrainz.org/release/11111111-0000-4000-8000-000000000003';
await fireEvent.input(screen.getByRole('textbox', { name: /musicbrainz album link/i }), { target: { value: link } });
await fireEvent.click(screen.getByRole('button', { name: /^match$/i }));
await waitFor(() => expect(matchUnmatched).toHaveBeenCalledWith('al-3', link));
});
test("a local band's album is left off, and one left off can be put back", async () => {
setup([album('al-1', 'Dial Tone', 'Annadale'), album('al-4', 'Demo', 'Annadale', { left_off: true })]);
await fireEvent.click(screen.getByRole('button', { name: /leave it off/i }));
await waitFor(() => expect(leaveUnmatchedOff).toHaveBeenCalledWith('al-1'));
const leftOff = screen.getByTestId('left-off-row');
expect(leftOff.textContent).toMatch(/left off 2d ago/);
await fireEvent.click(screen.getByRole('button', { name: /put back/i }));
await waitFor(() => expect(putUnmatchedBack).toHaveBeenCalledWith('al-4'));
});
test('with every album matched it says so, and still lists those left off', () => {
setup([album('al-4', 'Demo', 'Annadale', { left_off: true })]);
expect(screen.getByText(/every album in your library is matched/i)).toBeTruthy();
expect(screen.getByTestId('left-off')).toBeTruthy();
});
});
@@ -0,0 +1,106 @@
<script lang="ts">
import { Search } from 'lucide-svelte';
import { createCatalogueSearchQuery } from '#lib/api/catalogue.js';
import type { CatalogueSearchResult, UnmatchedAlbum } from '#lib/api/types.js';
// Naming the MusicBrainz album an unmatched album is (#5579): a search of
// MusicBrainz's albums, started from the album's own title and artist, or
// a link pasted from musicbrainz.org. MusicBrainz takes a request a second,
// so the search runs when it's asked for, not on each keystroke.
let {
album,
busy = false,
onMatch
}: { album: UnmatchedAlbum; busy?: boolean; onMatch: (ref: string) => void } = $props();
// svelte-ignore state_referenced_locally
let text = $state(`${album.title} ${album.artist}`.trim());
// svelte-ignore state_referenced_locally
let asked = $state(`${album.title} ${album.artist}`.trim());
let link = $state('');
const searchStore = $derived(createCatalogueSearchQuery(asked, 'album'));
const search = $derived($searchStore);
const results = $derived((search.data ?? []) as CatalogueSearchResult[]);
</script>
<div class="mt-3 space-y-3 rounded-md border border-border bg-bg p-3" data-testid="unmatched-match">
<form
class="flex gap-2"
onsubmit={(e) => {
e.preventDefault();
asked = text.trim();
}}
>
<input
type="search"
bind:value={text}
aria-label="Search MusicBrainz albums"
class="min-w-0 flex-1 rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-text-primary placeholder:text-text-muted"
/>
<button
type="submit"
class="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
>
<Search size={14} strokeWidth={1.75} aria-hidden="true" /> Search
</button>
</form>
{#if search.isPending && asked}
<p class="text-xs text-text-secondary">Asking MusicBrainz…</p>
{:else if search.isError}
<p class="text-xs text-error-fg">Couldn't reach MusicBrainz. A pasted link works the same.</p>
{:else if asked && results.length === 0}
<p class="text-xs text-text-secondary">MusicBrainz has no album like that.</p>
{:else if results.length > 0}
<ul class="divide-y divide-border">
{#each results as r (r.mbid)}
<li class="flex items-center gap-3 py-2" data-testid="unmatched-result">
<div class="min-w-0 flex-1">
<div class="truncate text-sm text-text-primary">{r.name}</div>
<div class="truncate text-xs text-text-secondary">{r.secondary_text}</div>
</div>
<a
href="https://musicbrainz.org/release-group/{r.mbid}"
target="_blank"
rel="noreferrer"
class="shrink-0 text-xs text-text-secondary hover:text-text-primary hover:underline">MusicBrainz</a
>
<button
type="button"
disabled={busy}
onclick={() => onMatch(r.mbid)}
aria-label="Match to {r.name}"
class="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
This one
</button>
</li>
{/each}
</ul>
{/if}
<form
class="flex gap-2 border-t border-border pt-3"
onsubmit={(e) => {
e.preventDefault();
if (link.trim()) onMatch(link.trim());
}}
>
<input
type="text"
bind:value={link}
placeholder="Or paste a MusicBrainz album link"
aria-label="MusicBrainz album link"
class="min-w-0 flex-1 rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-text-primary placeholder:text-text-muted"
/>
<button
type="submit"
disabled={busy || !link.trim()}
class="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs text-text-primary hover:bg-surface-hover disabled:opacity-50"
>
Match
</button>
</form>
</div>
@@ -54,7 +54,7 @@ function want(id: string, extra: Partial<Want> = {}): Want {
function page(
items: Want[],
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] },
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0 },
artist?: WantsPage['artist']
): WantsPage {
return {
@@ -63,7 +63,7 @@ function page(
total: items.length,
limit: 50,
offset: 0,
counts: { artists: 2, wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 },
counts: { artists: 2, wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0, unmatched: 0 },
library
};
}
+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 />