feat: Integrations splits into Library, Acquisition, Metadata sources and Email & network, each a page on the admin rail; the Overview keeps only what needs you (M503 #5607)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 16s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
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
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 16s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
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
- Library: the folders, Run scan and a row per scan job, cover art, fingerprints and loudness (off the Overview and Duplicates) - Acquisition: indexers and clients, searching, re-acquisition (off Missing files), Lidarr - Metadata sources: AcoustID, and one provider block for cover art and tags in place of the pasted pair - Email & network: outgoing email, the notification schedule, public address, proxies - Lidarr, outgoing email and the provider block are rebuilt as SettingsGroup rows; failures read as sentences, not codes - Duplicates and Missing files say what their setting is and link to where it is set Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -130,7 +130,7 @@
|
||||
{#if settings.email_unavailable_reason === 'no_address'}
|
||||
Email is off: <a href="/settings/account#profile" class="underline hover:text-text-primary">add an email address in your profile</a>.
|
||||
{:else if user.value?.is_admin}
|
||||
Email is off: <a href="/admin/integrations" class="underline hover:text-text-primary">SMTP isn't set up</a>.
|
||||
Email is off: <a href="/admin/email" class="underline hover:text-text-primary">SMTP isn't set up</a>.
|
||||
{:else}
|
||||
Email is off: this server doesn't send email.
|
||||
{/if}
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('NotificationSettings', () => {
|
||||
render(NotificationSettings);
|
||||
expect(screen.getByRole('link', { name: "SMTP isn't set up" })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/integrations'
|
||||
'/admin/email'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
|
||||
// Policy for putting an album whose files went missing back on the wanted
|
||||
// list (milestone #290, M502 #5520). Lives on the missing-files page rather
|
||||
// than under Integrations because this is where the operator meets the
|
||||
// problem it solves. Searching for a want, and retrying, is the search
|
||||
// list (milestone #290, M502 #5520). On Admin › Acquisition with the rest
|
||||
// of how music is got (#5607); Missing files says what it is set to and
|
||||
// links here. Searching for a want, and retrying, is the search
|
||||
// schedule's job, so there is no retry policy here.
|
||||
|
||||
let saved = $state<ReacquisitionSettings | null>(null);
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
if (r.gone) return 'The indexers no longer offer that release.';
|
||||
if (r.unconfigured) {
|
||||
return r.missing === 'download_client'
|
||||
? 'No download client is set up. Add one under Integrations.'
|
||||
: 'No indexer is set up. Add one under Integrations.';
|
||||
? 'No download client is set up. Add one under Admin › Acquisition.'
|
||||
: 'No indexer is set up. Add one under Admin › Acquisition.';
|
||||
}
|
||||
if (r.refused) return r.reason ? `Not taken: ${r.reason}` : 'Not taken.';
|
||||
return r.error ?? 'Not taken.';
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
<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: LidarrTestResult) {
|
||||
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>
|
||||
@@ -0,0 +1,188 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
|
||||
// One block for a list of outside sources (#5607): cover art and tags had
|
||||
// the same panel pasted twice. Each source is a row: its name and what it
|
||||
// supplies, a switch, a key field when it takes one, Save and Test.
|
||||
|
||||
type Provider = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
requires_api_key: boolean;
|
||||
supports: string[];
|
||||
enabled: boolean;
|
||||
api_key_set: boolean;
|
||||
testable: boolean;
|
||||
};
|
||||
type TestResult = { ok: boolean; duration_ms?: number; error?: string };
|
||||
type P = Provider;
|
||||
type Query = {
|
||||
data?: { providers: P[] };
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => unknown;
|
||||
};
|
||||
|
||||
let {
|
||||
title,
|
||||
hint,
|
||||
noun,
|
||||
query,
|
||||
save,
|
||||
test,
|
||||
keyPlaceholder = 'Paste an API key',
|
||||
note
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
// What the list is of, for the load error: "cover art sources".
|
||||
noun: string;
|
||||
query: Query;
|
||||
save: (id: string, patch: { enabled?: boolean; api_key?: string }) => Promise<unknown>;
|
||||
test: (id: string) => Promise<TestResult>;
|
||||
keyPlaceholder?: string;
|
||||
// A line under one source's name, when it needs one (where to get a key).
|
||||
note?: Snippet<[P]>;
|
||||
} = $props();
|
||||
|
||||
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';
|
||||
|
||||
// What a source supplies, in words rather than its capability codes.
|
||||
const SUPPLIES: Record<string, string> = {
|
||||
album_cover: 'album covers',
|
||||
artist_thumb: 'artist photos',
|
||||
artist_fanart: 'artist backdrops',
|
||||
track_tags: 'track tags'
|
||||
};
|
||||
function supplies(p: P): string {
|
||||
const words = p.supports.map((c) => SUPPLIES[c] ?? c.replace(/_/g, ' '));
|
||||
return words.length ? words.join(', ').replace(/^./, (c) => c.toUpperCase()) : '';
|
||||
}
|
||||
|
||||
// The operator's edits, kept across refetches until saved.
|
||||
let local = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
|
||||
let results = $state<Record<string, TestResult>>({});
|
||||
let saving = $state<Record<string, boolean>>({});
|
||||
let testing = $state<Record<string, boolean>>({});
|
||||
|
||||
$effect(() => {
|
||||
for (const p of query.data?.providers ?? []) {
|
||||
if (!local[p.id]) local[p.id] = { enabled: p.enabled, apiKey: '' };
|
||||
}
|
||||
});
|
||||
|
||||
function changed(p: P): boolean {
|
||||
const l = local[p.id];
|
||||
return !!l && (l.enabled !== p.enabled || l.apiKey !== '');
|
||||
}
|
||||
|
||||
async function onSave(p: P) {
|
||||
const l = local[p.id];
|
||||
if (!l || !changed(p)) return;
|
||||
const patch: { enabled?: boolean; api_key?: string } = {};
|
||||
if (l.enabled !== p.enabled) patch.enabled = l.enabled;
|
||||
if (l.apiKey !== '') patch.api_key = l.apiKey;
|
||||
saving[p.id] = true;
|
||||
try {
|
||||
await save(p.id, patch);
|
||||
local[p.id] = { enabled: l.enabled, apiKey: '' };
|
||||
} catch (e) {
|
||||
pushToast(`Couldn't save ${p.display_name}: ${errMessage(e)}`, 'error');
|
||||
} finally {
|
||||
saving[p.id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest(p: P) {
|
||||
testing[p.id] = true;
|
||||
try {
|
||||
results[p.id] = await test(p.id);
|
||||
} catch (e) {
|
||||
results[p.id] = { ok: false, error: errMessage(e) };
|
||||
} finally {
|
||||
testing[p.id] = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsGroup {title} {hint}>
|
||||
{#if query.data}
|
||||
{#each query.data.providers as p (p.id)}
|
||||
{@const l = local[p.id]}
|
||||
{@const r = results[p.id]}
|
||||
<div data-testid="provider" data-provider={p.id}>
|
||||
<SettingRow label={p.display_name} for="src-{p.id}">
|
||||
{#snippet hint()}
|
||||
{supplies(p)}
|
||||
{#if note}{' · '}{@render note(p)}{/if}
|
||||
{/snippet}
|
||||
{#if l}
|
||||
<input
|
||||
id="src-{p.id}"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={l.enabled}
|
||||
bind:checked={l.enabled}
|
||||
class="h-4 w-4 cursor-pointer accent-accent"
|
||||
/>
|
||||
{/if}
|
||||
</SettingRow>
|
||||
{#if l}
|
||||
<div class="flex flex-wrap items-center gap-2 px-4 pb-3">
|
||||
{#if p.requires_api_key}
|
||||
<input
|
||||
type="password"
|
||||
aria-label="{p.display_name} API key"
|
||||
placeholder={p.api_key_set ? 'Key saved · leave empty to keep it' : keyPlaceholder}
|
||||
bind:value={l.apiKey}
|
||||
class="w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!changed(p) || saving[p.id]}
|
||||
onclick={() => onSave(p)}
|
||||
class={button}>{saving[p.id] ? 'Saving…' : 'Save'}</button
|
||||
>
|
||||
{#if p.testable}
|
||||
<button type="button" disabled={testing[p.id]} onclick={() => onTest(p)} class={button}>
|
||||
{testing[p.id] ? 'Testing…' : 'Test'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if r}
|
||||
<span class="text-xs {r.ok ? 'text-text-secondary' : 'text-error-fg'}" data-testid="provider-test">
|
||||
{#if r.ok}
|
||||
Works{r.duration_ms ? ` · answered in ${r.duration_ms} ms` : ''}
|
||||
{:else}
|
||||
Didn't work: {r.error}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else if query.isPending}
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
|
||||
{:else if query.isError}
|
||||
<p class="px-4 py-3 text-sm text-error-fg" data-testid="providers-error">
|
||||
Couldn't load the {noun}.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">None available.</p>
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createSMTPConfigQuery, updateSMTPConfig, testSMTPConfig, type SMTPConfig } from '#lib/api/admin.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import { errCode, errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
|
||||
// Outgoing email (#5607): the mail server password resets and
|
||||
// notifications go out through. Send test mails the signed-in admin.
|
||||
|
||||
const client = useQueryClient();
|
||||
const smtpStore = createSMTPConfigQuery();
|
||||
const smtpQ = $derived($smtpStore);
|
||||
|
||||
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';
|
||||
|
||||
let form = $state<SMTPConfig>({
|
||||
enabled: false,
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from_address: '',
|
||||
from_name: 'Minstrel',
|
||||
use_tls: true
|
||||
});
|
||||
let passwordInput = $state('');
|
||||
let saving = $state(false);
|
||||
let testing = $state(false);
|
||||
|
||||
// Filled from the saved config once, when it first loads, and again after
|
||||
// a save, so a refetch doesn't throw edits away. Until it has loaded the
|
||||
// form holds defaults, so Save stays off: saving then would write the
|
||||
// defaults over the real config (#5604).
|
||||
let loaded = $state(false);
|
||||
$effect(() => {
|
||||
if (smtpQ.data && !loaded) {
|
||||
form = { ...smtpQ.data };
|
||||
passwordInput = '';
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function onSave() {
|
||||
saving = true;
|
||||
try {
|
||||
await updateSMTPConfig({ ...form, password: passwordInput });
|
||||
loaded = false;
|
||||
await client.invalidateQueries({ queryKey: qk.smtpConfig() });
|
||||
passwordInput = '';
|
||||
pushToast('Email settings saved.');
|
||||
} catch (e: unknown) {
|
||||
if (errCode(e) === 'missing_fields') pushToast('Server and from address are needed to send.', 'error');
|
||||
else pushToast(`Not saved: ${errMessage(e)}`, 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
testing = true;
|
||||
try {
|
||||
await testSMTPConfig();
|
||||
pushToast('Test email sent. Check your inbox.');
|
||||
} 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 › Account before testing.', 'error');
|
||||
else if (code === 'not_configured') pushToast('Save the email settings first.', 'error');
|
||||
else if (code === 'send_failed') pushToast(`Not sent: ${message || 'the server log says why'}`, 'error');
|
||||
else pushToast(`Not sent: ${errMessage(e)}`, 'error');
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsGroup title="Outgoing email" id="email" hint="For password resets and notifications.">
|
||||
{#if smtpQ.isError && !loaded}
|
||||
<p class="px-4 py-3 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}
|
||||
<SettingRow label="Send email" for="smtp-enabled">
|
||||
<input
|
||||
id="smtp-enabled"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={form.enabled}
|
||||
bind:checked={form.enabled}
|
||||
class="h-4 w-4 cursor-pointer accent-accent"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Server" for="smtp-host">
|
||||
<input id="smtp-host" type="text" bind:value={form.host} placeholder="smtp.example.com" class={field} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Port" for="smtp-port">
|
||||
<input id="smtp-port" type="number" min="1" max="65535" bind:value={form.port} class="w-24 rounded-md border border-border bg-background px-3 py-1.5 text-sm" />
|
||||
</SettingRow>
|
||||
<SettingRow label="STARTTLS" for="smtp-tls" hint="Encrypt the connection to the server.">
|
||||
<input
|
||||
id="smtp-tls"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={form.use_tls}
|
||||
bind:checked={form.use_tls}
|
||||
class="h-4 w-4 cursor-pointer accent-accent"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Username" for="smtp-username">
|
||||
<input id="smtp-username" type="text" bind:value={form.username} class={field} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Password" for="smtp-password">
|
||||
<input
|
||||
id="smtp-password"
|
||||
type="password"
|
||||
placeholder={form.password === '***' ? 'Saved · leave empty to keep it' : ''}
|
||||
bind:value={passwordInput}
|
||||
class={field}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="From address" for="smtp-from">
|
||||
<input id="smtp-from" type="email" bind:value={form.from_address} placeholder="minstrel@example.com" class={field} />
|
||||
</SettingRow>
|
||||
<SettingRow label="From name" for="smtp-from-name">
|
||||
<input id="smtp-from-name" type="text" bind:value={form.from_name} class={field} />
|
||||
</SettingRow>
|
||||
<div class="flex flex-wrap items-center gap-2 px-4 py-3">
|
||||
<button type="button" disabled={testing} onclick={onTest} class={button}>
|
||||
{testing ? 'Sending…' : 'Send test email'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !loaded}
|
||||
onclick={onSave}
|
||||
class="rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { SMTPConfig } from '#lib/api/admin.js';
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
|
||||
});
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createSMTPConfigQuery: vi.fn(),
|
||||
updateSMTPConfig: vi.fn(),
|
||||
testSMTPConfig: vi.fn()
|
||||
}));
|
||||
|
||||
import SmtpSettings from './SmtpSettings.svelte';
|
||||
import { createSMTPConfigQuery, updateSMTPConfig, testSMTPConfig } from '#lib/api/admin.js';
|
||||
|
||||
const configured: SMTPConfig = {
|
||||
enabled: true,
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '***',
|
||||
from_address: 'noreply@example.com',
|
||||
from_name: 'Minstrel',
|
||||
use_tls: true
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(q = mockQuery({ data: configured })) {
|
||||
vi.mocked(createSMTPConfigQuery).mockReturnValue(q as never);
|
||||
return render(SmtpSettings);
|
||||
}
|
||||
|
||||
describe('SmtpSettings', () => {
|
||||
test('the saved settings fill the rows, and a saved password is kept unless one is typed', async () => {
|
||||
setup();
|
||||
expect((screen.getByLabelText(/^server$/i) as HTMLInputElement).value).toBe('smtp.example.com');
|
||||
expect(screen.getByPlaceholderText(/saved · leave empty to keep it/i)).toBeInTheDocument();
|
||||
vi.mocked(updateSMTPConfig).mockResolvedValue(undefined as never);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(updateSMTPConfig).toHaveBeenCalledWith(expect.objectContaining({ host: 'smtp.example.com', password: '' })));
|
||||
});
|
||||
|
||||
test('a typed password is sent', async () => {
|
||||
setup();
|
||||
vi.mocked(updateSMTPConfig).mockResolvedValue(undefined as never);
|
||||
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'newsecret' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(updateSMTPConfig).toHaveBeenCalledWith(expect.objectContaining({ password: 'newsecret' })));
|
||||
});
|
||||
|
||||
test('Send test email sends one, and with no address on file says where to set it', async () => {
|
||||
setup();
|
||||
vi.mocked(testSMTPConfig).mockRejectedValueOnce({ code: 'no_email_on_file' });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /send test email/i }));
|
||||
await waitFor(() => expect(testSMTPConfig).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/set your email in settings › account/i));
|
||||
});
|
||||
|
||||
test('when the settings fail to load, Save is off, so defaults are never written over them', () => {
|
||||
setup(mockQuery({ isError: true, error: { code: 'server_error' } }));
|
||||
expect(screen.getByTestId('smtp-error')).toBeInTheDocument();
|
||||
expect((screen.getByRole('button', { name: /^save$/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user