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
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:
@@ -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"
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user