From 7c89e5953675ecbb754b3042ac390ce3702559e6 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 11 Oct 2026 09:40:11 -0400
Subject: [PATCH] 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)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
web/src/routes/admin/+page.svelte | 29 +++++++++--
web/src/routes/admin/admin.test.ts | 42 ++++++++++++++-
web/src/routes/admin/duplicates/+page.svelte | 12 +++--
.../admin/duplicates/duplicates.test.ts | 9 ++++
.../routes/admin/integrations/+page.svelte | 52 +++++++++++++++----
.../admin/integrations/integrations.test.ts | 37 ++++++++++++-
.../routes/admin/playback-errors/+page.svelte | 19 ++++---
.../playback-errors/playback-errors.test.ts | 48 +++++++++++++++++
web/src/routes/admin/quarantine/+page.svelte | 44 ++++++----------
.../admin/quarantine/quarantine.test.ts | 24 +++++----
web/src/routes/admin/requests/+page.svelte | 2 +-
web/src/routes/library/albums/+page.svelte | 37 ++++++++-----
web/src/routes/library/albums/empty.test.ts | 46 ++++++++++++++++
web/src/routes/library/artists/+page.svelte | 37 ++++++++-----
14 files changed, 343 insertions(+), 95 deletions(-)
create mode 100644 web/src/routes/admin/playback-errors/playback-errors.test.ts
create mode 100644 web/src/routes/library/albums/empty.test.ts
diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte
index 7f04dfbf..acec5dec 100644
--- a/web/src/routes/admin/+page.svelte
+++ b/web/src/routes/admin/+page.svelte
@@ -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"
>
Pending requests
- {pendingCount}
+ {tile(requests, pendingCount)}
Quarantined tracks
- {quarantineCount}
+ {tile(quarantine, quarantineCount)}
Lidarr
- {lidarrConnected ? 'Connected' : 'Unset'}
+ {config.isError ? '—' : lidarrConnected ? 'Connected' : 'Unset'}
@@ -296,6 +305,11 @@
{#if requests.isPending}
Loading…
+ {:else if requests.isError}
+
+ Couldn't load the requests.
+ requests.refetch()}>Try again
+
{:else if requestPreview.length === 0}
No pending requests.
{:else}
@@ -529,6 +543,11 @@
{#if quarantine.isPending}
Loading…
+ {:else if quarantine.isError}
+
+ Couldn't load the quarantine.
+ quarantine.refetch()}>Try again
+
{:else if quarantinePreview.length === 0}
No quarantined tracks.
{:else}
@@ -568,6 +587,9 @@
>
{fileArmed ? 'Confirm?' : 'Delete file'}
+
+ {#if lidarrConnected && row.lidarr_album_mbid}
onDeleteLidarr(row)}
@@ -578,6 +600,7 @@
>
{lidarrArmed ? 'Confirm?' : 'Delete via Lidarr'}
+ {/if}
{
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).mockReturnValue(
+ mockQuery({ isError: true, error: new Error('boom'), refetch })
+ );
+ (createAdminQuarantineQuery as ReturnType).mockReturnValue(
+ mockQuery({ isError: true, error: new Error('boom') })
+ );
+ (createLidarrConfigQuery as ReturnType).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) => ({
diff --git a/web/src/routes/admin/duplicates/+page.svelte b/web/src/routes/admin/duplicates/+page.svelte
index 35f39834..a9506f99 100644
--- a/web/src/routes/admin/duplicates/+page.svelte
+++ b/web/src/routes/admin/duplicates/+page.svelte
@@ -432,7 +432,8 @@
+ {#if smtpQ.isError && !smtpLoaded}
+
+ Couldn't load the email settings, so they can't be saved yet.
+ smtpQ.refetch()}>Try again
+
+ {/if}
+
Enabled
@@ -807,7 +837,7 @@
-
{smtpSaving ? 'Saving…' : 'Save'}
diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts
index b326a93d..0c94f567 100644
--- a/web/src/routes/admin/integrations/integrations.test.ts
+++ b/web/src/routes/admin/integrations/integrations.test.ts
@@ -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
).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).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).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).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"
diff --git a/web/src/routes/admin/playback-errors/+page.svelte b/web/src/routes/admin/playback-errors/+page.svelte
index 66be479a..3c659e06 100644
--- a/web/src/routes/admin/playback-errors/+page.svelte
+++ b/web/src/routes/admin/playback-errors/+page.svelte
@@ -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(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}
This removes {deleteFileOpen.track_title}
- 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.
{deleteFileOpen.track_file_path}
diff --git a/web/src/routes/admin/playback-errors/playback-errors.test.ts b/web/src/routes/admin/playback-errors/playback-errors.test.ts
new file mode 100644
index 00000000..ae4f5105
--- /dev/null
+++ b/web/src/routes/admin/playback-errors/playback-errors.test.ts
@@ -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
+ );
+ 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));
+ });
+});
diff --git a/web/src/routes/admin/quarantine/+page.svelte b/web/src/routes/admin/quarantine/+page.svelte
index 0c13ed86..9c544a07 100644
--- a/web/src/routes/admin/quarantine/+page.svelte
+++ b/web/src/routes/admin/quarantine/+page.svelte
@@ -170,7 +170,6 @@
{#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
+ }]
+ : [])
]}
- {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)}
@@ -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'}`}
{#if isExpanded}
@@ -273,29 +284,6 @@
- {#if lidarrDisabled}
-
-
-
- Delete via Lidarr
-
- {:else}
- openDeleteLidarr(r)}
- >
-
-
- Delete via Lidarr
-
- {/if}
{/each}
@@ -311,7 +299,7 @@
{#if deleteFileRow}
Remove {deleteFileRow.track_title}
- 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.
{
);
});
- 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 }));
diff --git a/web/src/routes/admin/requests/+page.svelte b/web/src/routes/admin/requests/+page.svelte
index 325eca16..d0e432b0 100644
--- a/web/src/routes/admin/requests/+page.svelte
+++ b/web/src/routes/admin/requests/+page.svelte
@@ -255,7 +255,7 @@
>
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 Collection › Following .
diff --git a/web/src/routes/library/albums/+page.svelte b/web/src/routes/library/albums/+page.svelte
index 3925c72a..a52956b2 100644
--- a/web/src/routes/library/albums/+page.svelte
+++ b/web/src/routes/library/albums/+page.svelte
@@ -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}
Loading…
{:else if !query.isPending && total === 0}
-
- {#snippet actions()}
-
- Open settings
-
- {/snippet}
-
+
+ {#if user.value?.is_admin}
+
+ {#snippet actions()}
+
+ Run a scan
+
+ {/snippet}
+
+ {:else}
+
+ {/if}
{:else if filter.trim() && filtered.length === 0}
No albums in your loaded library match
diff --git a/web/src/routes/library/albums/empty.test.ts b/web/src/routes/library/albums/empty.test.ts
new file mode 100644
index 00000000..e6d96d77
--- /dev/null
+++ b/web/src/routes/library/albums/empty.test.ts
@@ -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();
+ });
+});
diff --git a/web/src/routes/library/artists/+page.svelte b/web/src/routes/library/artists/+page.svelte
index e38808d1..6b070c33 100644
--- a/web/src/routes/library/artists/+page.svelte
+++ b/web/src/routes/library/artists/+page.svelte
@@ -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}
Loading…
{:else if !query.isPending && total === 0}
-
- {#snippet actions()}
-
- Open settings
-
- {/snippet}
-
+
+ {#if user.value?.is_admin}
+
+ {#snippet actions()}
+
+ Run a scan
+
+ {/snippet}
+
+ {:else}
+
+ {/if}
{:else if filter.trim() && filtered.length === 0}
No artists in your loaded library match