Fixes the defect the operator spotted in #370 immediately after it shipped: auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a proxy on a public address — a separate host, or a CDN, i.e. anyone running this publicly, since public means TLS means a proxy — recorded the PROXY for every session. created_ip and last_ip were then always equal and the "Address changed" signal could never fire. The feature looked like it worked and reported nothing. Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx). XFF grows left-to-right as each proxy appends the peer it received from, so for client -> CDN -> own-proxy -> app the app sees [client, CDN] with RemoteAddr = own-proxy, and the client sits at XFF[len - hops]: 0 RemoteAddr, XFF ignored — no proxy 1 the address your own proxy observed 2 through a CDN in front of your proxy Default 1, per the operator: publicly reachable means a TLS terminator in front. The cost is real and stated rather than hidden. hops >= 1 DECLARES that a proxy exists; set it with no proxy, or deeper than the actual chain, and the index reaches attacker-supplied entries, letting a visitor choose which address their own session shows — defeating exactly the detection #370 is for. That's inherent to the model, which is why 0 is a first-class value and the admin card says "count your proxies, don't guess high" instead of just exposing a number. Both mis-set shapes are pinned by tests so they stay known consequences rather than surprises. Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an optimisation: ClientIP runs in RequireUser for every authenticated request, so a per-request query would put the database on the critical path of the whole API. New() always returns a usable service so a boot-time DB hiccup degrades to the default instead of breaking that path (rule #131), and Hops() is nil-safe because test routers construct middleware without it. RequireUser now takes a func() int rather than an int — the value is operator-editable at runtime while the middleware is built once at boot, and reading it per request is what makes a save take effect with no restart (rule #25). The admin card is verifiable, not just configurable: it reports the address the CURRENT setting resolves THIS request to, the raw forwarded chain, and the socket peer — so you set the number, save, and confirm the address matches the machine you're on. It also counts the arriving chain and says how many proxies that implies. GET/PUT both return that payload, PUT recomputed under the new value, so the effect is visible without a reload. Also fixes styling in the #370 card that CI could not catch: text-destructive and bg-destructive don't exist in this Tailwind config — the palette is colors.action.destructive — so the "Address changed" warning and the sign-out-others button were rendering unstyled. Both now use text-action-destructive / bg-action-destructive / text-action-fg. Not done here: requestlog.go still logs raw RemoteAddr and will disagree with the sessions UI about who connected. Left for its own change.
870 lines
36 KiB
Svelte
870 lines
36 KiB
Svelte
<script lang="ts">
|
|
import { tick } from 'svelte';
|
|
import { pageTitle } from '$lib/branding';
|
|
import { Save, RefreshCw, Trash2 } from 'lucide-svelte';
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
import {
|
|
createLidarrConfigQuery,
|
|
createQualityProfilesQuery,
|
|
createMetadataProfilesQuery,
|
|
createRootFoldersQuery,
|
|
putLidarrConfig,
|
|
testLidarrConnection,
|
|
createCoverProvidersQuery,
|
|
updateCoverProvider,
|
|
testCoverProvider,
|
|
createTagProvidersQuery,
|
|
updateTagProvider,
|
|
testTagProvider,
|
|
updateSMTPConfig,
|
|
testSMTPConfig,
|
|
createSMTPConfigQuery,
|
|
type CoverProvider,
|
|
type TagProvider,
|
|
type SMTPConfig
|
|
} from '$lib/api/admin';
|
|
import { qk } from '$lib/api/queries';
|
|
import { errCode } from '$lib/api/errors';
|
|
import { pushToast } from '$lib/stores/toast.svelte';
|
|
import Modal from '$lib/components/Modal.svelte';
|
|
import NetworkSettingsCard from '$lib/components/NetworkSettingsCard.svelte';
|
|
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
|
|
|
// Lidarr connection panel. The "saved api key" is masked as "***" on GET —
|
|
// never displayed in the input. The input always starts empty; sending an
|
|
// empty string on PUT tells the backend "preserve the saved key". This is
|
|
// the one field with that semantics; everything else is sent as-typed.
|
|
|
|
const client = useQueryClient();
|
|
|
|
const configStore = createLidarrConfigQuery();
|
|
const config = $derived($configStore);
|
|
|
|
// Local form state — initialized once when config first loads. Subsequent
|
|
// refetches don't clobber whatever the operator is in the middle of editing.
|
|
let baseUrl = $state('');
|
|
let apiKeyInput = $state(''); // intentionally never seeded with '***'
|
|
let qualityId = $state<number>(0);
|
|
let metadataId = $state<number>(0);
|
|
let rootPath = $state<string>('');
|
|
let initialized = false;
|
|
|
|
// Test-derived lists. Populated by onTest or as the first step of
|
|
// onSave. When non-empty, dropdowns prefer these over the query data
|
|
// (the query path only works once Lidarr is enabled, which on a
|
|
// fresh deploy isn't true until after the first successful 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;
|
|
}
|
|
});
|
|
|
|
// Auto-default to the first available option. Sources, in priority
|
|
// order: the test response (works pre-save), then the query data
|
|
// (works post-save). The operator can still override after auto-fill.
|
|
$effect(() => {
|
|
if (qualityId === 0) {
|
|
const list = testQualityProfiles.length ? testQualityProfiles : (profiles.data ?? []);
|
|
if (list.length > 0) qualityId = list[0].id;
|
|
}
|
|
});
|
|
$effect(() => {
|
|
if (metadataId === 0) {
|
|
const list = testMetadataProfiles.length ? testMetadataProfiles : (metadataProfiles.data ?? []);
|
|
if (list.length > 0) metadataId = list[0].id;
|
|
}
|
|
});
|
|
$effect(() => {
|
|
if (rootPath === '') {
|
|
const list = testRootFolders.length ? testRootFolders : (folders.data ?? []);
|
|
if (list.length > 0) rootPath = list[0].path;
|
|
}
|
|
});
|
|
|
|
// The dropdown queries only fire once Lidarr is configured; otherwise the
|
|
// backend has no client to call and would 4xx.
|
|
const profilesEnabled = $derived(!!config.data?.enabled);
|
|
|
|
const profilesStore = $derived(createQualityProfilesQuery(profilesEnabled));
|
|
const profiles = $derived($profilesStore);
|
|
|
|
const metadataProfilesStore = $derived(createMetadataProfilesQuery(profilesEnabled));
|
|
const metadataProfiles = $derived($metadataProfilesStore);
|
|
|
|
const foldersStore = $derived(createRootFoldersQuery(profilesEnabled));
|
|
const folders = $derived($foldersStore);
|
|
|
|
let testResult: LidarrTestResult | null = $state(null);
|
|
let saveError: string | null = $state(null);
|
|
let isSaving = $state(false);
|
|
let isTesting = $state(false);
|
|
|
|
async function onSave() {
|
|
isSaving = true;
|
|
saveError = null;
|
|
try {
|
|
// Test first: confirm the connection AND fetch profiles/folders so
|
|
// the auto-default $effects can fill any zero/empty defaults.
|
|
const test = await testLidarrConnection({
|
|
base_url: baseUrl,
|
|
api_key: apiKeyInput
|
|
});
|
|
testResult = test;
|
|
if (!test.ok) {
|
|
saveError = test.error;
|
|
return;
|
|
}
|
|
testQualityProfiles = test.quality_profiles ?? [];
|
|
testMetadataProfiles = test.metadata_profiles ?? [];
|
|
testRootFolders = test.root_folders ?? [];
|
|
testListErrors = test.list_errors ?? {};
|
|
|
|
// Wait one tick for the auto-default $effects to fire on the
|
|
// newly-populated lists.
|
|
await tick();
|
|
|
|
if (qualityId === 0 || metadataId === 0 || rootPath === '') {
|
|
saveError = 'missing_defaults_after_test';
|
|
return;
|
|
}
|
|
|
|
const cfg: LidarrConfig = {
|
|
enabled: true,
|
|
base_url: baseUrl,
|
|
api_key: apiKeyInput, // empty string tells backend "preserve saved key"
|
|
default_quality_profile_id: qualityId,
|
|
default_metadata_profile_id: metadataId,
|
|
default_root_folder_path: rootPath
|
|
};
|
|
await putLidarrConfig(cfg);
|
|
// Invalidate config + profile/folder lists. A new base_url means a
|
|
// different server, so the cached lists must refetch.
|
|
await Promise.all([
|
|
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
|
|
]);
|
|
apiKeyInput = '';
|
|
} catch (e) {
|
|
const code = errCode(e);
|
|
saveError = code === 'unknown' ? 'save_failed' : code;
|
|
} 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) {
|
|
testQualityProfiles = result.quality_profiles ?? [];
|
|
testMetadataProfiles = result.metadata_profiles ?? [];
|
|
testRootFolders = result.root_folders ?? [];
|
|
testListErrors = result.list_errors ?? {};
|
|
}
|
|
} finally {
|
|
isTesting = false;
|
|
}
|
|
}
|
|
|
|
// Disconnect typed-confirm modal. Requires the literal "DISCONNECT" string
|
|
// (whitespace-trimmed) to avoid muscle-memory clearing of an integration the
|
|
// operator depends 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 Promise.all([
|
|
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
|
|
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
|
|
]);
|
|
modalOpen = false;
|
|
disconnectInput = '';
|
|
} catch (e) {
|
|
const code = errCode(e);
|
|
disconnectError = code === 'unknown' ? 'disconnect_failed' : code;
|
|
}
|
|
}
|
|
|
|
function cancelDisconnect() {
|
|
modalOpen = false;
|
|
disconnectInput = '';
|
|
disconnectError = null;
|
|
}
|
|
|
|
// ---- Cover art providers ----
|
|
const coverProvidersStore = $derived(createCoverProvidersQuery());
|
|
const coverProvidersQ = $derived($coverProvidersStore);
|
|
|
|
// Local edit state — keyed by provider_id. Initialised lazily when
|
|
// the query data lands, then preserved across refetches so the
|
|
// operator's in-flight edits don't get clobbered.
|
|
let coverLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
|
|
let coverTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
|
|
let coverSaving = $state<Record<string, boolean>>({});
|
|
let coverTesting = $state<Record<string, boolean>>({});
|
|
|
|
$effect(() => {
|
|
if (coverProvidersQ.data) {
|
|
for (const p of coverProvidersQ.data.providers) {
|
|
if (!coverLocalState[p.id]) {
|
|
coverLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
function coverHasChanges(provider: CoverProvider): boolean {
|
|
const local = coverLocalState[provider.id];
|
|
if (!local) return false;
|
|
return provider.enabled !== local.enabled || local.apiKey !== '';
|
|
}
|
|
|
|
async function coverSave(provider: CoverProvider) {
|
|
const local = coverLocalState[provider.id];
|
|
if (!local || !coverHasChanges(provider)) return;
|
|
const patch: { enabled?: boolean; api_key?: string } = {};
|
|
if (provider.enabled !== local.enabled) patch.enabled = local.enabled;
|
|
if (local.apiKey !== '') patch.api_key = local.apiKey;
|
|
|
|
coverSaving[provider.id] = true;
|
|
try {
|
|
const result = await updateCoverProvider(provider.id, patch);
|
|
coverLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
|
|
await client.invalidateQueries({ queryKey: qk.coverProviders() });
|
|
if (result.version_bumped) {
|
|
await client.invalidateQueries({ queryKey: qk.coverage() });
|
|
}
|
|
} catch (_e) {
|
|
// Error display is handled inline via saveError pattern; swallow here.
|
|
} finally {
|
|
coverSaving[provider.id] = false;
|
|
}
|
|
}
|
|
|
|
async function coverTest(provider: CoverProvider) {
|
|
coverTesting[provider.id] = true;
|
|
try {
|
|
coverTestResults[provider.id] = await testCoverProvider(provider.id);
|
|
} catch (e) {
|
|
coverTestResults[provider.id] = { ok: false, error: (e as Error).message };
|
|
} finally {
|
|
coverTesting[provider.id] = false;
|
|
}
|
|
}
|
|
|
|
// ---- Tag enrichment providers ----
|
|
// Mirrors the cover-art providers panel over /api/admin/tag-sources (#1490).
|
|
const tagProvidersStore = $derived(createTagProvidersQuery());
|
|
const tagProvidersQ = $derived($tagProvidersStore);
|
|
|
|
let tagLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
|
|
let tagTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
|
|
let tagSaving = $state<Record<string, boolean>>({});
|
|
let tagTesting = $state<Record<string, boolean>>({});
|
|
|
|
$effect(() => {
|
|
if (tagProvidersQ.data) {
|
|
for (const p of tagProvidersQ.data.providers) {
|
|
if (!tagLocalState[p.id]) {
|
|
tagLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
function tagHasChanges(provider: TagProvider): boolean {
|
|
const local = tagLocalState[provider.id];
|
|
if (!local) return false;
|
|
return provider.enabled !== local.enabled || local.apiKey !== '';
|
|
}
|
|
|
|
async function tagSave(provider: TagProvider) {
|
|
const local = tagLocalState[provider.id];
|
|
if (!local || !tagHasChanges(provider)) return;
|
|
const patch: { enabled?: boolean; api_key?: string } = {};
|
|
if (provider.enabled !== local.enabled) patch.enabled = local.enabled;
|
|
if (local.apiKey !== '') patch.api_key = local.apiKey;
|
|
|
|
tagSaving[provider.id] = true;
|
|
try {
|
|
const result = await updateTagProvider(provider.id, patch);
|
|
tagLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
|
|
await client.invalidateQueries({ queryKey: qk.tagProviders() });
|
|
} catch (_e) {
|
|
// Inline error surfacing handled by the mutation toast layer; swallow here.
|
|
} finally {
|
|
tagSaving[provider.id] = false;
|
|
}
|
|
}
|
|
|
|
async function tagTest(provider: TagProvider) {
|
|
tagTesting[provider.id] = true;
|
|
try {
|
|
tagTestResults[provider.id] = await testTagProvider(provider.id);
|
|
} catch (e) {
|
|
tagTestResults[provider.id] = { ok: false, error: (e as Error).message };
|
|
} finally {
|
|
tagTesting[provider.id] = false;
|
|
}
|
|
}
|
|
|
|
// SMTP config -------------------------------------------------------------
|
|
|
|
const smtpStore = $derived(createSMTPConfigQuery());
|
|
const smtpQ = $derived($smtpStore);
|
|
|
|
let smtpForm = $state<SMTPConfig>({
|
|
enabled: false, host: '', port: 587, username: '',
|
|
password: '', from_address: '', from_name: 'Minstrel', use_tls: true,
|
|
});
|
|
let smtpPasswordInput = $state('');
|
|
let smtpSaving = $state(false);
|
|
let smtpTesting = $state(false);
|
|
|
|
$effect(() => {
|
|
if (smtpQ.data) {
|
|
smtpForm = smtpQ.data;
|
|
smtpPasswordInput = '';
|
|
}
|
|
});
|
|
|
|
async function onSaveSMTP() {
|
|
smtpSaving = true;
|
|
try {
|
|
await updateSMTPConfig({ ...smtpForm, password: smtpPasswordInput });
|
|
await client.invalidateQueries({ queryKey: qk.smtpConfig() });
|
|
smtpPasswordInput = '';
|
|
pushToast('SMTP config saved.');
|
|
} catch (e: unknown) {
|
|
const code = errCode(e);
|
|
if (code === 'missing_fields') pushToast('Host and from address are required when enabled.', 'error');
|
|
else pushToast(`Save failed: ${code}`, 'error');
|
|
} finally {
|
|
smtpSaving = false;
|
|
}
|
|
}
|
|
|
|
async function onTestSMTP() {
|
|
smtpTesting = 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 before testing.', 'error');
|
|
else if (code === 'not_configured') pushToast('Save the SMTP config first.', 'error');
|
|
else if (code === 'send_failed') pushToast(`Send failed: ${message || 'see server logs'}`, 'error');
|
|
else pushToast(`Test failed: ${code}`, 'error');
|
|
} finally {
|
|
smtpTesting = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head><title>{pageTitle('Admin · Integrations')}</title></svelte:head>
|
|
|
|
<div class="space-y-6">
|
|
<header class="flex items-center justify-between">
|
|
<h2 class="font-display text-2xl font-medium text-text-primary">Integrations</h2>
|
|
{#if config.data?.enabled}
|
|
<span
|
|
class="inline-flex items-center gap-2 rounded-md bg-accent-tint px-3 py-1 text-xs font-medium text-action-primary"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full bg-action-primary"></span>
|
|
Lidarr · connected
|
|
</span>
|
|
{:else}
|
|
<span
|
|
class="inline-flex items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span>
|
|
Lidarr · unset
|
|
</span>
|
|
{/if}
|
|
</header>
|
|
|
|
<!-- Lidarr panel -->
|
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
|
<div>
|
|
<h3 class="font-display text-lg font-medium text-text-primary">Lidarr</h3>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
Search Lidarr from <span class="font-mono text-accent">/discover</span> and
|
|
route approved requests to it.
|
|
</p>
|
|
</div>
|
|
|
|
<label class="block">
|
|
<span class="block text-sm text-text-secondary">Base URL</span>
|
|
<input
|
|
type="text"
|
|
bind:value={baseUrl}
|
|
placeholder="http://lidarr.lan:8686"
|
|
class="mt-1 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-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
</label>
|
|
|
|
<label class="block">
|
|
<span class="block text-sm text-text-secondary">API key</span>
|
|
<input
|
|
type="password"
|
|
bind:value={apiKeyInput}
|
|
placeholder={config.data?.api_key === '***'
|
|
? '••• (saved — leave empty to keep)'
|
|
: 'Paste API key'}
|
|
class="mt-1 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-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
</label>
|
|
|
|
<label class="block">
|
|
<span class="block text-sm text-text-secondary">Default quality profile</span>
|
|
<select
|
|
bind:value={qualityId}
|
|
disabled={!profilesEnabled && testQualityProfiles.length === 0}
|
|
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
|
|
>
|
|
{#each (testQualityProfiles.length ? testQualityProfiles : (profiles.data ?? [])) as p (p.id)}
|
|
<option value={p.id}>{p.name}</option>
|
|
{/each}
|
|
</select>
|
|
{#if testListErrors.quality_profiles}
|
|
<p class="mt-1 text-xs text-error">
|
|
Couldn't fetch quality profiles — {testListErrors.quality_profiles}
|
|
</p>
|
|
{/if}
|
|
</label>
|
|
|
|
<label class="block">
|
|
<span class="block text-sm text-text-secondary">Default metadata profile</span>
|
|
<select
|
|
bind:value={metadataId}
|
|
disabled={!profilesEnabled && testMetadataProfiles.length === 0}
|
|
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
|
|
>
|
|
{#each (testMetadataProfiles.length ? testMetadataProfiles : (metadataProfiles.data ?? [])) as p (p.id)}
|
|
<option value={p.id}>{p.name}</option>
|
|
{/each}
|
|
</select>
|
|
{#if testListErrors.metadata_profiles}
|
|
<p class="mt-1 text-xs text-error">
|
|
Couldn't fetch metadata profiles — {testListErrors.metadata_profiles}
|
|
</p>
|
|
{/if}
|
|
</label>
|
|
|
|
<label class="block">
|
|
<span class="block text-sm text-text-secondary">Default root folder</span>
|
|
<select
|
|
bind:value={rootPath}
|
|
disabled={!profilesEnabled && testRootFolders.length === 0}
|
|
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
|
|
>
|
|
{#each (testRootFolders.length ? testRootFolders : (folders.data ?? [])) as f (f.path)}
|
|
<option value={f.path}
|
|
>{f.path}{f.accessible ? '' : ' (not accessible)'}</option
|
|
>
|
|
{/each}
|
|
</select>
|
|
{#if testListErrors.root_folders}
|
|
<p class="mt-1 text-xs text-error">
|
|
Couldn't fetch root folders — {testListErrors.root_folders}
|
|
</p>
|
|
{/if}
|
|
</label>
|
|
|
|
{#if testResult}
|
|
{#if testResult.ok}
|
|
<p class="text-sm text-action-primary">
|
|
Connected — Lidarr {testResult.version}
|
|
</p>
|
|
{:else}
|
|
<p class="text-sm text-error">Connection failed — {testResult.error}</p>
|
|
{/if}
|
|
{/if}
|
|
{#if saveError}
|
|
<p class="text-sm text-error">Save failed — {saveError}</p>
|
|
{/if}
|
|
|
|
<div class="flex items-center gap-2 pt-2">
|
|
<button
|
|
type="button"
|
|
onclick={onSave}
|
|
disabled={isSaving}
|
|
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
|
|
>
|
|
<Save size={14} strokeWidth={2} />
|
|
Save changes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={onTest}
|
|
disabled={isTesting}
|
|
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
|
>
|
|
<RefreshCw size={14} strokeWidth={2} />
|
|
Test connection
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => (modalOpen = true)}
|
|
class="ml-auto inline-flex items-center gap-2 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg"
|
|
>
|
|
<Trash2 size={14} strokeWidth={2} />
|
|
Disconnect
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- MusicBrainz overrides — visually present, not implemented. Foreshadows
|
|
future integrations so the page doesn't read like Lidarr-only. -->
|
|
<section class="space-y-2 rounded-xl border border-border bg-surface p-5 opacity-60">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="font-display text-lg font-medium text-text-primary">
|
|
MusicBrainz overrides
|
|
</h3>
|
|
<span
|
|
class="inline-flex items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span>
|
|
unset
|
|
</span>
|
|
</div>
|
|
<p class="text-sm text-text-secondary">Not yet configured.</p>
|
|
</section>
|
|
|
|
<!-- Cover art providers -->
|
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
|
<div>
|
|
<h3 class="font-display text-lg font-medium text-text-primary">Cover art providers</h3>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
External sources for album covers and artist images. Providers
|
|
are tried in registration order; first hit wins.
|
|
</p>
|
|
</div>
|
|
|
|
{#if coverProvidersQ.data}
|
|
{#each coverProvidersQ.data.providers as provider (provider.id)}
|
|
<article class="rounded-lg border border-border bg-background p-4 space-y-3">
|
|
<header class="flex items-baseline justify-between gap-3">
|
|
<div class="flex items-center gap-2">
|
|
<h4 class="font-display text-base font-medium text-text-primary">{provider.display_name}</h4>
|
|
<span
|
|
class="h-1.5 w-1.5 rounded-full {provider.enabled ? 'bg-action-primary' : 'bg-text-muted'}"
|
|
title={provider.enabled ? 'Active' : 'Disabled'}
|
|
></span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
{#each provider.supports as cap}
|
|
<span class="rounded bg-surface px-1.5 py-0.5 text-xs text-text-secondary">{cap.replace('_', ' ')}</span>
|
|
{/each}
|
|
</div>
|
|
</header>
|
|
|
|
{#if coverLocalState[provider.id]}
|
|
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
|
|
<label class="text-sm text-text-secondary" for="enabled-{provider.id}">Enabled</label>
|
|
<div>
|
|
<input id="enabled-{provider.id}" type="checkbox"
|
|
bind:checked={coverLocalState[provider.id].enabled} />
|
|
</div>
|
|
|
|
{#if provider.requires_api_key}
|
|
<label class="text-sm text-text-secondary" for="key-{provider.id}">API key</label>
|
|
<div class="flex items-center gap-2">
|
|
<input id="key-{provider.id}" type="password"
|
|
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Leave blank to use the public test key'}
|
|
bind:value={coverLocalState[provider.id].apiKey}
|
|
class="mt-1 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-none focus:ring-2 focus:ring-accent flex-1" />
|
|
{#if provider.api_key_set}
|
|
<span class="text-xs text-text-secondary whitespace-nowrap">✓ Set</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 pt-1">
|
|
<button type="button"
|
|
disabled={!coverHasChanges(provider) || coverSaving[provider.id]}
|
|
onclick={() => coverSave(provider)}
|
|
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
|
|
{coverSaving[provider.id] ? 'Saving…' : 'Save changes'}
|
|
</button>
|
|
{#if provider.testable}
|
|
<button type="button"
|
|
disabled={coverTesting[provider.id]}
|
|
onclick={() => coverTest(provider)}
|
|
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
|
{coverTesting[provider.id] ? 'Testing…' : 'Test connection'}
|
|
</button>
|
|
{/if}
|
|
{#if coverTestResults[provider.id]}
|
|
{#if coverTestResults[provider.id].ok}
|
|
<p class="text-sm text-action-primary">
|
|
OK{coverTestResults[provider.id].duration_ms ? ` (${coverTestResults[provider.id].duration_ms}ms)` : ''}
|
|
</p>
|
|
{:else}
|
|
<p class="text-sm text-error">
|
|
Failed — {coverTestResults[provider.id].error}
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if provider.id === 'theaudiodb'}
|
|
<p class="text-xs text-text-muted">
|
|
Public test key works out of the box. A free personal key gives separate quota
|
|
accounting — apply at <a class="underline" href="https://www.theaudiodb.com/api_apply.php" target="_blank" rel="noopener noreferrer">theaudiodb.com/api_apply.php</a>.
|
|
</p>
|
|
{/if}
|
|
</article>
|
|
{/each}
|
|
{:else if coverProvidersQ.isPending}
|
|
<p class="text-sm text-text-muted">Loading…</p>
|
|
{:else}
|
|
<p class="text-sm text-text-muted">No providers registered.</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Tag enrichment providers -->
|
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
|
<div>
|
|
<h3 class="font-display text-lg font-medium text-text-primary">Tag enrichment sources</h3>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
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.
|
|
</p>
|
|
</div>
|
|
|
|
{#if tagProvidersQ.data}
|
|
{#each tagProvidersQ.data.providers as provider (provider.id)}
|
|
<article class="rounded-lg border border-border bg-background p-4 space-y-3">
|
|
<header class="flex items-baseline justify-between gap-3">
|
|
<div class="flex items-center gap-2">
|
|
<h4 class="font-display text-base font-medium text-text-primary">{provider.display_name}</h4>
|
|
<span
|
|
class="h-1.5 w-1.5 rounded-full {provider.enabled ? 'bg-action-primary' : 'bg-text-muted'}"
|
|
title={provider.enabled ? 'Active' : 'Disabled'}
|
|
></span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
{#each provider.supports as cap}
|
|
<span class="rounded bg-surface px-1.5 py-0.5 text-xs text-text-secondary">{cap.replace('_', ' ')}</span>
|
|
{/each}
|
|
</div>
|
|
</header>
|
|
|
|
{#if tagLocalState[provider.id]}
|
|
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
|
|
<label class="text-sm text-text-secondary" for="tag-enabled-{provider.id}">Enabled</label>
|
|
<div>
|
|
<input id="tag-enabled-{provider.id}" type="checkbox"
|
|
bind:checked={tagLocalState[provider.id].enabled} />
|
|
</div>
|
|
|
|
{#if provider.requires_api_key}
|
|
<label class="text-sm text-text-secondary" for="tag-key-{provider.id}">API key</label>
|
|
<div class="flex items-center gap-2">
|
|
<input id="tag-key-{provider.id}" type="password"
|
|
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Paste your API key to enable this source'}
|
|
bind:value={tagLocalState[provider.id].apiKey}
|
|
class="mt-1 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-none focus:ring-2 focus:ring-accent flex-1" />
|
|
{#if provider.api_key_set}
|
|
<span class="text-xs text-text-secondary whitespace-nowrap">✓ Set</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 pt-1">
|
|
<button type="button"
|
|
disabled={!tagHasChanges(provider) || tagSaving[provider.id]}
|
|
onclick={() => tagSave(provider)}
|
|
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
|
|
{tagSaving[provider.id] ? 'Saving…' : 'Save changes'}
|
|
</button>
|
|
{#if provider.testable}
|
|
<button type="button"
|
|
disabled={tagTesting[provider.id]}
|
|
onclick={() => tagTest(provider)}
|
|
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
|
{tagTesting[provider.id] ? 'Testing…' : 'Test connection'}
|
|
</button>
|
|
{/if}
|
|
{#if tagTestResults[provider.id]}
|
|
{#if tagTestResults[provider.id].ok}
|
|
<p class="text-sm text-action-primary">
|
|
OK{tagTestResults[provider.id].duration_ms ? ` (${tagTestResults[provider.id].duration_ms}ms)` : ''}
|
|
</p>
|
|
{:else}
|
|
<p class="text-sm text-error">
|
|
Failed — {tagTestResults[provider.id].error}
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if provider.id === 'musicbrainz'}
|
|
<p class="text-xs text-text-muted">
|
|
Keyless and on by default — the always-available baseline. Matches tags by
|
|
recording MBID, so coverage tracks how well your library is MusicBrainz-tagged.
|
|
</p>
|
|
{:else if provider.id === 'lastfm'}
|
|
<p class="text-xs text-text-muted">
|
|
Name-based (artist + title), so it covers tracks without an MBID. Requires a free
|
|
API key — get one at <a class="underline" href="https://www.last.fm/api/account/create" target="_blank" rel="noopener noreferrer">last.fm/api</a>.
|
|
</p>
|
|
{/if}
|
|
</article>
|
|
{/each}
|
|
{:else if tagProvidersQ.isPending}
|
|
<p class="text-sm text-text-muted">Loading…</p>
|
|
{:else}
|
|
<p class="text-sm text-text-muted">No providers registered.</p>
|
|
{/if}
|
|
</section>
|
|
<!-- SMTP / email config -->
|
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
|
<div>
|
|
<h3 class="font-display text-lg font-medium text-text-primary">Email (SMTP)</h3>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
Configure outgoing email for password resets. The "Send test email"
|
|
button verifies your config end-to-end.
|
|
</p>
|
|
</div>
|
|
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" bind:checked={smtpForm.enabled} />
|
|
Enabled
|
|
</label>
|
|
|
|
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
|
|
<label for="smtp-host" class="text-sm text-text-secondary">Host</label>
|
|
<input id="smtp-host" type="text" bind:value={smtpForm.host}
|
|
class="mt-1 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-none focus:ring-2 focus:ring-accent" />
|
|
|
|
<label for="smtp-port" class="text-sm text-text-secondary">Port</label>
|
|
<input id="smtp-port" type="number" min="1" max="65535"
|
|
bind:value={smtpForm.port}
|
|
class="mt-1 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-none focus:ring-2 focus:ring-accent" />
|
|
|
|
<label for="smtp-username" class="text-sm text-text-secondary">Username</label>
|
|
<input id="smtp-username" type="text" bind:value={smtpForm.username}
|
|
class="mt-1 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-none focus:ring-2 focus:ring-accent" />
|
|
|
|
<label for="smtp-password" class="text-sm text-text-secondary">Password</label>
|
|
<input id="smtp-password" type="password"
|
|
placeholder={smtpForm.password === '***' ? 'Leave blank to keep current' : ''}
|
|
bind:value={smtpPasswordInput}
|
|
class="mt-1 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-none focus:ring-2 focus:ring-accent" />
|
|
|
|
<label for="smtp-from" class="text-sm text-text-secondary">From address</label>
|
|
<input id="smtp-from" type="email" bind:value={smtpForm.from_address}
|
|
class="mt-1 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-none focus:ring-2 focus:ring-accent" />
|
|
|
|
<label for="smtp-from-name" class="text-sm text-text-secondary">From name</label>
|
|
<input id="smtp-from-name" type="text" bind:value={smtpForm.from_name}
|
|
class="mt-1 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-none focus:ring-2 focus:ring-accent" />
|
|
|
|
<span class="text-sm text-text-secondary">TLS</span>
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" bind:checked={smtpForm.use_tls} />
|
|
Use STARTTLS
|
|
</label>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 pt-2">
|
|
<button type="button" disabled={smtpSaving} onclick={onSaveSMTP}
|
|
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
|
|
{smtpSaving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
<button type="button" disabled={smtpTesting} onclick={onTestSMTP}
|
|
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
|
{smtpTesting ? 'Sending…' : 'Send test email'}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Client IP detection. Belongs here rather than in user Settings: it
|
|
describes how Minstrel sits behind other infrastructure, same as every
|
|
other card on this page, and it's an operator-wide setting rather than
|
|
a per-user preference. -->
|
|
<NetworkSettingsCard />
|
|
</div>
|
|
|
|
<Modal
|
|
title="Disconnect Lidarr?"
|
|
open={modalOpen}
|
|
onClose={cancelDisconnect}
|
|
>
|
|
<p class="-mt-2 text-text-secondary">
|
|
This clears the saved configuration. Type
|
|
<span class="font-mono text-text-primary">DISCONNECT</span> to remove the
|
|
Lidarr connection.
|
|
</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-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
{#if disconnectError}
|
|
<p class="mt-2 text-sm text-error">Disconnect failed — {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>
|