feat: Minstrel no longer talks to Lidarr: its connection, Delete via Lidarr and the unmonitor options are gone, and the track reports are plain quarantine (M502 #5525)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
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

Migration 0091 drops lidarr_config and renames the quarantine tables, types,
indexes and the history column off the Lidarr name. The Integrations Lidarr
card, the Reports "Delete via Lidarr" action, the merge and remove-track
unmonitor options, the Lidarr audit actions and error codes go, on the
server, web and Android. The README describes acquisition as it is now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 13:48:20 -04:00
co-authored by Claude Opus 5.5
parent c54792c51e
commit 3a16732883
170 changed files with 994 additions and 6242 deletions
@@ -13,7 +13,7 @@
// Where Minstrel looks for music and what downloads it (M502 #5518),
// Fabled Librarian's model: two lists of connections the operator adds and
// configures. Nothing is seeded or copied from Lidarr. A FabledExchange is
// configures. Nothing is seeded or copied. A FabledExchange is
// one entry under download clients, with a switch for each of its jobs.
let services = $state<AcquisitionService[] | null>(null);
@@ -129,7 +129,7 @@ describe('DiscoverResultCard', () => {
// --- snooze affordance (#2375) ---
test('snooze button appears only when onSnooze is supplied', () => {
// The Lidarr search results reuse this card and have nothing to park, so
// The catalogue search results reuse this card and have nothing to park, so
// the affordance must not appear unconditionally.
render(DiscoverResultCard, {
props: { kind: 'artist', title: 'Outsider', state: 'requestable' }
+3 -3
View File
@@ -5,7 +5,7 @@
import { flagTrack } from '#lib/api/quarantine.js';
import { errCode } from '#lib/api/errors.js';
import { qk } from '#lib/api/queries.js';
import type { TrackRef, LidarrQuarantineReason } from '#lib/api/types.js';
import type { TrackRef, QuarantineReason } from '#lib/api/types.js';
let {
track,
@@ -15,11 +15,11 @@
}: {
track: TrackRef;
onClose: () => void;
initialReason?: LidarrQuarantineReason;
initialReason?: QuarantineReason;
initialNotes?: string;
} = $props();
let reason: LidarrQuarantineReason = $state(untrack(() => initialReason ?? 'bad_rip'));
let reason: QuarantineReason = $state(untrack(() => initialReason ?? 'bad_rip'));
let notes = $state(untrack(() => initialNotes ?? ''));
let submitting = $state(false);
let error = $state<string | null>(null);
+1 -1
View File
@@ -53,7 +53,7 @@
// still matches all sub-routes by prefix so the active state covers
// every tab.
// Collection (M502) is an admin's: the catalogue of what the library holds
// and wants, and what Minstrel is fetching for it, shaped like Lidarr's.
// and wants, and what Minstrel is fetching for it.
const navItems = $derived([
{ href: '/', label: 'Home', icon: House, matchPrefix: '/' },
{ href: '/library/artists', label: 'Library', icon: LibraryBig, matchPrefix: '/library' },
@@ -1,327 +0,0 @@
<script lang="ts">
import { tick } from 'svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createLidarrConfigQuery,
createQualityProfilesQuery,
createMetadataProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection
} from '#lib/api/admin.js';
import { qk } from '#lib/api/queries.js';
import { errCode } from '#lib/api/errors.js';
import { copyForCode } from '#lib/api/error-copy.js';
import Modal from '#lib/components/Modal.svelte';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
import type { LidarrConfig, LidarrTestResult } from '#lib/api/types.js';
// The Lidarr connection, on Admin › Acquisition (#5607). Optional: wants
// are Minstrel's own, and a connected Lidarr is still asked to search for
// a cleaner copy of a suspect album, to remove an album through it, and to
// stop monitoring a track you remove.
//
// The saved key comes back masked as "***" and is never put in the field.
// The field starts empty, and an empty key on save keeps the saved one.
const client = useQueryClient();
const configStore = createLidarrConfigQuery();
const config = $derived($configStore);
const button =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
const field = 'w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm';
// Form state, filled once when the config first loads, so a refetch
// doesn't throw away what is being typed.
let baseUrl = $state('');
let apiKeyInput = $state('');
let qualityId = $state<number>(0);
let metadataId = $state<number>(0);
let rootPath = $state<string>('');
let initialized = false;
// Lists from a test. A fresh install has no saved connection to read the
// lists through, so the test's own lists fill the choices until a save.
let testQualityProfiles = $state<{ id: number; name: string }[]>([]);
let testMetadataProfiles = $state<{ id: number; name: string }[]>([]);
let testRootFolders = $state<{ path: string; accessible: boolean; free_space: number }[]>([]);
let testListErrors = $state<Record<string, string>>({});
$effect(() => {
const c = config.data;
if (c && !initialized) {
baseUrl = c.base_url;
qualityId = c.default_quality_profile_id;
metadataId = c.default_metadata_profile_id;
rootPath = c.default_root_folder_path;
initialized = true;
}
});
const profilesEnabled = $derived(!!config.data?.enabled);
const profilesStore = $derived(createQualityProfilesQuery(profilesEnabled));
const metadataProfilesStore = $derived(createMetadataProfilesQuery(profilesEnabled));
const foldersStore = $derived(createRootFoldersQuery(profilesEnabled));
const qualityList = $derived(testQualityProfiles.length ? testQualityProfiles : ($profilesStore.data ?? []));
const metadataList = $derived(
testMetadataProfiles.length ? testMetadataProfiles : ($metadataProfilesStore.data ?? [])
);
const folderList = $derived(testRootFolders.length ? testRootFolders : ($foldersStore.data ?? []));
// Each choice defaults to the first on offer until one is picked.
$effect(() => {
if (qualityId === 0 && qualityList.length > 0) qualityId = qualityList[0].id;
});
$effect(() => {
if (metadataId === 0 && metadataList.length > 0) metadataId = metadataList[0].id;
});
$effect(() => {
if (rootPath === '' && folderList.length > 0) rootPath = folderList[0].path;
});
// What a failure code means, said to the operator.
const SAID: Record<string, string> = {
lidarr_unreachable: "Couldn't reach Lidarr at that address.",
lidarr_auth_failed: 'Lidarr turned the API key down.',
lidarr_lookup_failed: "Lidarr answered, but not in a way Minstrel understands.",
internal_error: 'Something went wrong on the server. Its log says what.',
no_defaults: 'Lidarr offered no quality profile, metadata profile or root folder to use.'
};
const said = (code: string | undefined) => (code && SAID[code]) || copyForCode(code);
const LIST_NAMES: Record<string, string> = {
quality_profiles: 'quality profiles',
metadata_profiles: 'metadata profiles',
root_folders: 'root folders'
};
let testResult: LidarrTestResult | null = $state(null);
let saveError: string | null = $state(null);
let isSaving = $state(false);
let isTesting = $state(false);
function takeLists(t: Extract<LidarrTestResult, { ok: true }>) {
testQualityProfiles = t.quality_profiles ?? [];
testMetadataProfiles = t.metadata_profiles ?? [];
testRootFolders = t.root_folders ?? [];
testListErrors = t.list_errors ?? {};
}
async function invalidate() {
await Promise.all([
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
]);
}
async function onSave() {
isSaving = true;
saveError = null;
try {
// Test first: it confirms the connection and brings the lists the
// defaults are taken from.
const test = await testLidarrConnection({ base_url: baseUrl, api_key: apiKeyInput });
testResult = test;
if (!test.ok) {
saveError = said(test.error);
return;
}
takeLists(test);
await tick();
if (qualityId === 0 || metadataId === 0 || rootPath === '') {
saveError = said('no_defaults');
return;
}
const cfg: LidarrConfig = {
enabled: true,
base_url: baseUrl,
api_key: apiKeyInput,
default_quality_profile_id: qualityId,
default_metadata_profile_id: metadataId,
default_root_folder_path: rootPath
};
await putLidarrConfig(cfg);
// A new address is a different server, so its lists are read again.
await invalidate();
apiKeyInput = '';
} catch (e) {
saveError = said(errCode(e));
} finally {
isSaving = false;
}
}
async function onTest() {
isTesting = true;
testResult = null;
try {
const result = await testLidarrConnection({ base_url: baseUrl, api_key: apiKeyInput });
testResult = result;
if (result.ok) takeLists(result);
} catch (e) {
testResult = { ok: false, error: errCode(e) };
} finally {
isTesting = false;
}
}
// Disconnecting asks for DISCONNECT typed out, so a slip doesn't clear a
// connection the operator relies on.
let modalOpen = $state(false);
let disconnectInput = $state('');
let disconnectError = $state<string | null>(null);
const canDisconnect = $derived(disconnectInput.trim() === 'DISCONNECT');
async function onConfirmDisconnect() {
if (!canDisconnect) return;
disconnectError = null;
try {
await putLidarrConfig({
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_metadata_profile_id: 0,
default_root_folder_path: ''
});
await invalidate();
modalOpen = false;
disconnectInput = '';
} catch (e) {
disconnectError = said(errCode(e));
}
}
function cancelDisconnect() {
modalOpen = false;
disconnectInput = '';
disconnectError = null;
}
</script>
{#snippet listError(key: string)}
{#if testListErrors[key]}
<p class="w-full text-xs text-error-fg">Couldn't read Lidarr's {LIST_NAMES[key]}.</p>
{/if}
{/snippet}
<SettingsGroup
title="Lidarr"
id="lidarr"
hint="Optional. Asked for a cleaner copy of a suspect album, and to remove albums."
>
<SettingRow label="Status">
<span class="text-sm text-text-secondary" data-testid="lidarr-state">
{#if testResult?.ok}
Connected · Lidarr {testResult.version}
{:else if config.data?.enabled}
Connected
{:else}
Not connected
{/if}
</span>
</SettingRow>
<SettingRow label="Address" for="lidarr-url">
<input id="lidarr-url" type="text" bind:value={baseUrl} placeholder="http://lidarr.lan:8686" class={field} />
</SettingRow>
<SettingRow label="API key" for="lidarr-key" hint="In Lidarr under Settings › General.">
<input
id="lidarr-key"
type="password"
bind:value={apiKeyInput}
placeholder={config.data?.api_key === '***' ? 'Key saved · leave empty to keep it' : 'Paste the API key'}
class={field}
/>
</SettingRow>
<SettingRow label="Quality profile" for="lidarr-quality">
<select
id="lidarr-quality"
bind:value={qualityId}
disabled={qualityList.length === 0}
class="{field} disabled:opacity-50"
>
{#each qualityList as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
{@render listError('quality_profiles')}
</SettingRow>
<SettingRow label="Metadata profile" for="lidarr-metadata">
<select
id="lidarr-metadata"
bind:value={metadataId}
disabled={metadataList.length === 0}
class="{field} disabled:opacity-50"
>
{#each metadataList as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
{@render listError('metadata_profiles')}
</SettingRow>
<SettingRow label="Root folder" for="lidarr-root">
<select id="lidarr-root" bind:value={rootPath} disabled={folderList.length === 0} class="{field} disabled:opacity-50">
{#each folderList as f (f.path)}
<option value={f.path}>{f.path}{f.accessible ? '' : ' (Lidarr can’t reach it)'}</option>
{/each}
</select>
{@render listError('root_folders')}
</SettingRow>
<div class="flex flex-wrap items-center gap-2 px-4 py-3">
<button type="button" onclick={onTest} disabled={isTesting} class={button}>
{isTesting ? 'Testing…' : 'Test'}
</button>
<button
type="button"
onclick={onSave}
disabled={isSaving}
class="rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
{isSaving ? 'Saving…' : 'Save'}
</button>
{#if config.data?.enabled}
<button type="button" onclick={() => (modalOpen = true)} class="ml-auto {button}">Disconnect</button>
{/if}
{#if testResult && !testResult.ok && !saveError}
<p class="w-full text-sm text-error-fg" data-testid="lidarr-test-error">{said(testResult.error)}</p>
{/if}
{#if saveError}
<p class="w-full text-sm text-error-fg" data-testid="lidarr-save-error">Not saved: {saveError}</p>
{/if}
</div>
</SettingsGroup>
<Modal title="Disconnect Lidarr?" open={modalOpen} onClose={cancelDisconnect}>
<p class="-mt-2 text-text-secondary">
This clears the saved connection. Type
<span class="font-mono text-text-primary">DISCONNECT</span> to go ahead.
</p>
<input
type="text"
bind:value={disconnectInput}
placeholder="DISCONNECT"
aria-label="Type DISCONNECT to confirm"
class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
/>
{#if disconnectError}
<p class="mt-2 text-sm text-error-fg">Not disconnected: {disconnectError}</p>
{/if}
<div class="mt-5 flex justify-end gap-2">
<button type="button" onclick={cancelDisconnect} class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg">
Cancel
</button>
<button
type="button"
onclick={onConfirmDisconnect}
disabled={!canDisconnect}
class="rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
Disconnect
</button>
</div>
</Modal>
@@ -1,188 +0,0 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrConfig } from '#lib/api/types.js';
const invalidateQueries = vi.fn();
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries }) };
});
vi.mock('#lib/api/admin.js', () => ({
createLidarrConfigQuery: vi.fn(),
createQualityProfilesQuery: vi.fn(),
createMetadataProfilesQuery: vi.fn(),
createRootFoldersQuery: vi.fn(),
putLidarrConfig: vi.fn(),
testLidarrConnection: vi.fn()
}));
import LidarrSettings from './LidarrSettings.svelte';
import {
createLidarrConfigQuery,
createQualityProfilesQuery,
createMetadataProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection
} from '#lib/api/admin.js';
const connected: LidarrConfig = {
enabled: true,
base_url: 'http://lidarr.local',
api_key: '***',
default_quality_profile_id: 1,
default_metadata_profile_id: 1,
default_root_folder_path: '/music'
};
const unset: LidarrConfig = {
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_metadata_profile_id: 0,
default_root_folder_path: ''
};
const testOk = {
ok: true as const,
version: '2.0.5',
quality_profiles: [
{ id: 1, name: 'FLAC' },
{ id: 2, name: 'MP3-320' }
],
metadata_profiles: [{ id: 1, name: 'Standard' }],
root_folders: [{ path: '/music', accessible: true, free_space: 12345 }]
};
afterEach(() => {
vi.clearAllMocks();
invalidateQueries.mockReset();
});
function setup(config: LidarrConfig = connected, lists = true) {
vi.mocked(createLidarrConfigQuery).mockReturnValue(mockQuery({ data: config }) as never);
vi.mocked(createQualityProfilesQuery).mockReturnValue(
mockQuery({ data: lists ? [{ id: 1, name: 'Standard' }, { id: 2, name: 'Lossless' }] : [] }) as never
);
vi.mocked(createMetadataProfilesQuery).mockReturnValue(
mockQuery({ data: lists ? [{ id: 1, name: 'Standard' }, { id: 2, name: 'Singles only' }] : [] }) as never
);
vi.mocked(createRootFoldersQuery).mockReturnValue(
mockQuery({ data: lists ? [{ path: '/music', accessible: true, free_space: 0 }] : [] }) as never
);
return render(LidarrSettings);
}
function modalConfirm(): HTMLElement {
return within(screen.getByRole('dialog')).getByRole('button', { name: /^disconnect$/i });
}
describe('LidarrSettings', () => {
test('connected, it says so; not connected, it says that and offers no Disconnect', () => {
const { unmount } = setup();
expect(screen.getByTestId('lidarr-state').textContent?.trim()).toBe('Connected');
expect(screen.getByRole('button', { name: /^disconnect$/i })).toBeInTheDocument();
unmount();
setup(unset);
expect(screen.getByTestId('lidarr-state').textContent?.trim()).toBe('Not connected');
expect(screen.queryByRole('button', { name: /^disconnect$/i })).toBeNull();
});
test('Save tests first, then saves with an empty key, which keeps the saved one', async () => {
setup();
vi.mocked(testLidarrConnection).mockResolvedValueOnce(testOk);
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(putLidarrConfig).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, base_url: 'http://lidarr.local', api_key: '', default_root_folder_path: '/music' })
)
);
});
test('a typed key is sent', async () => {
setup();
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'newkey' } });
vi.mocked(testLidarrConnection).mockResolvedValueOnce(testOk);
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(putLidarrConfig).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'newkey' })));
});
test('Test shows the Lidarr version when it connects', async () => {
setup();
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: true, version: '2.0.0' });
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
await waitFor(() => expect(screen.getByTestId('lidarr-state').textContent).toMatch(/lidarr 2\.0\.0/i));
});
test('a failed test says what went wrong in words, not its code', async () => {
setup();
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: false, error: 'lidarr_unreachable' });
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
const said = await screen.findByTestId('lidarr-test-error');
expect(said.textContent).toMatch(/couldn't reach lidarr/i);
expect(said.textContent).not.toMatch(/lidarr_unreachable/);
});
test('a save whose test fails is not saved, and says why', async () => {
setup(unset, false);
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: false, error: 'lidarr_auth_failed' });
await fireEvent.input(screen.getByLabelText(/address/i), { target: { value: 'http://wrong.local' } });
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'k' } });
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(screen.getByTestId('lidarr-save-error').textContent).toMatch(/turned the api key down/i));
expect(putLidarrConfig).not.toHaveBeenCalled();
});
test('first-time setup fills the defaults from the test, keeping a choice made after it', async () => {
setup(unset, false);
vi.mocked(testLidarrConnection).mockResolvedValue(testOk);
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
await fireEvent.input(screen.getByLabelText(/address/i), { target: { value: 'http://lidarr.local' } });
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'secret-key' } });
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
await waitFor(() => expect(testLidarrConnection).toHaveBeenCalledTimes(1));
await fireEvent.change(screen.getByLabelText(/quality profile/i), { target: { value: '2' } });
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(putLidarrConfig).toHaveBeenCalledWith({
enabled: true,
base_url: 'http://lidarr.local',
api_key: 'secret-key',
default_quality_profile_id: 2,
default_metadata_profile_id: 1,
default_root_folder_path: '/music'
})
);
});
test('Disconnect needs DISCONNECT typed; cancelling changes nothing', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
expect((modalConfirm() as HTMLButtonElement).disabled).toBe(true);
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(putLidarrConfig).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('Disconnect, confirmed, clears the connection', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
await fireEvent.input(screen.getByPlaceholderText('DISCONNECT'), { target: { value: 'DISCONNECT' } });
vi.mocked(putLidarrConfig).mockResolvedValueOnce(unset);
await fireEvent.click(modalConfirm());
expect(putLidarrConfig).toHaveBeenCalledWith(expect.objectContaining({ enabled: false, base_url: '', api_key: '' }));
});
test('the profile and folder choices come from Lidarr', () => {
setup();
expect(screen.getAllByText('Standard').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('Lossless')).toBeInTheDocument();
expect(screen.getByText('Singles only')).toBeInTheDocument();
expect(screen.getByText('/music')).toBeInTheDocument();
});
});
@@ -2,9 +2,9 @@
import Cover from '#lib/components/Cover.svelte';
import type { WantArtist } from '#lib/api/types.js';
// One artist in the Collection's catalogue (#5572), like a Lidarr artist
// poster: the picture, a bar of how many of their albums on the list are
// held, and whether Minstrel follows them for new releases.
// One artist in the Collection's catalogue (#5572), as a poster: the
// picture, a bar of how many of their albums on the list are held, and
// whether Minstrel follows them for new releases.
let { artist: a }: { artist: WantArtist } = $props();
@@ -7,8 +7,7 @@
import CatalogueArtistCard from './CatalogueArtistCard.svelte';
import type { WantArtist } from '#lib/api/types.js';
// The Collection's catalogue (#5572), its main page, shaped like Lidarr's
// artist index so a person coming from Lidarr finds their way: every
// The Collection's catalogue (#5572), its main page, an artist index: every
// 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
@@ -2,11 +2,10 @@
import SectionRail from '#lib/components/SectionRail.svelte';
import type { WantCounts } from '#lib/api/types.js';
// The Collection's sections, down the left like Lidarr's (#5572): the
// catalogue of artists first, then the sections that show state. Activity
// carries a second, accented count when downloads are waiting on a person.
// Unmatched, like Lidarr's Unmapped Files, holds the library albums no
// MusicBrainz album matched (#5579). Upgrades, Lidarr's Cutoff Unmet,
// The Collection's sections, down the left (#5572): the catalogue of
// artists first, then the sections that show state. Activity carries a
// second, accented count when downloads are waiting on a person. Unmatched
// holds the library albums no MusicBrainz album matched (#5579). Upgrades
// holds the albums held but wanted for a better copy (#5524).
let { counts }: { counts?: WantCounts } = $props();
@@ -8,12 +8,12 @@
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.
// The Collection's Unmatched section (#5579): 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();