fix: admin bugs from the audit: a playback-error delete no longer reports failure, a failed Overview load no longer reads as all-clear, and email settings can't be saved over with defaults (M503 #5604)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m49s
release / integration (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / android (push) Successful in 6m26s
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

- Playback errors: Delete file stops resolving the error row the track
  deletion already took with it (ON DELETE CASCADE), which 404'd into
  "Delete failed".
- Overview: a queue that fails to load says so with Try again, and its tile
  shows a dash, not 0. Delete via Lidarr shows only for an album Lidarr
  holds while Lidarr is connected. An approve refreshes the Collection.
- Quarantine: Delete via Lidarr is in the row menu, so a phone reaches it;
  "1 report", not "1 reports".
- Duplicates: the Keep badge follows the copy picked.
- Integrations: provider save failures and load errors are said, not
  swallowed or shown as "No providers registered"; the Lidarr test catches
  a failed call; SMTP fills once and Save stays off until it has loaded.
  Lidarr's blurb says what it is still used for.
- Requests: release profiles point at Collection › Following.
- Library: an empty library sends an admin to Admin to run a scan, and
  tells anyone else the admin scans it, instead of opening Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 09:40:11 -04:00
co-authored by Claude Opus 5.5
parent c94c09aeae
commit 7c89e59536
14 changed files with 343 additions and 95 deletions
+26 -3
View File
@@ -56,6 +56,12 @@
const quarantineCount = $derived(quarantine.data?.length ?? 0);
const lidarrConnected = $derived(config.data?.enabled === true);
// A tile whose query failed shows a dash, not 0: a failed load must not
// read as an all-clear (#5604).
function tile(q: { isPending: boolean; isError: boolean }, n: number): string {
return q.isPending || q.isError ? '—' : String(n);
}
const requestPreview = $derived(
(requests.data ?? []).slice(0, PREVIEW_LIMIT)
);
@@ -110,6 +116,9 @@
// ---- Action handlers ----
async function invalidateRequests() {
await Promise.all([
// An approved request becomes a want, so the Collection's lists and
// counts change too (#5604).
client.invalidateQueries({ queryKey: ['adminWants'] }),
client.invalidateQueries({ queryKey: qk.adminRequests('pending') }),
client.invalidateQueries({ queryKey: qk.adminRequests('approved') }),
client.invalidateQueries({ queryKey: qk.adminRequests('rejected') }),
@@ -265,14 +274,14 @@
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
>
<div class="text-sm text-text-secondary">Pending requests</div>
<div class="font-display mt-1 text-3xl font-medium text-text-primary">{pendingCount}</div>
<div class="font-display mt-1 text-3xl font-medium text-text-primary" data-testid="tile-requests">{tile(requests, pendingCount)}</div>
</a>
<a
href="/admin/quarantine"
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
>
<div class="text-sm text-text-secondary">Quarantined tracks</div>
<div class="font-display mt-1 text-3xl font-medium text-text-primary">{quarantineCount}</div>
<div class="font-display mt-1 text-3xl font-medium text-text-primary" data-testid="tile-quarantine">{tile(quarantine, quarantineCount)}</div>
</a>
<a
href="/admin/integrations"
@@ -280,7 +289,7 @@
>
<div class="text-sm text-text-secondary">Lidarr</div>
<div class="font-display mt-1 text-2xl font-medium text-text-primary">
{lidarrConnected ? 'Connected' : 'Unset'}
{config.isError ? '—' : lidarrConnected ? 'Connected' : 'Unset'}
</div>
</a>
</div>
@@ -296,6 +305,11 @@
{#if requests.isPending}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if requests.isError}
<p class="text-sm text-error-fg" data-testid="requests-error">
Couldn't load the requests.
<button type="button" class="underline hover:no-underline" onclick={() => requests.refetch()}>Try again</button>
</p>
{:else if requestPreview.length === 0}
<p class="text-sm text-text-secondary">No pending requests.</p>
{:else}
@@ -529,6 +543,11 @@
{#if quarantine.isPending}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if quarantine.isError}
<p class="text-sm text-error-fg" data-testid="quarantine-error">
Couldn't load the quarantine.
<button type="button" class="underline hover:no-underline" onclick={() => quarantine.refetch()}>Try again</button>
</p>
{:else if quarantinePreview.length === 0}
<p class="text-sm text-text-secondary">No quarantined tracks.</p>
{:else}
@@ -568,6 +587,9 @@
>
<Trash2 size={14} strokeWidth={1.5} /> {fileArmed ? 'Confirm?' : 'Delete file'}
</button>
<!-- Only for an album Lidarr holds, while Lidarr is connected; a
local-only track has nothing for Lidarr to remove (#5604). -->
{#if lidarrConnected && row.lidarr_album_mbid}
<button
type="button"
onclick={() => onDeleteLidarr(row)}
@@ -578,6 +600,7 @@
>
<Cloud size={14} strokeWidth={1.5} /> {lidarrArmed ? 'Confirm?' : 'Delete via Lidarr'}
</button>
{/if}
<a
href="/admin/quarantine"
aria-label="Open in quarantine queue"
+40 -2
View File
@@ -221,8 +221,9 @@ describe('admin Overview page', () => {
setupOverview({
quarantine: [
{ track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC',
album_title: 'Geogaddi', report_count: 1, reports: [] }
]
album_title: 'Geogaddi', lidarr_album_mbid: 'rg-1', report_count: 1, reports: [] }
],
lidarr: { enabled: true }
});
render(OverviewPage);
await fireEvent.click(
@@ -235,6 +236,43 @@ describe('admin Overview page', () => {
expect(deleteQuarantineViaLidarr).toHaveBeenCalledWith('t1');
});
test('Delete via Lidarr is offered only for an album Lidarr holds, while Lidarr is connected', async () => {
const local = { track_id: 't1', track_title: 'Dial Tone', artist_name: 'Annadale',
album_title: 'Dial Tone', lidarr_album_mbid: null, report_count: 1, reports: [] };
const held = { track_id: 't2', track_title: 'Roygbiv', artist_name: 'BoC',
album_title: 'Geogaddi', lidarr_album_mbid: 'rg-1', report_count: 1, reports: [] };
setupOverview({ quarantine: [local, held], lidarr: { enabled: false } });
render(OverviewPage);
expect(screen.queryByRole('button', { name: /delete via lidarr/i })).toBeNull();
cleanup();
setupOverview({ quarantine: [local, held], lidarr: { enabled: true } });
render(OverviewPage);
expect(screen.queryByRole('button', { name: /delete via lidarr dial tone/i })).toBeNull();
expect(screen.getByRole('button', { name: /delete via lidarr roygbiv/i })).toBeInTheDocument();
});
test('a queue that fails to load says so, with Try again, and its tile shows a dash, not 0', async () => {
const refetch = vi.fn();
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ isError: true, error: new Error('boom'), refetch })
);
(createAdminQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ isError: true, error: new Error('boom') })
);
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: { enabled: false } })
);
render(OverviewPage);
expect(screen.queryByText(/no pending requests/i)).toBeNull();
expect(screen.queryByText(/no quarantined tracks/i)).toBeNull();
expect(screen.getByTestId('tile-requests').textContent).toBe('—');
expect(screen.getByTestId('tile-quarantine').textContent).toBe('—');
expect(screen.getByTestId('requests-error').textContent).toMatch(/couldn't load the requests/i);
await fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]);
expect(refetch).toHaveBeenCalled();
});
test('"View all" link surfaces only when count exceeds the preview limit', async () => {
// 6 requests > limit of 5; quarantine empty.
const six = Array.from({ length: 6 }, (_, i) => ({
+7 -5
View File
@@ -432,7 +432,8 @@
<ul class="divide-y divide-border">
{#each group.members as m (m.track_id)}
{@const keep = m.track_id === group.survivor_track_id}
{@const keep = keeperOf(group) === m.track_id}
{@const proposed = m.track_id === group.survivor_track_id}
<li class="flex items-start gap-3 px-4 py-3" data-testid="duplicate-member">
<input
type="radio"
@@ -446,14 +447,15 @@
<div class="flex flex-wrap items-center gap-2">
<span class="truncate text-sm text-text-primary">{m.title}</span>
{#if keep}
<!-- The proposed copy to keep, with the rule that chose it.
A default the merge will let the operator override. -->
<!-- The copy the merge will keep: the proposed one with the
rule that chose it, or the one the operator picked. The
badge follows the pick, as the merge does (#5604). -->
<span
class="shrink-0 rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="survivor-badge"
title="Proposed to keep: {group.survivor_reason}"
title={proposed ? `Proposed to keep: ${group.survivor_reason}` : 'Your pick'}
>
Keep · {group.survivor_reason}
Keep · {proposed ? group.survivor_reason : 'your pick'}
</span>
{/if}
</div>
@@ -151,6 +151,15 @@ describe('admin duplicates', () => {
expect(rows[1].querySelector('[data-testid="survivor-badge"]')).toBeNull();
});
test('picking another copy moves the keep badge to it', async () => {
renderWith(response());
const rows = screen.getAllByTestId('duplicate-member');
await fireEvent.click(rows[1].querySelector('input[type="radio"]') as HTMLInputElement);
const after = screen.getAllByTestId('duplicate-member');
expect(after[0].querySelector('[data-testid="survivor-badge"]')).toBeNull();
expect(text(after[1].querySelector('[data-testid="survivor-badge"]') as HTMLElement)).toBe('Keep · your pick');
});
test('an acoustic group reads as a match percentage', () => {
const r = response();
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 };
+41 -11
View File
@@ -24,7 +24,7 @@
type SMTPConfig
} from '#lib/api/admin.js';
import { qk } from '#lib/api/queries.js';
import { errCode } from '#lib/api/errors.js';
import { errCode, errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import Modal from '#lib/components/Modal.svelte';
import AcoustIDCard from '#lib/components/AcoustIDCard.svelte';
@@ -184,6 +184,10 @@
testRootFolders = result.root_folders ?? [];
testListErrors = result.list_errors ?? {};
}
} catch (e) {
// A test that can't reach the server says so, rather than leaving
// the button to stop spinning with nothing said (#5604).
testResult = { ok: false, error: errMessage(e) };
} finally {
isTesting = false;
}
@@ -272,8 +276,8 @@
if (result.version_bumped) {
await client.invalidateQueries({ queryKey: qk.coverage() });
}
} catch (_e) {
// Error display is handled inline via saveError pattern; swallow here.
} catch (e) {
pushToast(`Couldn't save ${provider.display_name}: ${errMessage(e)}`, 'error');
} finally {
coverSaving[provider.id] = false;
}
@@ -328,8 +332,8 @@
const result = await updateTagProvider(provider.id, patch);
tagLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
await client.invalidateQueries({ queryKey: qk.tagProviders() });
} catch (_e) {
// Inline error surfacing handled by the mutation toast layer; swallow here.
} catch (e) {
pushToast(`Couldn't save ${provider.display_name}: ${errMessage(e)}`, 'error');
} finally {
tagSaving[provider.id] = false;
}
@@ -359,10 +363,17 @@
let smtpSaving = $state(false);
let smtpTesting = $state(false);
// The form is filled from the saved config once, when it first loads,
// and again after a save. Re-filling on every refetch threw away edits
// in progress, and until a load succeeds the form holds defaults, so
// Save stays off then: saving it would write those defaults over the
// real config (#5604).
let smtpLoaded = $state(false);
$effect(() => {
if (smtpQ.data) {
smtpForm = smtpQ.data;
if (smtpQ.data && !smtpLoaded) {
smtpForm = { ...smtpQ.data };
smtpPasswordInput = '';
smtpLoaded = true;
}
});
@@ -370,6 +381,7 @@
smtpSaving = true;
try {
await updateSMTPConfig({ ...smtpForm, password: smtpPasswordInput });
smtpLoaded = false;
await client.invalidateQueries({ queryKey: qk.smtpConfig() });
smtpPasswordInput = '';
pushToast('SMTP config saved.');
@@ -390,7 +402,7 @@
} catch (e: unknown) {
const code = errCode(e);
const message = (e as { message?: string })?.message;
if (code === 'no_email_on_file') pushToast('Set your email in /settings before testing.', 'error');
if (code === 'no_email_on_file') pushToast('Set your email in Settings › Account before testing.', 'error');
else if (code === 'not_configured') pushToast('Save the SMTP config first.', 'error');
else if (code === 'send_failed') pushToast(`Send failed: ${message || 'see server logs'}`, 'error');
else pushToast(`Test failed: ${code}`, 'error');
@@ -431,8 +443,9 @@
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Lidarr</h3>
<p class="mt-1 text-sm text-text-secondary">
Route approved requests from <span class="font-mono text-accent-fg">/discover</span>
to Lidarr. Search itself now asks MusicBrainz.
Optional. Approved requests go on Minstrel's own wanted list; a connected Lidarr is
still used to search for a cleaner copy of a suspect album, to remove an album through
it, and to stop monitoring tracks you remove.
</p>
</div>
@@ -650,6 +663,11 @@
{/each}
{:else if coverProvidersQ.isPending}
<p class="text-sm text-text-muted">Loading…</p>
{:else if coverProvidersQ.isError}
<p class="text-sm text-error-fg" data-testid="cover-providers-error">
Couldn't load the cover art providers.
<button type="button" class="underline hover:no-underline" onclick={() => coverProvidersQ.refetch()}>Try again</button>
</p>
{:else}
<p class="text-sm text-text-muted">No providers registered.</p>
{/if}
@@ -752,6 +770,11 @@
{/each}
{:else if tagProvidersQ.isPending}
<p class="text-sm text-text-muted">Loading…</p>
{:else if tagProvidersQ.isError}
<p class="text-sm text-error-fg" data-testid="tag-providers-error">
Couldn't load the tag providers.
<button type="button" class="underline hover:no-underline" onclick={() => tagProvidersQ.refetch()}>Try again</button>
</p>
{:else}
<p class="text-sm text-text-muted">No providers registered.</p>
{/if}
@@ -766,6 +789,13 @@
</p>
</div>
{#if smtpQ.isError && !smtpLoaded}
<p class="text-sm text-error-fg" data-testid="smtp-error">
Couldn't load the email settings, so they can't be saved yet.
<button type="button" class="underline hover:no-underline" onclick={() => smtpQ.refetch()}>Try again</button>
</p>
{/if}
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={smtpForm.enabled} />
Enabled
@@ -807,7 +837,7 @@
</div>
<div class="flex items-center gap-2 pt-2">
<button type="button" disabled={smtpSaving} onclick={onSaveSMTP}
<button type="button" disabled={smtpSaving || !smtpLoaded} onclick={onSaveSMTP}
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
{smtpSaving ? 'Saving…' : 'Save'}
</button>
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrConfig } from '#lib/api/types.js';
import type { CoverProvider, TagProvider, SMTPConfig } from '#lib/api/admin.js';
@@ -468,6 +468,27 @@ describe('/admin/integrations — cover art providers', () => {
);
});
test('a provider save that fails says so', async () => {
setup();
(updateCoverProvider as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ code: 'server_error' });
const theAudioDbCard = providerCard('TheAudioDB');
await fireEvent.input(within(theAudioDbCard).getByLabelText(/api key/i), { target: { value: 'k' } });
await fireEvent.click(within(theAudioDbCard).getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(screen.getByTestId('toast').textContent).toMatch(/couldn't save theaudiodb/i)
);
});
test('providers that fail to load say so, not "No providers registered"', () => {
setup();
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ isError: true, error: { code: 'server_error' } })
);
cleanup();
render(IntegrationsPage);
expect(screen.getByTestId('cover-providers-error')).toBeInTheDocument();
});
test('when version_bumped: true, invalidateQueries is called with qk.coverage()', async () => {
setup();
(updateCoverProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
@@ -603,10 +624,22 @@ describe('/admin/integrations — SMTP card', () => {
const section = smtpSection();
await fireEvent.click(within(section).getByRole('button', { name: /send test email/i }));
await waitFor(() =>
expect(screen.getByTestId('toast').textContent).toMatch(/set your email in \/settings/i)
expect(screen.getByTestId('toast').textContent).toMatch(/set your email in settings › account/i)
);
});
test('when the email settings fail to load, Save is off, so defaults are never written over them', () => {
setup();
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ isError: true, error: { code: 'server_error' } })
);
cleanup();
render(IntegrationsPage);
const section = smtpSection();
expect(within(section).getByTestId('smtp-error')).toBeInTheDocument();
expect((within(section).getByRole('button', { name: /^save$/i }) as HTMLButtonElement).disabled).toBe(true);
});
test('password input placeholder shows hint when existing password is masked', async () => {
setup({ smtp: smtpConfigured });
// smtpConfigured has password: '***' — placeholder should show "Leave blank to keep current"
@@ -85,9 +85,12 @@
}
}
// Delete the source file via the existing quarantine admin endpoint;
// on success stamp this row as resolved='deleted' so the inbox
// reflects the action immediately.
// Delete the source file via the quarantine admin endpoint. That
// removes the track row, and the track's error reports go with it
// (ON DELETE CASCADE, migration 0032), so there is no row left to
// stamp 'deleted': resolving it afterwards 404'd and the page said
// "Delete failed" for a file already gone (#5604). The deletion is
// recorded in the quarantine action log instead.
let deleteFileOpen = $state<AdminPlaybackError | null>(null);
async function confirmDeleteFile() {
@@ -96,12 +99,12 @@
deleteFileOpen = null;
try {
await deleteQuarantineFile(row.track_id);
await resolvePlaybackError(row.id, 'deleted');
pushToast(`Deleted "${row.track_title}"`);
await invalidate();
} catch (e: unknown) {
pushToast(`Delete failed: ${errMessage(e)}`, 'error');
return;
}
pushToast(`Deleted "${row.track_title}"`);
await client.invalidateQueries({ queryKey: ['adminPlaybackErrors'] });
}
// Manual mark-resolved modal. Resolution dropdown limited to the two
@@ -240,8 +243,8 @@
{#if deleteFileOpen}
<p class="text-sm text-text-secondary">
This removes <span class="font-medium text-text-primary">{deleteFileOpen.track_title}</span>
from the library and deletes the underlying file. The error row
will be marked resolved.
from the library and deletes the underlying file. Its error reports
go with it.
</p>
<p class="mt-2 text-xs text-text-muted font-mono">{deleteFileOpen.track_file_path}</p>
<div class="mt-4 flex justify-end gap-2">
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { AdminPlaybackError } from '#lib/api/types.js';
vi.mock('#lib/api/admin.js', () => ({
createAdminPlaybackErrorsQuery: vi.fn(),
resolvePlaybackError: vi.fn(),
deleteQuarantineFile: vi.fn()
}));
import Page from './+page.svelte';
import { createAdminPlaybackErrorsQuery, deleteQuarantineFile, resolvePlaybackError } from '#lib/api/admin.js';
const row = {
id: 'pe-1',
track_id: 't-1',
track_title: 'Roygbiv',
artist_name: 'Boards of Canada',
album_title: 'Geogaddi',
track_file_path: '/music/BoC/Geogaddi/05 Roygbiv.flac',
kind: 'load_failed',
detail: null,
username: 'alice',
client_id: 'android-1',
occurred_at: new Date().toISOString(),
resolution: null
} as unknown as AdminPlaybackError;
afterEach(() => vi.clearAllMocks());
describe('/admin/playback-errors', () => {
// Deleting the track takes its error rows with it (ON DELETE CASCADE), so
// the page must not then try to resolve the row: that 404'd and said
// "Delete failed" for a file already gone (#5604).
test('Delete file deletes the track and says so, without resolving the row it took with it', async () => {
vi.mocked(createAdminPlaybackErrorsQuery).mockReturnValue(
mockQuery({ data: [row] }) as unknown as ReturnType<typeof createAdminPlaybackErrorsQuery>
);
vi.mocked(deleteQuarantineFile).mockResolvedValue({} as never);
render(Page);
await fireEvent.click(screen.getByRole('button', { name: /delete file for roygbiv/i }));
await fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: /^delete file$/i }));
await waitFor(() => expect(deleteQuarantineFile).toHaveBeenCalledWith('t-1'));
expect(resolvePlaybackError).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/deleted "roygbiv"/i));
});
});
+16 -28
View File
@@ -170,7 +170,6 @@
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each rows as r (r.track_id)}
{@const isExpanded = !!expanded[r.track_id]}
{@const lidarrDisabled = !r.lidarr_album_mbid}
{@const primary: RowAction = {
icon: RotateCcw,
label: 'Resolve',
@@ -190,7 +189,19 @@
ariaLabel: `Delete file for ${r.track_title}`,
onclick: () => openDeleteFile(r),
danger: true
}
},
// In the row's actions, so a phone reaches it from the menu too
// (#5604). A local-only track has no Lidarr album to remove, so
// it isn't offered.
...(r.lidarr_album_mbid
? [{
icon: Cloud,
label: 'Delete via Lidarr',
ariaLabel: `Delete via Lidarr for ${r.track_title}`,
onclick: () => openDeleteLidarr(r),
danger: true
}]
: [])
]}
<li
class="flex items-start gap-4 p-3"
@@ -219,7 +230,7 @@
{r.track_title}
</div>
<div class="truncate text-sm text-text-secondary">
{r.artist_name} · {r.album_title} · {r.report_count} reports — latest {relativeTime(r.latest_at)}
{r.artist_name} · {r.album_title} · {r.report_count} {r.report_count === 1 ? 'report' : 'reports'} — latest {relativeTime(r.latest_at)}
</div>
<!-- Reason distribution pills -->
@@ -247,7 +258,7 @@
strokeWidth={1.5}
class="transition-transform {isExpanded ? 'rotate-90' : ''}"
/>
{isExpanded ? 'Hide reports' : `Show ${r.reports.length} reports`}
{isExpanded ? 'Hide reports' : `Show ${r.reports.length} ${r.reports.length === 1 ? 'report' : 'reports'}`}
</button>
{#if isExpanded}
@@ -273,29 +284,6 @@
<div class="flex shrink-0 items-center gap-2">
<RowActionsMenu {primary} {secondary} />
{#if lidarrDisabled}
<span
aria-label={`Delete via Lidarr disabled for ${r.track_title}`}
title="Local-only track — no Lidarr album to remove."
class="hidden md:inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg opacity-50 cursor-not-allowed"
data-testid="delete-lidarr-disabled"
>
<Trash2 size={14} strokeWidth={2} />
<Cloud size={14} strokeWidth={2} />
Delete via Lidarr
</span>
{:else}
<button
type="button"
aria-label={`Delete via Lidarr for ${r.track_title}`}
class="hidden md:inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg"
onclick={() => openDeleteLidarr(r)}
>
<Trash2 size={14} strokeWidth={2} />
<Cloud size={14} strokeWidth={2} />
Delete via Lidarr
</button>
{/if}
</div>
</li>
{/each}
@@ -311,7 +299,7 @@
{#if deleteFileRow}
<p class="-mt-2 text-sm text-text-secondary">
Remove <em class="font-medium text-text-primary">{deleteFileRow.track_title}</em>
from disk and clear {deleteFileRow.report_count} reports? Lidarr may auto-redownload depending on its monitor settings.
from disk and clear {deleteFileRow.report_count} {deleteFileRow.report_count === 1 ? 'report' : 'reports'}? Lidarr may auto-redownload depending on its monitor settings.
</p>
<div class="mt-5 flex justify-end gap-2">
<button
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { AdminQuarantineRow } from '#lib/api/types.js';
@@ -154,20 +154,26 @@ describe('/admin/quarantine', () => {
);
});
test('Dimmed Delete-via-Lidarr when lidarr_album_mbid is null', () => {
test('a local-only track (no Lidarr album) offers no Delete via Lidarr', () => {
setup([{ ...baseRow, lidarr_album_mbid: null }]);
const disabled = screen.getByTestId('delete-lidarr-disabled');
expect(disabled).toHaveClass('cursor-not-allowed');
expect(disabled).toHaveClass('opacity-50');
expect(disabled).toHaveAttribute(
'title',
'Local-only track — no Lidarr album to remove.'
);
expect(
screen.queryByRole('button', { name: /delete via lidarr for roygbiv/i })
).not.toBeInTheDocument();
});
test('on a phone, Delete via Lidarr is in the row menu', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /more actions/i }));
expect(within(screen.getByRole('menu')).getByText('Delete via Lidarr')).toBeInTheDocument();
});
test('one report reads "1 report"', () => {
setup([{ ...baseRow, report_count: 1, reports: [baseRow.reports[0]] }]);
const row = screen.getByTestId('admin-quarantine-row');
expect(row.textContent).toMatch(/· 1 report — latest/);
expect(row.textContent).toMatch(/Show 1 report\b/);
});
test('Inline play button calls playRadio with the track id', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /play roygbiv/i }));
+1 -1
View File
@@ -255,7 +255,7 @@
>
<p class="-mt-2 text-sm text-text-secondary">
The profile says which kinds of release following
{profileRow?.artist_name ?? 'this artist'} wants. Profiles are edited under Integrations.
{profileRow?.artist_name ?? 'this artist'} wants. Profiles are edited under <a href="/collection/following" class="underline hover:no-underline">Collection › Following</a>.
</p>
<label class="mt-4 block">
+24 -13
View File
@@ -7,6 +7,7 @@
import ListContinuation from '#lib/components/ListContinuation.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte';
import EmptyState from '#lib/components/EmptyState.svelte';
import { user } from '#lib/auth/user.svelte.js';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
import type { AlbumRef } from '#lib/api/types.js';
@@ -48,19 +49,29 @@
{:else if showSkeleton.value && albums.length === 0}
<p class="text-text-secondary">Loading…</p>
{:else if !query.isPending && total === 0}
<EmptyState
title="No albums yet"
hint="Once a library folder has been scanned, albums will appear here."
>
{#snippet actions()}
<a
href="/settings"
class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90"
>
Open settings
</a>
{/snippet}
</EmptyState>
<!-- Library folders are server config (library.scan_paths), not a
setting, so only an admin can act: they run a scan from Admin.
Everyone else is told whose job it is (#5604). -->
{#if user.value?.is_admin}
<EmptyState
title="No albums yet"
hint="Library folders are set in the server's config. Once they're scanned, albums appear here."
>
{#snippet actions()}
<a
href="/admin"
class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90"
>
Run a scan
</a>
{/snippet}
</EmptyState>
{:else}
<EmptyState
title="No albums yet"
hint="Once your server's admin has scanned the library, albums appear here."
/>
{/if}
{:else if filter.trim() && filtered.length === 0}
<p class="text-text-secondary">
No albums in your loaded library match
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import { apiClientMock } from '../../../test-utils/mocks/client';
vi.mock('#lib/api/albums.js', () => ({
createAlbumsAlphaInfiniteQuery: () =>
readable({
data: { pages: [{ items: [], total: 0, limit: 50, offset: 0 }] },
isPending: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
refetch: () => {},
fetchNextPage: () => {}
}),
ALBUM_PAGE_SIZE: 50
}));
vi.mock('#lib/api/client.js', () => apiClientMock());
import Page from './+page.svelte';
import { setUser } from '#lib/auth/user.svelte.js';
import type { User } from '#lib/api/client.js';
afterEach(() => {
cleanup();
setUser(null);
});
// Library folders are server config, not a setting (#5604): the empty
// library no longer sends anyone to Settings.
describe('/library/albums with an empty library', () => {
test('an admin is sent to Admin to run a scan', () => {
setUser({ id: 'u1', username: 'root', is_admin: true } as User);
render(Page);
expect(screen.getByRole('link', { name: /run a scan/i }).getAttribute('href')).toBe('/admin');
expect(screen.queryByRole('link', { name: /open settings/i })).toBeNull();
});
test('anyone else is told the admin scans the library, with no link', () => {
setUser({ id: 'u2', username: 'alice', is_admin: false } as User);
render(Page);
expect(screen.getByText(/your server's admin has scanned the library/i)).toBeInTheDocument();
expect(screen.queryByRole('link')).toBeNull();
});
});
+24 -13
View File
@@ -7,6 +7,7 @@
import ListContinuation from '#lib/components/ListContinuation.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte';
import EmptyState from '#lib/components/EmptyState.svelte';
import { user } from '#lib/auth/user.svelte.js';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
import type { ArtistRef } from '#lib/api/types.js';
@@ -46,19 +47,29 @@
{:else if showSkeleton.value && artists.length === 0}
<p class="text-text-secondary">Loading…</p>
{:else if !query.isPending && total === 0}
<EmptyState
title="No artists yet"
hint="Once a library folder has been scanned, artists will appear here."
>
{#snippet actions()}
<a
href="/settings"
class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90"
>
Open settings
</a>
{/snippet}
</EmptyState>
<!-- Library folders are server config (library.scan_paths), not a
setting, so only an admin can act: they run a scan from Admin.
Everyone else is told whose job it is (#5604). -->
{#if user.value?.is_admin}
<EmptyState
title="No artists yet"
hint="Library folders are set in the server's config. Once they're scanned, artists appear here."
>
{#snippet actions()}
<a
href="/admin"
class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90"
>
Run a scan
</a>
{/snippet}
</EmptyState>
{:else}
<EmptyState
title="No artists yet"
hint="Once your server's admin has scanned the library, artists appear here."
/>
{/if}
{:else if filter.trim() && filtered.length === 0}
<p class="text-text-secondary">
No artists in your loaded library match