From 7e1cfb8d0858985d9db566c2c427a81b268cb531 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 11 Oct 2026 11:21:48 -0400 Subject: [PATCH] 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) - 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 --- .../com/fabledsword/minstrel/api/ErrorCopy.kt | 4 +- docs/hosting.md | 4 +- docs/screenshots/README.md | 2 +- internal/api/auth_forgot.go | 2 +- internal/lidarr/types.go | 4 +- internal/server/server.go | 2 +- .../components/NotificationSettings.svelte | 2 +- .../components/NotificationSettings.test.ts | 2 +- .../ReacquisitionSettingsCard.svelte | 6 +- web/src/lib/components/WantCandidates.svelte | 4 +- .../components/admin/LidarrSettings.svelte | 327 +++++++ .../components/admin/LidarrSettings.test.ts | 188 ++++ .../lib/components/admin/ProviderGroup.svelte | 180 ++++ .../lib/components/admin/SmtpSettings.svelte | 146 +++ .../lib/components/admin/SmtpSettings.test.ts | 69 ++ web/src/lib/styles/error-copy.json | 4 +- web/src/routes/admin/+layout.svelte | 5 +- web/src/routes/admin/+page.svelte | 306 +----- web/src/routes/admin/acquisition/+page.svelte | 27 + web/src/routes/admin/admin.test.ts | 69 +- web/src/routes/admin/duplicates/+page.svelte | 19 +- .../admin/duplicates/duplicates.test.ts | 19 +- web/src/routes/admin/email/+page.svelte | 27 + .../routes/admin/integrations/+page.svelte | 898 ------------------ .../admin/integrations/integrations.test.ts | 808 ---------------- web/src/routes/admin/library/+page.svelte | 214 +++++ web/src/routes/admin/library/library.test.ts | 110 +++ web/src/routes/admin/metadata/+page.svelte | 92 ++ .../routes/admin/metadata/metadata.test.ts | 174 ++++ .../routes/admin/missing-files/+page.svelte | 33 +- .../admin/missing-files/missing-files.test.ts | 15 +- web/src/routes/collection/+layout.svelte | 2 +- 32 files changed, 1649 insertions(+), 2115 deletions(-) create mode 100644 web/src/lib/components/admin/LidarrSettings.svelte create mode 100644 web/src/lib/components/admin/LidarrSettings.test.ts create mode 100644 web/src/lib/components/admin/ProviderGroup.svelte create mode 100644 web/src/lib/components/admin/SmtpSettings.svelte create mode 100644 web/src/lib/components/admin/SmtpSettings.test.ts create mode 100644 web/src/routes/admin/acquisition/+page.svelte create mode 100644 web/src/routes/admin/email/+page.svelte delete mode 100644 web/src/routes/admin/integrations/+page.svelte delete mode 100644 web/src/routes/admin/integrations/integrations.test.ts create mode 100644 web/src/routes/admin/library/+page.svelte create mode 100644 web/src/routes/admin/library/library.test.ts create mode 100644 web/src/routes/admin/metadata/+page.svelte create mode 100644 web/src/routes/admin/metadata/metadata.test.ts diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt index 60fe379f..6a795d74 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt @@ -106,12 +106,12 @@ object ErrorCopy { "This server is on the internet, so its URL must start with https://. " + "Plain http:// only works on your home network.", "lidarr_unreachable" to - "Lidarr is unreachable right now. Try again, or check Admin → Integrations.", + "Lidarr is unreachable right now. Try again, or check Admin › Acquisition.", "lidarr_disabled" to "Lidarr integration is not enabled.", "lidarr_auth_failed" to "Lidarr authentication failed.", "lidarr_defaults_incomplete" to "Lidarr is missing a default quality profile or root folder. " + - "Set them in Admin → Integrations.", + "Set them in Admin › Acquisition.", "lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.", "lidarr_rejected" to "Lidarr rejected the request. Check the server logs for the field-level reason.", diff --git a/docs/hosting.md b/docs/hosting.md index 10ede42e..ab544713 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -13,7 +13,7 @@ Publish the port to the network and use `http://:4533`: ports: ['4533:4533'] ``` -If nothing sits in front of Minstrel, set **Admin → Integrations → Client IP +If nothing sits in front of Minstrel, set **Admin › Email & network → Client IP detection** to 0. It defaults to 1, which assumes one proxy (see below for why that matters). @@ -48,7 +48,7 @@ Three things, all required: the app is. Raise the proxy's read timeout well past a minute (nginx: `proxy_read_timeout 1h;`). -3. **Tell Minstrel where it lives**, in **Admin → Integrations**: +3. **Tell Minstrel where it lives**, in **Admin › Email & network**: - **Client IP detection:** the number of proxies in front of Minstrel. One proxy (Caddy, Traefik, nginx) is 1; Cloudflare in front of Traefik is 2. Minstrel uses this to find the real client address, for login rate diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index 3aae8e97..d333d32d 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -11,7 +11,7 @@ across all shots for a consistent look. | `home.png` | `/` | The home page with a populated library (system playlists / recently added) | | `register.png` | `/register` | The account-creation form on a fresh instance | | `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) | -| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards | +| `integrations.png` | `/admin/acquisition` | Indexers, download clients, searching and Lidarr | | `android-download.png` | `/settings` | The "Install the Android app" download card | | `invite-users.png` | `/admin/users` | The invite-token generation / users list | diff --git a/internal/api/auth_forgot.go b/internal/api/auth_forgot.go index 6d652437..e85fdc91 100644 --- a/internal/api/auth_forgot.go +++ b/internal/api/auth_forgot.go @@ -133,7 +133,7 @@ func (h *handlers) sendResetEmail(ctx context.Context, user dbq.User) error { // errNoPublicURL stops a reset email from going out before the operator has // said where Minstrel lives. It surfaces in the log, not the response, which // stays the same opaque 200 either way. -var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin → Integrations → Public address); reset email not sent") +var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin › Email & network › Public address); reset email not sent") // buildResetURL builds the emailed link from the operator-set public URL. // It deliberately ignores the request: the Host header is whatever the diff --git a/internal/lidarr/types.go b/internal/lidarr/types.go index 3fd9db36..8fdef82f 100644 --- a/internal/lidarr/types.go +++ b/internal/lidarr/types.go @@ -25,7 +25,7 @@ type lidarrImage struct { URL string `json:"url"` } -// QualityProfile is the dropdown choice in /admin/integrations. +// QualityProfile is the dropdown choice on Admin › Acquisition. type QualityProfile struct { ID int Name string @@ -41,7 +41,7 @@ type MetadataProfile struct { Name string } -// RootFolder is the dropdown choice in /admin/integrations. +// RootFolder is the dropdown choice on Admin › Acquisition. type RootFolder struct { Path string Accessible bool diff --git a/internal/server/server.go b/internal/server/server.go index 7d325e78..8fcc1fbe 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -161,7 +161,7 @@ func (s *Server) Router() http.Handler { ) lidarrCfg := lidarrconfig.New(s.Pool) // Per-call client factory: re-reads config so an admin save in - // /admin/integrations takes effect immediately without restart. + // Admin › Acquisition takes effect immediately without restart. // Returns nil when Lidarr is disabled, which the Service-layer // methods translate to ErrLidarrDisabled. lidarrClientFn := func() *lidarr.Client { diff --git a/web/src/lib/components/NotificationSettings.svelte b/web/src/lib/components/NotificationSettings.svelte index b186ba84..f2ea490a 100644 --- a/web/src/lib/components/NotificationSettings.svelte +++ b/web/src/lib/components/NotificationSettings.svelte @@ -130,7 +130,7 @@ {#if settings.email_unavailable_reason === 'no_address'} Email is off: add an email address in your profile. {:else if user.value?.is_admin} - Email is off: SMTP isn't set up. + Email is off: SMTP isn't set up. {:else} Email is off: this server doesn't send email. {/if} diff --git a/web/src/lib/components/NotificationSettings.test.ts b/web/src/lib/components/NotificationSettings.test.ts index 937ca6b3..ee61f279 100644 --- a/web/src/lib/components/NotificationSettings.test.ts +++ b/web/src/lib/components/NotificationSettings.test.ts @@ -123,7 +123,7 @@ describe('NotificationSettings', () => { render(NotificationSettings); expect(screen.getByRole('link', { name: "SMTP isn't set up" })).toHaveAttribute( 'href', - '/admin/integrations' + '/admin/email' ); }); }); diff --git a/web/src/lib/components/ReacquisitionSettingsCard.svelte b/web/src/lib/components/ReacquisitionSettingsCard.svelte index 437faeca..569ac884 100644 --- a/web/src/lib/components/ReacquisitionSettingsCard.svelte +++ b/web/src/lib/components/ReacquisitionSettingsCard.svelte @@ -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(null); diff --git a/web/src/lib/components/WantCandidates.svelte b/web/src/lib/components/WantCandidates.svelte index 878eabc8..8d48e404 100644 --- a/web/src/lib/components/WantCandidates.svelte +++ b/web/src/lib/components/WantCandidates.svelte @@ -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.'; diff --git a/web/src/lib/components/admin/LidarrSettings.svelte b/web/src/lib/components/admin/LidarrSettings.svelte new file mode 100644 index 00000000..83c1158d --- /dev/null +++ b/web/src/lib/components/admin/LidarrSettings.svelte @@ -0,0 +1,327 @@ + + +{#snippet listError(key: string)} + {#if testListErrors[key]} +

Couldn't read Lidarr's {LIST_NAMES[key]}.

+ {/if} +{/snippet} + + + + + {#if testResult?.ok} + Connected · Lidarr {testResult.version} + {:else if config.data?.enabled} + Connected + {:else} + Not connected + {/if} + + + + + + + + + + + {@render listError('quality_profiles')} + + + + {@render listError('metadata_profiles')} + + + + {@render listError('root_folders')} + +
+ + + {#if config.data?.enabled} + + {/if} + {#if testResult && !testResult.ok && !saveError} +

{said(testResult.error)}

+ {/if} + {#if saveError} +

Not saved: {saveError}

+ {/if} +
+
+ + +

+ This clears the saved connection. Type + DISCONNECT to go ahead. +

+ + {#if disconnectError} +

Not disconnected: {disconnectError}

+ {/if} +
+ + +
+
diff --git a/web/src/lib/components/admin/LidarrSettings.test.ts b/web/src/lib/components/admin/LidarrSettings.test.ts new file mode 100644 index 00000000..3b21a247 --- /dev/null +++ b/web/src/lib/components/admin/LidarrSettings.test.ts @@ -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; + 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(); + }); +}); diff --git a/web/src/lib/components/admin/ProviderGroup.svelte b/web/src/lib/components/admin/ProviderGroup.svelte new file mode 100644 index 00000000..8a2c4296 --- /dev/null +++ b/web/src/lib/components/admin/ProviderGroup.svelte @@ -0,0 +1,180 @@ + + + + {#if query.data} + {#each query.data.providers as p (p.id)} + {@const l = local[p.id]} + {@const r = results[p.id]} +
+ + {#snippet hint()} + {supplies(p)} + {#if note}{' · '}{@render note(p)}{/if} + {/snippet} + {#if l} + + {/if} + + {#if l} +
+ {#if p.requires_api_key} + + {/if} + + {#if p.testable} + + {/if} + {#if r} + + {#if r.ok} + Works{r.duration_ms ? ` · answered in ${r.duration_ms} ms` : ''} + {:else} + Didn't work: {r.error} + {/if} + + {/if} +
+ {/if} +
+ {/each} + {:else if query.isPending} +

Loading…

+ {:else if query.isError} +

+ Couldn't load the {noun}. + +

+ {:else} +

None available.

+ {/if} +
diff --git a/web/src/lib/components/admin/SmtpSettings.svelte b/web/src/lib/components/admin/SmtpSettings.svelte new file mode 100644 index 00000000..be408305 --- /dev/null +++ b/web/src/lib/components/admin/SmtpSettings.svelte @@ -0,0 +1,146 @@ + + + + {#if smtpQ.isError && !loaded} +

+ Couldn't load the email settings, so they can't be saved yet. + +

+ {/if} + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
diff --git a/web/src/lib/components/admin/SmtpSettings.test.ts b/web/src/lib/components/admin/SmtpSettings.test.ts new file mode 100644 index 00000000..ab52310b --- /dev/null +++ b/web/src/lib/components/admin/SmtpSettings.test.ts @@ -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; + 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); + }); +}); diff --git a/web/src/lib/styles/error-copy.json b/web/src/lib/styles/error-copy.json index 4197716c..5a0aa2b6 100644 --- a/web/src/lib/styles/error-copy.json +++ b/web/src/lib/styles/error-copy.json @@ -34,10 +34,10 @@ "system_playlist_readonly": "System playlists can't be edited directly.", "connection_refused": "Couldn't reach the server. Check the URL and try again.", "musicbrainz_unreachable": "MusicBrainz can't be reached right now, so search is unavailable. Try again in a minute.", - "lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin → Integrations.", + "lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin › Acquisition.", "lidarr_disabled": "Lidarr integration is not enabled.", "lidarr_auth_failed": "Lidarr authentication failed.", - "lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin → Integrations.", + "lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin › Acquisition.", "lidarr_server_error": "Lidarr returned an error. Check Lidarr's logs for the cause.", "lidarr_rejected": "Lidarr rejected the request. Check the server logs for the field-level reason.", "lidarr_album_lookup_failed": "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.", diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte index 4ee0d968..03c384ba 100644 --- a/web/src/routes/admin/+layout.svelte +++ b/web/src/routes/admin/+layout.svelte @@ -32,7 +32,10 @@ { href: '/admin/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') }, { href: '/admin/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') }, { href: '/admin/missing-files', label: 'Missing files', attention: countOf(summary, 'missing') }, - { href: '/admin/integrations', label: 'Integrations' }, + { href: '/admin/library', label: 'Library' }, + { href: '/admin/acquisition', label: 'Acquisition' }, + { href: '/admin/metadata', label: 'Metadata sources' }, + { href: '/admin/email', label: 'Email & network' }, { href: '/admin/users', label: 'Users' }, { href: '/admin/diagnostics', label: 'Diagnostics' }, { href: '/admin/tuning', label: 'Tuning' } diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index b88e8b86..3b77b7a9 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -1,21 +1,6 @@ {pageTitle('Admin · Overview')} @@ -141,11 +47,14 @@ {#if problems > 0}
    {#if scan?.error_message} -
  • - The last library scan failed: {scan.error_message} - {#if scan.finished_at} - {relativeTime(scan.finished_at)} - {/if} +
  • + + The last library scan failed: {scan.error_message} + {#if scan.finished_at} + {relativeTime(scan.finished_at)} + {/if} + Open +
  • {/if} {#each waiting as d (d.key)} @@ -169,194 +78,5 @@ {/if} {/if} - - -
    -
    -
    -

    Library scan

    -

    - Walks the music tree, fills missing MusicBrainz IDs from tags, and refetches album covers. -

    -
    - -
    - - {#if !scan || !scan.id} -

    No scan has run yet.

    - {:else} -
    - -
    -
    -
    Library walk
    - -
    - {#if scan.library} -
    -
    Scanned
    {scan.library.scanned}
    -
    Added
    {scan.library.added}
    -
    Updated
    {scan.library.updated}
    -
    Skipped
    {scan.library.skipped}
    -
    Errored
    {scan.library.errored}
    -
    - {:else if stageState(scan, 'library') === 'pending'} -

    Waiting for previous stage…

    - {/if} -
    - - -
    -
    -
    MBID backfill
    - -
    - {#if scan.mbid_backfill} -
    -
    Processed
    {scan.mbid_backfill.processed}
    -
    Healed
    {scan.mbid_backfill.healed}
    -
    Skipped
    {scan.mbid_backfill.skipped}
    -
    Duplicates
    {scan.mbid_backfill.duplicates ?? 0}
    -
    - {:else if stageState(scan, 'mbid_backfill') === 'pending'} -

    Waiting for previous stage…

    - {/if} -
    - - -
    -
    -
    Cover enrichment
    - -
    - {#if scan.cover_enrich} -
    -
    Processed
    {scan.cover_enrich.processed}
    -
    Succeeded
    {scan.cover_enrich.succeeded}
    -
    Failed
    {scan.cover_enrich.failed}
    -
    - {:else if stageState(scan, 'cover_enrich') === 'pending'} -

    Waiting for previous stage…

    - {/if} -
    - - -
    -
    -
    Artist art
    - -
    - {#if scan.artist_art_enrich} -
    -
    Processed
    {scan.artist_art_enrich.processed}
    -
    Succeeded
    {scan.artist_art_enrich.succeeded}
    -
    Failed
    {scan.artist_art_enrich.failed}
    -
    - {:else if stageState(scan, 'artist_art_enrich') === 'pending'} -

    Waiting for previous stage…

    - {/if} -
    -
    - -
    - Started {formatTime(scan.started_at)} - {#if scan.finished_at}· Finished {formatTime(scan.finished_at)}{/if} - {#if scan.error_message}· Error: {scan.error_message}{/if} -
    - {/if} - - {#if triggerResult} -

    {triggerResult}

    - {/if} - - - {#if fingerprints && fingerprints.total > 0} -
    - Fingerprints - {fingerprints.fingerprinted.toLocaleString()} of {fingerprints.total.toLocaleString()} tracks - {#if fingerprints.pending > 0} - · - {fingerprints.pending.toLocaleString()} pending - {/if} - {#if fingerprints.enabled === false} - - · - fingerprinting is off - {/if} - {#if fingerprints.rejected > 0} - · - - {fingerprints.rejected.toLocaleString()} unreadable - - {/if} -
    - {/if} -
    - - - - - -
    -

    Cover art

    -

    - Refetch cover art for albums that are missing one or where the previous attempt failed. -

    -
    - - - - {#if coverage} - {#if coverage.total === 0} - Library is empty. - {:else} -
    - {coverage.with_art.toLocaleString()} with art - · - 0 ? 'cursor-help' : ''} - title={coverage.pending_no_mbid > 0 - ? `${coverage.pending_no_mbid.toLocaleString()} of these have no MBID and won't be helped by another scan — fix tags or merge duplicate albums.` - : undefined} - > - {coverage.pending.toLocaleString()} pending - - · - {coverage.settled.toLocaleString()} settled -
    - {/if} - {/if} -
    - {#if bulkResult} -

    {bulkResult}

    - {/if} -
    - diff --git a/web/src/routes/admin/acquisition/+page.svelte b/web/src/routes/admin/acquisition/+page.svelte new file mode 100644 index 00000000..c71cc1b5 --- /dev/null +++ b/web/src/routes/admin/acquisition/+page.svelte @@ -0,0 +1,27 @@ + + +{pageTitle('Admin · Acquisition')} + +

    Acquisition

    + + + {#snippet main()} + + + {/snippet} + {#snippet side()} +
    + + {/snippet} +
    diff --git a/web/src/routes/admin/admin.test.ts b/web/src/routes/admin/admin.test.ts index ece41c08..86a84c19 100644 --- a/web/src/routes/admin/admin.test.ts +++ b/web/src/routes/admin/admin.test.ts @@ -26,30 +26,7 @@ vi.mock('#lib/api/admin.js', async () => { isPending: false, isError: false }) - ), - createCoverageQuery: () => - readable({ - data: undefined, - isPending: false, - isError: false - }), - createFingerprintCoverageQuery: () => - readable({ - data: undefined, - isPending: false, - isError: false - }), - triggerScan: vi.fn().mockResolvedValue({}), - refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }), - researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }), - // LoudnessSettingsCard, rendered by the page and tested on its own. - getLoudnessSettings: vi - .fn() - .mockResolvedValue({ enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 }), - updateLoudnessSettings: vi.fn(), - getLoudnessCoverage: vi.fn().mockResolvedValue({ - total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true - }) + ) }; }); @@ -88,11 +65,7 @@ describe('admin/+layout.ts load gate', () => { // where the page picked up empty data despite `mockReturnValue` being set. // Importing at module load means the mocks attach to the same handles the // page binds against. -import { - createAdminSummaryQuery, - createScanStatusQuery, - researchMissingArt -} from '#lib/api/admin.js'; +import { createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js'; import type { AdminSummary } from '#lib/api/types.js'; import OverviewPage from './+page.svelte'; import AdminLayout from './+layout.svelte'; @@ -155,7 +128,9 @@ describe('admin Overview page', () => { setupOverview(); render(OverviewPage); expect(screen.getByTestId('verdict').textContent).toMatch(/1 thing needs you/); - expect(screen.getByText(/the last library scan failed: library root not mounted/i)).toBeInTheDocument(); + expect(screen.getByText(/the last library scan failed: library root not mounted/i).closest('a')?.getAttribute('href')).toBe( + '/admin/library' + ); }); test('when the queues fail to load it says so, with Try again, rather than reading as all-clear', async () => { @@ -167,35 +142,6 @@ describe('admin Overview page', () => { await fireEvent.click(screen.getByRole('button', { name: /try again/i })); expect(refetch).toHaveBeenCalled(); }); - - test('renders Re-search missing art button and invokes on click', async () => { - setupOverview(); - render(OverviewPage); - const btn = screen.getByRole('button', { name: /re-search missing art/i }); - await fireEvent.click(btn); - expect(researchMissingArt).toHaveBeenCalled(); - }); - - test('Library scan card shows Processing badge when library tally is populated and in-flight', async () => { - const { readable } = await import('svelte/store'); - (createScanStatusQuery as ReturnType).mockReturnValueOnce( - readable({ - data: { - id: 'in-flight', - started_at: '2026-05-06T11:00:00Z', - finished_at: null, - in_flight: true, - library: { scanned: 100, added: 1, updated: 0, skipped: 99, errored: 0 } - }, - isPending: false, - isError: false - }) - ); - setupOverview(); - userState.current = { id: 'admin', username: 'admin', is_admin: true }; - render(OverviewPage); - expect(await screen.findByText('Processing')).toBeInTheDocument(); - }); }); describe('admin layout', () => { @@ -216,7 +162,10 @@ describe('admin layout', () => { ['/admin/duplicates', 'Duplicates 5'], ['/admin/suspect-sources', 'Suspect sources'], ['/admin/missing-files', 'Missing files'], - ['/admin/integrations', 'Integrations'], + ['/admin/library', 'Library'], + ['/admin/acquisition', 'Acquisition'], + ['/admin/metadata', 'Metadata sources'], + ['/admin/email', 'Email & network'], ['/admin/users', 'Users'], ['/admin/diagnostics', 'Diagnostics'], ['/admin/tuning', 'Tuning'] diff --git a/web/src/routes/admin/duplicates/+page.svelte b/web/src/routes/admin/duplicates/+page.svelte index a9506f99..27608636 100644 --- a/web/src/routes/admin/duplicates/+page.svelte +++ b/web/src/routes/admin/duplicates/+page.svelte @@ -18,7 +18,6 @@ DuplicatesView, ResolvedDuplicateActivity } from '#lib/api/types.js'; - import FingerprintSettingsCard from '#lib/components/FingerprintSettingsCard.svelte'; import ListContinuation from '#lib/components/ListContinuation.svelte'; import TabStrip from '#lib/components/TabStrip.svelte'; @@ -73,11 +72,6 @@ { id: 'resolved' as Tab, label: 'Resolved automatically', count: counts?.resolved } ]); - function refetchAll() { - query.refetch(); - resolved.refetch(); - } - async function onRunSweep() { sweeping = true; try { @@ -312,8 +306,8 @@

    Nothing to compare yet.

    {#if prints.enabled === false} - Fingerprinting is off, so no track has a fingerprint to compare. Turn it on in the - settings below. + Fingerprinting is off, so no track has a fingerprint to compare. Turn it on under + Library. {:else} The library is still being fingerprinted — {prints.pending.toLocaleString()} tracks to go. Duplicates appear here as the sweep finds them. @@ -485,7 +479,10 @@ /> {/if} - - + +

    + Fingerprinting is {prints?.enabled === false ? 'off' : 'on'}. It's set under + Library. +

    diff --git a/web/src/routes/admin/duplicates/duplicates.test.ts b/web/src/routes/admin/duplicates/duplicates.test.ts index 38b55ba2..369524fa 100644 --- a/web/src/routes/admin/duplicates/duplicates.test.ts +++ b/web/src/routes/admin/duplicates/duplicates.test.ts @@ -15,18 +15,7 @@ vi.mock('#lib/api/admin.js', () => ({ mergeDuplicateGroup: vi.fn().mockResolvedValue({ survivor_track_id: 'www-01', removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3'] - }), - // The page embeds FingerprintSettingsCard, which loads its own settings from - // this module; the card has its own suite. - getFingerprintSettings: vi.fn().mockResolvedValue({ - enabled: true, - chromaprint_length_sec: 120, - acoustic_max_bit_error_rate: 0.15, - backfill_concurrency: 2, - sweep_interval_hours: 1, - auto_resolve: true - }), - updateFingerprintSettings: vi.fn() + }) })); import AdminDuplicatesPage from './+page.svelte'; @@ -250,9 +239,11 @@ describe('admin duplicates', () => { expect(text(screen.getByTestId('sweep-status'))).toContain('Fingerprinting is off'); }); - test('the fingerprinting settings are on this page', async () => { + test('the fingerprinting settings are a link to Library, saying whether it is on', () => { renderWith(response()); - expect(await screen.findByRole('spinbutton', { name: /seconds of audio/i })).toBeTruthy(); + const line = screen.getByTestId('fingerprint-settings-link'); + expect(text(line)).toContain('Fingerprinting is on'); + expect(line.querySelector('a')?.getAttribute('href')).toBe('/admin/library#fingerprints'); }); test('empty before any sweep says the sweep has not run', () => { diff --git a/web/src/routes/admin/email/+page.svelte b/web/src/routes/admin/email/+page.svelte new file mode 100644 index 00000000..49696010 --- /dev/null +++ b/web/src/routes/admin/email/+page.svelte @@ -0,0 +1,27 @@ + + +{pageTitle('Admin · Email & network')} + +

    Email and network

    + + + {#snippet main()} + + + {/snippet} + {#snippet side()} + + + {/snippet} + diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte deleted file mode 100644 index 57f0ab72..00000000 --- a/web/src/routes/admin/integrations/+page.svelte +++ /dev/null @@ -1,898 +0,0 @@ - - -{pageTitle('Admin · Integrations')} - -
    -
    -

    Integrations

    - {#if config.data?.enabled} - - - Lidarr · connected - - {:else} - - - Lidarr · unset - - {/if} -
    - - - - - - -
    -
    -

    Lidarr

    -

    - 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. -

    -
    - - - - - - - - - - - - {#if testResult} - {#if testResult.ok} -

    - Connected — Lidarr {testResult.version} -

    - {:else} -

    Connection failed — {testResult.error}

    - {/if} - {/if} - {#if saveError} -

    Save failed — {saveError}

    - {/if} - -
    - - - -
    -
    - - - - - -
    -
    -

    Cover art providers

    -

    - External sources for album covers and artist images. Providers - are tried in registration order; first hit wins. -

    -
    - - {#if coverProvidersQ.data} - {#each coverProvidersQ.data.providers as provider (provider.id)} -
    -
    -
    -

    {provider.display_name}

    - -
    -
    - {#each provider.supports as cap} - {cap.replace('_', ' ')} - {/each} -
    -
    - - {#if coverLocalState[provider.id]} -
    - -
    - -
    - - {#if provider.requires_api_key} - -
    - - {#if provider.api_key_set} - ✓ Set - {/if} -
    - {/if} -
    - -
    - - {#if provider.testable} - - {/if} - {#if coverTestResults[provider.id]} - {#if coverTestResults[provider.id].ok} -

    - OK{coverTestResults[provider.id].duration_ms ? ` (${coverTestResults[provider.id].duration_ms}ms)` : ''} -

    - {:else} -

    - Failed — {coverTestResults[provider.id].error} -

    - {/if} - {/if} -
    - {/if} - - {#if provider.id === 'theaudiodb'} -

    - Public test key works out of the box. A free personal key gives separate quota - accounting — apply at theaudiodb.com/api_apply.php. -

    - {/if} -
    - {/each} - {:else if coverProvidersQ.isPending} -

    Loading…

    - {:else if coverProvidersQ.isError} -

    - Couldn't load the cover art providers. - -

    - {:else} -

    No providers registered.

    - {/if} -
    - - -
    -
    -

    Tag enrichment sources

    -

    - Folksonomy style/mood tags fetched per track and folded into each - listener's taste profile alongside the raw file genre — so "Rock" - gains "post-punk / shoegaze / melancholic". All enabled sources are - merged. Enabling or disabling a source re-opens previously-processed - tracks for a fresh pass. -

    -
    - - {#if tagProvidersQ.data} - {#each tagProvidersQ.data.providers as provider (provider.id)} -
    -
    -
    -

    {provider.display_name}

    - -
    -
    - {#each provider.supports as cap} - {cap.replace('_', ' ')} - {/each} -
    -
    - - {#if tagLocalState[provider.id]} -
    - -
    - -
    - - {#if provider.requires_api_key} - -
    - - {#if provider.api_key_set} - ✓ Set - {/if} -
    - {/if} -
    - -
    - - {#if provider.testable} - - {/if} - {#if tagTestResults[provider.id]} - {#if tagTestResults[provider.id].ok} -

    - OK{tagTestResults[provider.id].duration_ms ? ` (${tagTestResults[provider.id].duration_ms}ms)` : ''} -

    - {:else} -

    - Failed — {tagTestResults[provider.id].error} -

    - {/if} - {/if} -
    - {/if} - - {#if provider.id === 'musicbrainz'} -

    - Keyless and on by default — the always-available baseline. Matches tags by - recording MBID, so coverage tracks how well your library is MusicBrainz-tagged. -

    - {:else if provider.id === 'lastfm'} -

    - Name-based (artist + title), so it covers tracks without an MBID. Requires a free - API key — get one at last.fm/api. -

    - {/if} -
    - {/each} - {:else if tagProvidersQ.isPending} -

    Loading…

    - {:else if tagProvidersQ.isError} -

    - Couldn't load the tag providers. - -

    - {:else} -

    No providers registered.

    - {/if} -
    - -
    -
    -

    Email (SMTP)

    -

    - Configure outgoing email for password resets and notifications. The "Send test email" - button verifies your config end-to-end. -

    -
    - - {#if smtpQ.isError && !smtpLoaded} -

    - Couldn't load the email settings, so they can't be saved yet. - -

    - {/if} - - - -
    - - - - - - - - - - - - - - - - - - - TLS - -
    - -
    - - -
    -
    - - - - - - -
    - - -

    - This clears the saved configuration. Type - DISCONNECT to remove the - Lidarr connection. -

    - - {#if disconnectError} -

    Disconnect failed — {disconnectError}

    - {/if} -
    - - -
    -
    diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts deleted file mode 100644 index 0c94f567..00000000 --- a/web/src/routes/admin/integrations/integrations.test.ts +++ /dev/null @@ -1,808 +0,0 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; -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'; - -// useQueryClient is wrapped so the page can call invalidateQueries() without -// a real QueryClient context. Per-file mock OVERRIDES the vitest.setup default -// so assertions see this named spy rather than the setup's anonymous fn. -const invalidateQueries = vi.fn(); -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - 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(), - createCoverProvidersQuery: vi.fn(), - updateCoverProvider: vi.fn(), - testCoverProvider: vi.fn(), - createTagProvidersQuery: vi.fn(), - updateTagProvider: vi.fn(), - testTagProvider: vi.fn(), - createSMTPConfigQuery: vi.fn(), - updateSMTPConfig: vi.fn(), - testSMTPConfig: vi.fn(), - // AcoustIDCard loads on mount; a never-settling read leaves it on its own - // "Loading AcoustID settings…", which the page's /loading…/i queries skip. - getAcoustID: vi.fn(() => new Promise(() => {})), - updateAcoustIDSettings: vi.fn(), - runAcoustIDLookup: vi.fn(), - listUnsettledAcoustID: vi.fn(), - // NotificationEmailCard loads on mount; left pending like AcoustIDCard. - getNotificationEmailSettings: vi.fn(() => new Promise(() => {})), - updateNotificationEmailSettings: vi.fn(), - // AcquisitionCard loads on mount; left pending like AcoustIDCard. - listAcquisitionServices: vi.fn(() => new Promise(() => {})), - addAcquisitionService: vi.fn() -})); - -// SearchSettingsCard reads its own query; settled, so it adds no "Loading…". -vi.mock('#lib/api/wanted.js', async () => { - const { mockQuery } = await import('../../../test-utils/query'); - return { - createSearchSettingsQuery: vi.fn(() => - mockQuery({ - data: { - auto_grab: true, - search_interval_hours: 24, - // Not "Lossless": a Lidarr quality profile on this page is named that. - allowed_qualities: ['lossy_high'], - tiers: [{ tier: 'lossy_high', label: 'High quality lossy', what: 'MP3 320' }], - last_pass_at: null, - last_pass: {}, - import_root: '', - // Not "/music": a Lidarr root folder on this page is named that. - scan_paths: ['/library'], - seed_ratio: 2, - seed_days: 14, - stall_hours: 6 - } - }) - ), - putSearchSettings: vi.fn() - }; -}); - -import IntegrationsPage from './+page.svelte'; -import { - createLidarrConfigQuery, - createQualityProfilesQuery, - createMetadataProfilesQuery, - createRootFoldersQuery, - putLidarrConfig, - testLidarrConnection, - createCoverProvidersQuery, - updateCoverProvider, - testCoverProvider, - createTagProvidersQuery, - updateTagProvider, - testTagProvider, - createSMTPConfigQuery, - updateSMTPConfig, - testSMTPConfig -} from '#lib/api/admin.js'; - -const cfgConnected: 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 cfgUnset: LidarrConfig = { - enabled: false, - base_url: '', - api_key: '', - default_quality_profile_id: 0, - default_metadata_profile_id: 0, - default_root_folder_path: '' -}; - -// Fixture cover providers -const providerMbcaa: CoverProvider = { - id: 'mbcaa', - display_name: 'MusicBrainz CAA', - requires_api_key: false, - supports: ['album_cover'], - enabled: true, - api_key_set: false, - display_order: 0, - testable: true -}; - -const providerTheAudioDB: CoverProvider = { - id: 'theaudiodb', - display_name: 'TheAudioDB', - requires_api_key: true, - supports: ['album_cover', 'artist_thumb', 'artist_fanart'], - enabled: true, - api_key_set: false, - display_order: 1, - testable: true -}; - -const providerTheAudioDBKeySet: CoverProvider = { - ...providerTheAudioDB, - api_key_set: true -}; - -const defaultCoverProviders = { - providers: [providerMbcaa, providerTheAudioDB], - sources_version: 1 -}; - -// Fixture tag providers -const providerMusicbrainz: TagProvider = { - id: 'musicbrainz', - display_name: 'MusicBrainz', - requires_api_key: false, - supports: ['track_tags'], - enabled: true, - api_key_set: false, - display_order: 0, - testable: true -}; - -const providerLastfm: TagProvider = { - id: 'lastfm', - display_name: 'Last.fm', - requires_api_key: true, - supports: ['track_tags'], - enabled: false, - api_key_set: false, - display_order: 1, - testable: true -}; - -const defaultTagProviders = { - providers: [providerMusicbrainz, providerLastfm], - sources_version: 1 -}; - -const testResponseOk = { - 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 }] -}; - -const smtpUnset: SMTPConfig = { - enabled: false, - host: '', - port: 587, - username: '', - password: '', - from_address: '', - from_name: 'Minstrel', - use_tls: true -}; - -const smtpConfigured: 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(); - invalidateQueries.mockReset(); -}); - -function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; tagProviders?: typeof defaultTagProviders | null; smtp?: SMTPConfig } = {}) { - (createLidarrConfigQuery as ReturnType).mockReturnValue( - mockQuery({ data: opts.config ?? cfgConnected }) - ); - (createQualityProfilesQuery as ReturnType).mockReturnValue( - mockQuery({ - data: [ - { id: 1, name: 'Standard' }, - { id: 2, name: 'Lossless' } - ] - }) - ); - (createMetadataProfilesQuery as ReturnType).mockReturnValue( - mockQuery({ - data: [ - { id: 1, name: 'Standard' }, - { id: 2, name: 'Singles only' } - ] - }) - ); - (createRootFoldersQuery as ReturnType).mockReturnValue( - mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] }) - ); - const cpData = opts.coverProviders === null ? undefined : (opts.coverProviders ?? defaultCoverProviders); - (createCoverProvidersQuery as ReturnType).mockReturnValue( - mockQuery({ data: cpData, isPending: opts.coverProviders === null }) - ); - const tpData = opts.tagProviders === null ? undefined : (opts.tagProviders ?? defaultTagProviders); - (createTagProvidersQuery as ReturnType).mockReturnValue( - mockQuery({ data: tpData, isPending: opts.tagProviders === null }) - ); - (createSMTPConfigQuery as ReturnType).mockReturnValue( - mockQuery({ data: opts.smtp ?? smtpUnset }) - ); - return render(IntegrationsPage); -} - -// Helper for finding the modal's Disconnect confirm button. Both the panel -// trigger and the modal confirm share the visible name "Disconnect"; the -// modal one is the descendant of role="dialog". -function modalConfirmButton(): HTMLButtonElement { - const matches = screen.getAllByRole('button', { name: /^disconnect$/i }); - const inDialog = matches.find((b) => b.closest('[role="dialog"]') !== null); - if (!inDialog) throw new Error('modal Disconnect button not found'); - return inDialog as HTMLButtonElement; -} - -// Section-scoping helpers. Both Lidarr and the cover-art-providers panel render -// "Save changes", "Test connection", and "API key" controls, so unscoped queries -// match multiple elements. These helpers narrow every query to its own
    . - -function lidarrSection(): HTMLElement { - return screen.getByRole('heading', { name: /^lidarr$/i, level: 3 }).closest('section') as HTMLElement; -} - -function coverProvidersSection(): HTMLElement { - return screen.getByRole('heading', { name: /cover art providers/i, level: 3 }).closest('section') as HTMLElement; -} - -// Each provider is rendered inside an
    . Provider headings are h4. -function providerCard(providerName: string): HTMLElement { - return within(coverProvidersSection()) - .getByRole('heading', { name: providerName, level: 4 }) - .closest('article') as HTMLElement; -} - -function tagProvidersSection(): HTMLElement { - return screen.getByRole('heading', { name: /tag enrichment sources/i, level: 3 }).closest('section') as HTMLElement; -} - -function tagProviderCard(providerName: string): HTMLElement { - return within(tagProvidersSection()) - .getByRole('heading', { name: providerName, level: 4 }) - .closest('article') as HTMLElement; -} - -describe('/admin/integrations', () => { - test('Save calls putLidarrConfig with empty api_key when input is blank', async () => { - setup(); - // Save now runs Test first (commit bca8622); mock both endpoints. The - // test response also has to match enough of the existing config that - // the Save handler treats it as a valid passthrough. - (testLidarrConnection as ReturnType).mockResolvedValueOnce(testResponseOk); - (putLidarrConfig as ReturnType).mockResolvedValueOnce(cfgConnected); - await fireEvent.click(within(lidarrSection()).getByRole('button', { name: /save changes/i })); - await waitFor(() => { - expect(putLidarrConfig).toHaveBeenCalledWith( - expect.objectContaining({ - enabled: true, - base_url: 'http://lidarr.local', - api_key: '', - default_quality_profile_id: 1, - default_root_folder_path: '/music' - }) - ); - }); - }); - - test('Save with new api_key sends the typed value', async () => { - setup(); - const apiInput = screen.getByPlaceholderText(/saved — leave empty to keep/i); - await fireEvent.input(apiInput, { target: { value: 'newkey' } }); - (testLidarrConnection as ReturnType).mockResolvedValueOnce(testResponseOk); - (putLidarrConfig as ReturnType).mockResolvedValueOnce(cfgConnected); - await fireEvent.click(within(lidarrSection()).getByRole('button', { name: /save changes/i })); - await waitFor(() => { - expect(putLidarrConfig).toHaveBeenCalledWith( - expect.objectContaining({ api_key: 'newkey' }) - ); - }); - }); - - test('Test connection renders Lidarr version on success', async () => { - setup(); - (testLidarrConnection as ReturnType).mockResolvedValueOnce({ - ok: true, - version: '2.0.0' - }); - await fireEvent.click( - within(lidarrSection()).getByRole('button', { name: /test connection/i }) - ); - await waitFor(() => - expect(screen.getByText(/lidarr 2\.0\.0/i)).toBeInTheDocument() - ); - }); - - test('Test connection renders error code on failure', async () => { - setup(); - (testLidarrConnection as ReturnType).mockResolvedValueOnce({ - ok: false, - error: 'lidarr_unreachable' - }); - await fireEvent.click( - within(lidarrSection()).getByRole('button', { name: /test connection/i }) - ); - await waitFor(() => - expect(screen.getByText(/lidarr_unreachable/i)).toBeInTheDocument() - ); - }); - - test('Disconnect requires typed confirm; cancelling does not call API', async () => { - setup(); - // Open modal via the panel-level Disconnect button. - await fireEvent.click( - screen.getByRole('button', { name: /^disconnect$/i }) - ); - expect(screen.getByRole('dialog')).toBeInTheDocument(); - // Confirm button is disabled until "DISCONNECT" is typed. - expect(modalConfirmButton().disabled).toBe(true); - await fireEvent.click(screen.getByRole('button', { name: /cancel/i })); - expect(putLidarrConfig).not.toHaveBeenCalled(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - test('Disconnect with typed confirmation calls API with cleared config', async () => { - setup(); - await fireEvent.click( - screen.getByRole('button', { name: /^disconnect$/i }) - ); - const confirmInput = screen.getByPlaceholderText('DISCONNECT'); - await fireEvent.input(confirmInput, { target: { value: 'DISCONNECT' } }); - (putLidarrConfig as ReturnType).mockResolvedValueOnce(cfgUnset); - await fireEvent.click(modalConfirmButton()); - expect(putLidarrConfig).toHaveBeenCalledWith( - expect.objectContaining({ - enabled: false, - base_url: '', - api_key: '', - default_quality_profile_id: 0, - default_root_folder_path: '' - }) - ); - }); - - test('Quality / metadata / root folder dropdowns populate from API', () => { - setup(); - // 'Standard' appears in both quality + metadata fixtures, so use *AllBy*. - expect(screen.getAllByText('Standard').length).toBeGreaterThanOrEqual(2); - expect(screen.getByText('Lossless')).toBeInTheDocument(); - expect(screen.getByText('Singles only')).toBeInTheDocument(); - expect(screen.getByText('/music')).toBeInTheDocument(); - }); - - test('header shows "Lidarr · connected" when configured', () => { - setup(); - expect(screen.getByText(/lidarr · connected/i)).toBeInTheDocument(); - }); - - test('header shows "Lidarr · unset" when not configured', () => { - setup({ config: cfgUnset }); - expect(screen.getByText(/lidarr · unset/i)).toBeInTheDocument(); - }); -}); - -describe('/admin/integrations — cover art providers', () => { - test('renders one card per provider in the response', () => { - setup(); - expect(screen.getByText('MusicBrainz CAA')).toBeInTheDocument(); - expect(screen.getByText('TheAudioDB')).toBeInTheDocument(); - }); - - test('capability badges render for each capability in supports[]', () => { - setup(); - // TheAudioDB has album_cover, artist_thumb, artist_fanart - expect(screen.getAllByText('album cover').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('artist thumb')).toBeInTheDocument(); - expect(screen.getByText('artist fanart')).toBeInTheDocument(); - }); - - test('"✓ Set" indicator renders when api_key_set is true', () => { - setup({ - coverProviders: { - providers: [providerTheAudioDBKeySet], - sources_version: 1 - } - }); - expect(screen.getByText(/✓ set/i)).toBeInTheDocument(); - }); - - test('"✓ Set" indicator does not render when api_key_set is false', () => { - setup(); - expect(screen.queryByText(/✓ set/i)).not.toBeInTheDocument(); - }); - - test('Save button is disabled initially (no changes)', () => { - setup(); - const cpSection = coverProvidersSection(); - const saveButtons = within(cpSection).getAllByRole('button', { name: /save changes/i }); - // Both provider cards render Save buttons, both should be disabled initially - for (const btn of saveButtons) { - expect((btn as HTMLButtonElement).disabled).toBe(true); - } - }); - - test('Save button enables after typing in API key field', async () => { - setup(); - const theAudioDbCard = providerCard('TheAudioDB'); - const keyInput = within(theAudioDbCard).getByLabelText(/api key/i); - await fireEvent.input(keyInput, { target: { value: 'myapikey' } }); - // The TheAudioDB save button should now be enabled - const theAudioDbSave = within(theAudioDbCard).getByRole('button', { name: /save changes/i }); - expect(theAudioDbSave).toBeDefined(); - expect((theAudioDbSave as HTMLButtonElement).disabled).toBe(false); - }); - - test('clicking Save calls updateCoverProvider with api_key patch body', async () => { - setup(); - (updateCoverProvider as ReturnType).mockResolvedValueOnce({ - ...providerTheAudioDB, - version_bumped: false - }); - const theAudioDbCard = providerCard('TheAudioDB'); - const keyInput = within(theAudioDbCard).getByLabelText(/api key/i); - await fireEvent.input(keyInput, { target: { value: 'newkey123' } }); - const btn = within(theAudioDbCard).getByRole('button', { name: /save changes/i }); - await fireEvent.click(btn); - expect(updateCoverProvider).toHaveBeenCalledWith( - 'theaudiodb', - expect.objectContaining({ api_key: 'newkey123' }) - ); - }); - - 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({ - ...providerMbcaa, - enabled: false, - version_bumped: true - }); - // Toggle enabled on MusicBrainz CAA card - const mbcaaCard = screen.getByText('MusicBrainz CAA').closest('article')!; - const enabledCheckbox = mbcaaCard.querySelector('input[type="checkbox"]') as HTMLInputElement; - await fireEvent.click(enabledCheckbox); - const saveButtons = screen.getAllByRole('button', { name: /save changes/i }); - const mbcaaSave = saveButtons.find((b) => b.closest('article')?.contains(enabledCheckbox)); - await fireEvent.click(mbcaaSave!); - await waitFor(() => { - expect(invalidateQueries).toHaveBeenCalledWith( - expect.objectContaining({ queryKey: ['coverage'] }) - ); - }); - }); - - test('when version_bumped: false, invalidateQueries is NOT called with qk.coverage()', async () => { - setup(); - (updateCoverProvider as ReturnType).mockResolvedValueOnce({ - ...providerMbcaa, - enabled: false, - version_bumped: false - }); - const mbcaaCard = screen.getByText('MusicBrainz CAA').closest('article')!; - const enabledCheckbox = mbcaaCard.querySelector('input[type="checkbox"]') as HTMLInputElement; - await fireEvent.click(enabledCheckbox); - const saveButtons = screen.getAllByRole('button', { name: /save changes/i }); - const mbcaaSave = saveButtons.find((b) => b.closest('article')?.contains(enabledCheckbox)); - await fireEvent.click(mbcaaSave!); - await waitFor(() => { - expect(invalidateQueries).toHaveBeenCalledWith( - expect.objectContaining({ queryKey: ['coverProviders'] }) - ); - }); - // coverage invalidation should NOT have been called - const coverageCalls = invalidateQueries.mock.calls.filter( - (call: any[]) => JSON.stringify(call[0]?.queryKey) === JSON.stringify(['coverage']) - ); - expect(coverageCalls.length).toBe(0); - }); - - test('Test connection button calls testCoverProvider and shows OK result', async () => { - setup(); - (testCoverProvider as ReturnType).mockResolvedValueOnce({ - ok: true, - duration_ms: 120 - }); - const mbcaaCard = providerCard('MusicBrainz CAA'); - await fireEvent.click(within(mbcaaCard).getByRole('button', { name: /test connection/i })); - await waitFor(() => - expect(screen.getByText(/ok.*120ms/i)).toBeInTheDocument() - ); - }); - - test('Test connection button shows failure message on error response', async () => { - setup(); - (testCoverProvider as ReturnType).mockResolvedValueOnce({ - ok: false, - error: 'mbcaa_unreachable' - }); - const mbcaaCard = providerCard('MusicBrainz CAA'); - await fireEvent.click(within(mbcaaCard).getByRole('button', { name: /test connection/i })); - await waitFor(() => - expect(screen.getByText(/mbcaa_unreachable/i)).toBeInTheDocument() - ); - }); - - test('TheAudioDB help text renders with link', () => { - setup(); - expect(screen.getByText(/public test key works out of the box/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /theaudiodb\.com\/api_apply\.php/i })).toBeInTheDocument(); - }); - - test('shows loading state when cover providers query is pending', () => { - setup({ coverProviders: null }); - expect(screen.getByText(/loading…/i)).toBeInTheDocument(); - }); -}); - -function smtpSection(): HTMLElement { - return screen.getByRole('heading', { name: /email \(smtp\)/i, level: 3 }).closest('section') as HTMLElement; -} - -describe('/admin/integrations — SMTP card', () => { - test('renders "Email (SMTP)" heading', () => { - setup(); - expect(screen.getByRole('heading', { name: /email \(smtp\)/i, level: 3 })).toBeInTheDocument(); - }); - - test('Save calls updateSMTPConfig with form values; empty password preserves stored value', async () => { - setup({ smtp: smtpConfigured }); - (updateSMTPConfig as ReturnType).mockResolvedValue(undefined); - // password input starts empty — should send '' which tells backend to preserve - const section = smtpSection(); - await fireEvent.click(within(section).getByRole('button', { name: /^save$/i })); - await waitFor(() => - expect(updateSMTPConfig).toHaveBeenCalledWith( - expect.objectContaining({ password: '' }) - ) - ); - }); - - test('Save with a typed password sends the typed value', async () => { - setup({ smtp: smtpConfigured }); - (updateSMTPConfig as ReturnType).mockResolvedValue(undefined); - const section = smtpSection(); - const pwInput = within(section).getByLabelText(/password/i); - await fireEvent.input(pwInput, { target: { value: 'newsecret' } }); - await fireEvent.click(within(section).getByRole('button', { name: /^save$/i })); - await waitFor(() => - expect(updateSMTPConfig).toHaveBeenCalledWith( - expect.objectContaining({ password: 'newsecret' }) - ) - ); - }); - - test('Test button calls testSMTPConfig', async () => { - setup(); - (testSMTPConfig as ReturnType).mockResolvedValue(undefined); - const section = smtpSection(); - await fireEvent.click(within(section).getByRole('button', { name: /send test email/i })); - await waitFor(() => expect(testSMTPConfig).toHaveBeenCalled()); - }); - - test('no_email_on_file error from testSMTPConfig surfaces actionable toast', async () => { - setup(); - (testSMTPConfig as ReturnType).mockRejectedValueOnce({ code: 'no_email_on_file' }); - 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 › 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" - const section = smtpSection(); - await waitFor(() => - expect(within(section).getByPlaceholderText(/leave blank to keep current/i)).toBeInTheDocument() - ); - }); -}); - -describe('Lidarr first-time setup (Test-then-Save)', () => { - // Fresh-install variant of setup() — the three list queries return - // empty arrays so we can verify the dropdowns get populated from the - // test response, not from the (empty) cached query data. - function setupFresh() { - (createLidarrConfigQuery as ReturnType).mockReturnValue( - mockQuery({ data: cfgUnset }) - ); - (createQualityProfilesQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); - (createMetadataProfilesQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); - (createRootFoldersQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); - (createCoverProvidersQuery as ReturnType).mockReturnValue( - mockQuery({ data: defaultCoverProviders }) - ); - (createSMTPConfigQuery as ReturnType).mockReturnValue( - mockQuery({ data: smtpUnset }) - ); - return render(IntegrationsPage); - } - - test('Save calls test first, fills defaults from response, then put-config', async () => { - setupFresh(); - (testLidarrConnection as ReturnType).mockResolvedValueOnce(testResponseOk); - (putLidarrConfig as ReturnType).mockResolvedValueOnce(cfgConnected); - - const section = lidarrSection(); - await fireEvent.input(within(section).getByLabelText(/Base URL/i), { - target: { value: 'http://lidarr.local' } - }); - await fireEvent.input(within(section).getByLabelText(/API key/i), { - target: { value: 'secret-key' } - }); - await fireEvent.click(within(section).getByRole('button', { name: /Save changes/i })); - - await waitFor(() => { - expect(testLidarrConnection).toHaveBeenCalledWith({ - base_url: 'http://lidarr.local', - api_key: 'secret-key' - }); - }); - await waitFor(() => { - expect(putLidarrConfig).toHaveBeenCalledWith({ - enabled: true, - base_url: 'http://lidarr.local', - api_key: 'secret-key', - default_quality_profile_id: 1, - default_metadata_profile_id: 1, - default_root_folder_path: '/music' - }); - }); - }); - - test('Save aborts when test fails and surfaces the error', async () => { - setupFresh(); - (testLidarrConnection as ReturnType).mockResolvedValueOnce({ - ok: false, - error: 'lidarr_unreachable' - }); - - const section = lidarrSection(); - await fireEvent.input(within(section).getByLabelText(/Base URL/i), { - target: { value: 'http://wrong.local' } - }); - await fireEvent.input(within(section).getByLabelText(/API key/i), { - target: { value: 'k' } - }); - await fireEvent.click(within(section).getByRole('button', { name: /Save changes/i })); - - await waitFor(() => { - expect(testLidarrConnection).toHaveBeenCalled(); - }); - expect(putLidarrConfig).not.toHaveBeenCalled(); - await waitFor(() => { - expect(within(lidarrSection()).getByText(/save failed — lidarr_unreachable/i)).toBeInTheDocument(); - }); - }); - - test('Save preserves operator-picked defaults over auto-defaults', async () => { - setupFresh(); - (testLidarrConnection as ReturnType).mockResolvedValue(testResponseOk); - (putLidarrConfig as ReturnType).mockResolvedValueOnce(cfgConnected); - - const section = lidarrSection(); - await fireEvent.input(within(section).getByLabelText(/Base URL/i), { - target: { value: 'http://lidarr.local' } - }); - await fireEvent.input(within(section).getByLabelText(/API key/i), { - target: { value: 'secret-key' } - }); - // Click Test first to populate the dropdowns, then change the - // quality profile selection before clicking Save. - await fireEvent.click(within(section).getByRole('button', { name: /Test connection/i })); - await waitFor(() => { - expect(testLidarrConnection).toHaveBeenCalledTimes(1); - }); - const qualitySelect = within(section).getByLabelText(/Default quality profile/i); - await fireEvent.change(qualitySelect, { target: { value: '2' } }); - - await fireEvent.click(within(section).getByRole('button', { name: /Save changes/i })); - - await waitFor(() => { - expect(putLidarrConfig).toHaveBeenCalledWith( - expect.objectContaining({ - default_quality_profile_id: 2, - default_metadata_profile_id: 1, - default_root_folder_path: '/music' - }) - ); - }); - }); -}); - -describe('/admin/integrations tag sources', () => { - test('renders tag enrichment providers', () => { - setup(); - const section = tagProvidersSection(); - expect(within(section).getByRole('heading', { name: 'MusicBrainz', level: 4 })).toBeInTheDocument(); - expect(within(section).getByRole('heading', { name: 'Last.fm', level: 4 })).toBeInTheDocument(); - expect(within(section).getAllByText('track tags').length).toBeGreaterThanOrEqual(1); - }); - - test('enabling Last.fm and typing a key calls updateTagProvider', async () => { - setup(); - (updateTagProvider as ReturnType).mockResolvedValueOnce({ - ...providerLastfm, - enabled: true, - api_key_set: true, - version_bumped: true - }); - const card = tagProviderCard('Last.fm'); - const enabledCheckbox = card.querySelector('input[type="checkbox"]') as HTMLInputElement; - await fireEvent.click(enabledCheckbox); - const keyInput = within(card).getByLabelText(/api key/i); - await fireEvent.input(keyInput, { target: { value: 'lfmkey' } }); - await fireEvent.click(within(card).getByRole('button', { name: /save changes/i })); - - expect(updateTagProvider).toHaveBeenCalledWith( - 'lastfm', - expect.objectContaining({ enabled: true, api_key: 'lfmkey' }) - ); - await waitFor(() => - expect(invalidateQueries).toHaveBeenCalledWith( - expect.objectContaining({ queryKey: ['tagProviders'] }) - ) - ); - }); - - test('Test connection on a tag provider calls testTagProvider and shows OK', async () => { - setup(); - (testTagProvider as ReturnType).mockResolvedValueOnce({ ok: true, duration_ms: 42 }); - const card = tagProviderCard('MusicBrainz'); - await fireEvent.click(within(card).getByRole('button', { name: /test connection/i })); - await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz')); - await waitFor(() => expect(within(card).getByText(/ok.*42ms/i)).toBeInTheDocument()); - }); -}); diff --git a/web/src/routes/admin/library/+page.svelte b/web/src/routes/admin/library/+page.svelte new file mode 100644 index 00000000..481a9f98 --- /dev/null +++ b/web/src/routes/admin/library/+page.svelte @@ -0,0 +1,214 @@ + + +{pageTitle('Admin · Library')} + +

    Library

    + + + {#snippet main()} + + + + {#each folders as f (f)}{f}{:else}—{/each} + + + + + + {#if scan?.id} + {#each jobs as job (job.key)} +
    + + + +
    + {/each} + {/if} +
    + + + + + + {#if coverage && coverage.pending_no_mbid > 0} + + {/if} + + + + + {/snippet} + {#snippet side()} + {#if prints && prints.total > 0} + + + + {/if} +
    + client.invalidateQueries({ queryKey: qk.fingerprintCoverage() })} + /> +
    + + {/snippet} +
    diff --git a/web/src/routes/admin/library/library.test.ts b/web/src/routes/admin/library/library.test.ts new file mode 100644 index 00000000..13627c12 --- /dev/null +++ b/web/src/routes/admin/library/library.test.ts @@ -0,0 +1,110 @@ +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 { ScanStatus } from '#lib/api/admin.js'; + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +vi.mock('#lib/api/admin.js', () => ({ + createScanStatusQuery: vi.fn(), + createCoverageQuery: vi.fn(), + createFingerprintCoverageQuery: vi.fn(), + triggerScan: vi.fn().mockResolvedValue({}), + refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }), + researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }), + // The loudness and fingerprint settings load on mount and have their own + // suites; left pending. + getLoudnessSettings: vi.fn(() => new Promise(() => {})), + updateLoudnessSettings: vi.fn(), + getLoudnessCoverage: vi.fn(() => new Promise(() => {})), + getFingerprintSettings: vi.fn(() => new Promise(() => {})), + updateFingerprintSettings: vi.fn() +})); + +vi.mock('#lib/api/wanted.js', async () => { + const { mockQuery } = await import('../../../test-utils/query'); + return { createSearchSettingsQuery: vi.fn(() => mockQuery({ data: { scan_paths: ['/music', '/more-music'] } })) }; +}); + +import LibraryPage from './+page.svelte'; +import { + createScanStatusQuery, + createCoverageQuery, + createFingerprintCoverageQuery, + triggerScan, + researchMissingArt, + refetchMissingCovers +} from '#lib/api/admin.js'; + +const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString(); + +afterEach(() => vi.clearAllMocks()); + +function setup(scan: Partial = { id: '', started_at: '', finished_at: null, in_flight: false }) { + vi.mocked(createScanStatusQuery).mockReturnValue(mockQuery({ data: scan }) as never); + vi.mocked(createCoverageQuery).mockReturnValue( + mockQuery({ data: { total: 100, with_art: 90, pending: 6, settled: 4, pending_no_mbid: 0 } }) as never + ); + vi.mocked(createFingerprintCoverageQuery).mockReturnValue( + mockQuery({ data: { total: 100, fingerprinted: 80, rejected: 1, pending: 19, enabled: true } }) as never + ); + return render(LibraryPage); +} + +const squash = (el: Element) => el.textContent?.replace(/\s+/g, ' ').trim(); + +describe('Admin › Library', () => { + test('lists the folders Minstrel reads', () => { + setup(); + expect(squash(screen.getByTestId('library-folders'))).toBe('/music /more-music'); + }); + + test('before any scan it says so, with no job rows', () => { + setup(); + expect(screen.getByText("Hasn't run yet.")).toBeInTheDocument(); + expect(screen.queryAllByTestId('scan-job')).toHaveLength(0); + }); + + test('a running scan has a row per job, each with what it did and where it is', () => { + setup({ + id: 's1', + started_at: hoursAgo(0.1), + finished_at: null, + in_flight: true, + library: { scanned: 1200, added: 3, updated: 2, skipped: 1195, errored: 0 } + }); + const jobs = screen.getAllByTestId('scan-job').map(squash); + expect(jobs[0]).toBe('Reading files 1,200 read · 3 new · 2 changed Processing'); + expect(jobs[1]).toMatch(/^MusicBrainz ids Pending$/); + expect((screen.getByRole('button', { name: /scanning…/i }) as HTMLButtonElement).disabled).toBe(true); + }); + + test('a failed scan says so on the scan row', () => { + setup({ id: 's1', started_at: hoursAgo(2), finished_at: hoursAgo(1), in_flight: false, error_message: 'library root not mounted' }); + expect(screen.getByText(/failed 1h ago: library root not mounted/i)).toBeInTheDocument(); + }); + + test('Run scan starts one', async () => { + setup(); + await fireEvent.click(screen.getByRole('button', { name: /run scan/i })); + await waitFor(() => expect(triggerScan).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/scan started/i)); + }); + + test('cover art says how many have art, and can look for the missing and look again', async () => { + setup(); + expect(screen.getByText('90 with art · 6 still to look for · 4 no source has')).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: /look for missing covers/i })); + await waitFor(() => expect(refetchMissingCovers).toHaveBeenCalled()); + await fireEvent.click(screen.getByRole('button', { name: /^look again$/i })); + await waitFor(() => expect(researchMissingArt).toHaveBeenCalled()); + }); + + test('fingerprint progress is one line', () => { + setup(); + expect(screen.getByText('80 of 100 tracks · 19 to go · 1 unreadable')).toBeInTheDocument(); + }); +}); diff --git a/web/src/routes/admin/metadata/+page.svelte b/web/src/routes/admin/metadata/+page.svelte new file mode 100644 index 00000000..8f493745 --- /dev/null +++ b/web/src/routes/admin/metadata/+page.svelte @@ -0,0 +1,92 @@ + + +{pageTitle('Admin · Metadata sources')} + +

    Metadata sources

    + +{#snippet coverNote(p: { id: string })} + {#if p.id === 'theaudiodb'} + Works with the public key; + a free key of your own gets its own quota. + {/if} +{/snippet} + +{#snippet tagNote(p: { id: string })} + {#if p.id === 'musicbrainz'} + Needs no key; matches by MusicBrainz id. + {:else if p.id === 'lastfm'} + Matches by artist and title. + Get a free key. + {/if} +{/snippet} + + + {#snippet main()} + + {/snippet} + {#snippet side()} + + + {/snippet} + diff --git a/web/src/routes/admin/metadata/metadata.test.ts b/web/src/routes/admin/metadata/metadata.test.ts new file mode 100644 index 00000000..0ab85c68 --- /dev/null +++ b/web/src/routes/admin/metadata/metadata.test.ts @@ -0,0 +1,174 @@ +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 { CoverProvider, TagProvider } from '#lib/api/admin.js'; + +const invalidateQueries = vi.fn(); +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries }) }; +}); + +vi.mock('#lib/api/admin.js', () => ({ + createCoverProvidersQuery: vi.fn(), + updateCoverProvider: vi.fn(), + testCoverProvider: vi.fn(), + createTagProvidersQuery: vi.fn(), + updateTagProvider: vi.fn(), + testTagProvider: vi.fn(), + // AcoustIDCard loads on mount and has its own suite; left pending. + getAcoustID: vi.fn(() => new Promise(() => {})), + updateAcoustIDSettings: vi.fn(), + runAcoustIDLookup: vi.fn(), + listUnsettledAcoustID: vi.fn() +})); + +import MetadataPage from './+page.svelte'; +import { + createCoverProvidersQuery, + updateCoverProvider, + testCoverProvider, + createTagProvidersQuery, + updateTagProvider, + testTagProvider +} from '#lib/api/admin.js'; + +const caa: CoverProvider = { + id: 'mbcaa', + display_name: 'MusicBrainz CAA', + requires_api_key: false, + supports: ['album_cover'], + enabled: true, + api_key_set: false, + display_order: 0, + testable: true +}; +const audiodb: CoverProvider = { + id: 'theaudiodb', + display_name: 'TheAudioDB', + requires_api_key: true, + supports: ['album_cover', 'artist_thumb', 'artist_fanart'], + enabled: true, + api_key_set: false, + display_order: 1, + testable: true +}; +const mb: TagProvider = { + id: 'musicbrainz', + display_name: 'MusicBrainz', + requires_api_key: false, + supports: ['track_tags'], + enabled: true, + api_key_set: false, + display_order: 0, + testable: true +}; +const lastfm: TagProvider = { + id: 'lastfm', + display_name: 'Last.fm', + requires_api_key: true, + supports: ['track_tags'], + enabled: false, + api_key_set: false, + display_order: 1, + testable: true +}; + +afterEach(() => { + vi.clearAllMocks(); + invalidateQueries.mockReset(); +}); + +function setup(opts: { cover?: ReturnType; covers?: CoverProvider[] } = {}) { + vi.mocked(createCoverProvidersQuery).mockReturnValue( + (opts.cover ?? mockQuery({ data: { providers: opts.covers ?? [caa, audiodb], sources_version: 1 } })) as never + ); + vi.mocked(createTagProvidersQuery).mockReturnValue( + mockQuery({ data: { providers: [mb, lastfm], sources_version: 1 } }) as never + ); + return render(MetadataPage); +} + +const source = (id: string) => screen.getAllByTestId('provider').find((el) => el.dataset.provider === id)!; +const group = (title: string) => screen.getByRole('heading', { name: title, level: 2 }).closest('section') as HTMLElement; + +describe('Admin › Metadata sources', () => { + test('each source is a row saying what it supplies in words, not its codes', () => { + setup(); + const row = source('theaudiodb'); + expect(row.textContent).toMatch(/Album covers, artist photos, artist backdrops/); + expect(row.textContent).not.toMatch(/artist_thumb|album_cover/); + expect(within(group('Tags')).getAllByTestId('provider')).toHaveLength(2); + }); + + test('a saved key says so, and an unsaved one invites a key', () => { + setup({ covers: [{ ...audiodb, api_key_set: true }] }); + expect(within(source('theaudiodb')).getByPlaceholderText(/key saved/i)).toBeInTheDocument(); + }); + + test('Save is off until something changes, then sends only the change', async () => { + setup(); + vi.mocked(updateCoverProvider).mockResolvedValueOnce({ ...audiodb, version_bumped: false }); + const row = source('theaudiodb'); + const save = within(row).getByRole('button', { name: /^save$/i }) as HTMLButtonElement; + expect(save.disabled).toBe(true); + await fireEvent.input(within(row).getByLabelText(/theaudiodb api key/i), { target: { value: 'newkey123' } }); + expect(save.disabled).toBe(false); + await fireEvent.click(save); + expect(updateCoverProvider).toHaveBeenCalledWith('theaudiodb', { api_key: 'newkey123' }); + }); + + test('switching a cover source off reopens covers no source had', async () => { + setup(); + vi.mocked(updateCoverProvider).mockResolvedValueOnce({ ...caa, enabled: false, version_bumped: true }); + const row = source('mbcaa'); + await fireEvent.click(within(row).getByRole('switch')); + await fireEvent.click(within(row).getByRole('button', { name: /^save$/i })); + await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['coverage'] }))); + }); + + test('a save that fails says so', async () => { + setup(); + vi.mocked(updateCoverProvider).mockRejectedValueOnce({ code: 'server_error' }); + const row = source('theaudiodb'); + await fireEvent.input(within(row).getByLabelText(/api key/i), { target: { value: 'k' } }); + await fireEvent.click(within(row).getByRole('button', { name: /^save$/i })); + await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/couldn't save theaudiodb/i)); + }); + + test('Test says whether the source answered', async () => { + setup(); + vi.mocked(testCoverProvider).mockResolvedValueOnce({ ok: true, duration_ms: 120 }); + await fireEvent.click(within(source('mbcaa')).getByRole('button', { name: /^test$/i })); + await waitFor(() => expect(within(source('mbcaa')).getByTestId('provider-test').textContent).toMatch(/works · answered in 120 ms/i)); + }); + + test('sources that fail to load say so, with Try again', () => { + setup({ cover: mockQuery({ isError: true, error: { code: 'server_error' } }) }); + expect(within(group('Cover art')).getByTestId('providers-error').textContent).toMatch(/couldn't load the cover art sources/i); + }); + + test('a tag source is switched on with its key in one save', async () => { + setup(); + vi.mocked(updateTagProvider).mockResolvedValueOnce({ ...lastfm, enabled: true, api_key_set: true }); + const row = source('lastfm'); + await fireEvent.click(within(row).getByRole('switch')); + await fireEvent.input(within(row).getByLabelText(/api key/i), { target: { value: 'lfmkey' } }); + await fireEvent.click(within(row).getByRole('button', { name: /^save$/i })); + expect(updateTagProvider).toHaveBeenCalledWith('lastfm', { enabled: true, api_key: 'lfmkey' }); + await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['tagProviders'] }))); + }); + + test('Test on a tag source asks that source', async () => { + setup(); + vi.mocked(testTagProvider).mockResolvedValueOnce({ ok: true, duration_ms: 42 }); + await fireEvent.click(within(source('musicbrainz')).getByRole('button', { name: /^test$/i })); + await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz')); + }); + + test('where to get a key is linked from the source that needs one', () => { + setup(); + expect(within(source('lastfm')).getByRole('link', { name: /get a free key/i })).toBeInTheDocument(); + expect(within(source('theaudiodb')).getByRole('link', { name: /a free key of your own/i })).toBeInTheDocument(); + }); +}); diff --git a/web/src/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte index 656a3cb4..df3a6ad7 100644 --- a/web/src/routes/admin/missing-files/+page.svelte +++ b/web/src/routes/admin/missing-files/+page.svelte @@ -1,10 +1,14 @@ {pageTitle('Admin · Missing files')} @@ -58,10 +73,16 @@

    - - +

    + {#if policy} + {policy.enabled + ? `Albums missing for ${policy.grace_hours} hours go back on the wanted list by themselves.` + : 'Missing albums are not wanted back automatically.'} + {/if} + + {policy ? 'Change' : 'Set what happens to missing files'} + +

    {#if query.isPending}

    Checking what's missing…

    diff --git a/web/src/routes/admin/missing-files/missing-files.test.ts b/web/src/routes/admin/missing-files/missing-files.test.ts index a9a113c9..68673dd3 100644 --- a/web/src/routes/admin/missing-files/missing-files.test.ts +++ b/web/src/routes/admin/missing-files/missing-files.test.ts @@ -3,9 +3,8 @@ import { render, screen, within } from '@testing-library/svelte'; import { mockQuery } from '../../../test-utils/query'; import type { AdminMissingResponse } from '#lib/api/types.js'; -// The page now embeds ReacquisitionSettingsCard, which loads its own settings -// from this same module on mount. Stubbing both keeps these tests about the -// missing-files list — the card has its own suite. +// The page reads the re-acquisition policy to say what happens to these; +// the settings themselves are on Admin › Acquisition. vi.mock('#lib/api/admin.js', () => ({ createMissingFilesQuery: vi.fn(), getReacquisitionSettings: vi.fn().mockResolvedValue({ @@ -13,8 +12,7 @@ vi.mock('#lib/api/admin.js', () => ({ grace_hours: 24, max_per_pass: 20, unnameable_albums: 0 - }), - updateReacquisitionSettings: vi.fn() + }) })); import AdminMissingFilesPage from './+page.svelte'; @@ -77,6 +75,13 @@ function renderWith(data: AdminMissingResponse | undefined, extra = {}) { } describe('admin missing files', () => { + test('says what happens to missing files, and links to where it is set', async () => { + renderWith(response); + const line = screen.getByTestId('reacquisition-policy'); + await screen.findByText(/missing for 24 hours go back on the wanted list/i); + expect(line.querySelector('a')?.getAttribute('href')).toBe('/admin/acquisition#reacquisition'); + }); + test('groups rows under their directory', () => { renderWith(response); expect(screen.getByText('/music/Linkin Park/Minutes to Midnight')).toBeTruthy(); diff --git a/web/src/routes/collection/+layout.svelte b/web/src/routes/collection/+layout.svelte index 136345e1..9b240ae0 100644 --- a/web/src/routes/collection/+layout.svelte +++ b/web/src/routes/collection/+layout.svelte @@ -39,7 +39,7 @@ getting for it, from user requests, new releases of the artists it follows, and albums whose files went missing. It searches your indexers on a schedule, checks each download against MusicBrainz, and files what's right into the library. Searching and quality are set under - Integrations. + Acquisition.

    {#if verdict}