feat: Settings is one page per section down a left rail, Account first, every setting a row; the profile form no longer saves blanks over your name and email (M503 #5595 #5596 #5597)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m45s
release / go (push) Successful in 1m58s
release / integration (push) Successful in 5m39s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m45s
release / go (push) Successful in 1m58s
release / integration (push) Successful in 5m39s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
- /settings lands on Account: the Android app, profile, password, signed-in devices. Then Playback, Appearance, Notifications, Connections, About. - Each setting is a row (name, one short line, control at the end) in a capped-width column; one-of-N choices are a shared Segmented control. - Collection and Settings share one SectionRail. - Connections leads ListenBrainz with a status line: not connected, connected and not sending, or sending with the last scrobble's age. - Notifications prints its column labels once; the digest email's settings link points at /settings/notifications. - #5596: the profile form now loads what is stored and sends only the fields that changed. It used to start blank, and the server reads a blank as "clear", so one Save wiped the display name and email. - Recommendation metrics moves to Library › Your listening, and Hidden tracks gets a Library tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import SectionRail from '#lib/components/SectionRail.svelte';
|
||||
|
||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||
|
||||
// Settings (#5595): one page per section down a left rail, laid out as
|
||||
// the Collection is. Account is first and is where /settings lands. The
|
||||
// admin's settings are a separate area (/admin) a listener never sees.
|
||||
const items = [
|
||||
{ href: '/settings/account', label: 'Account' },
|
||||
{ href: '/settings/playback', label: 'Playback' },
|
||||
{ href: '/settings/appearance', label: 'Appearance' },
|
||||
{ href: '/settings/notifications', label: 'Notifications' },
|
||||
{ href: '/settings/connections', label: 'Connections' },
|
||||
{ href: '/settings/about', label: 'About' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">Settings</h1>
|
||||
<div class="md:grid md:grid-cols-[11rem_minmax(0,1fr)] md:gap-6">
|
||||
<div class="mb-4 md:mb-0">
|
||||
<div class="md:sticky md:top-4">
|
||||
<SectionRail label="Settings sections" {items} />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Capped so a row's control sits near its name on a wide screen. -->
|
||||
<div class="min-w-0 max-w-3xl space-y-6">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,701 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import type { CreateQueryResult, CreateMutationResult } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createLBStatusQuery,
|
||||
createTokenMutation,
|
||||
createEnabledMutation,
|
||||
type LBStatus
|
||||
} from '#lib/api/listenbrainz.js';
|
||||
import {
|
||||
createRecommendationMetricsQuery,
|
||||
type RecommendationMetrics,
|
||||
type SurfaceIntent,
|
||||
type MetricDelta
|
||||
} from '#lib/api/metrics.js';
|
||||
import { theme, setTheme, type ThemePreference } from '#lib/stores/theme.svelte.js';
|
||||
import { player, setCrossfade } from '#lib/player/store.svelte.js';
|
||||
import {
|
||||
updateProfile,
|
||||
changePassword,
|
||||
regenerateAPIToken,
|
||||
getSubsonicPasswordStatus,
|
||||
generateSubsonicPassword,
|
||||
clearSubsonicPassword
|
||||
} from '#lib/api/me.js';
|
||||
import { errCode } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import MobileAppDownload from '#lib/components/MobileAppDownload.svelte';
|
||||
import ServerVersion from '#lib/components/ServerVersion.svelte';
|
||||
import ActiveSessions from '#lib/components/ActiveSessions.svelte';
|
||||
import NormalizationSetting from '#lib/components/NormalizationSetting.svelte';
|
||||
import NotificationSettings from '#lib/components/NotificationSettings.svelte';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const status = createLBStatusQuery() as CreateQueryResult<LBStatus>;
|
||||
const metrics = createRecommendationMetricsQuery() as CreateQueryResult<RecommendationMetrics>;
|
||||
|
||||
// Per-intent expectation copy: each surface band is judged against its
|
||||
// job — discovery mixes are supposed to run hotter skip rates.
|
||||
const intentHints: Record<SurfaceIntent, string> = {
|
||||
go_to: 'Everyday surfaces — these should beat your own picking.',
|
||||
discovery: 'Exploration surfaces — higher skip rates here are expected and healthy.',
|
||||
direct: 'Music you chose yourself — naturally close to the baseline.'
|
||||
};
|
||||
|
||||
function pct(v: number): string {
|
||||
return `${(v * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
// Deltas come from the server with their margin of error (#2495). The client
|
||||
// no longer subtracts rates itself: the margin needs the sample sizes and
|
||||
// variances, and having both clients re-derive it invites them to disagree.
|
||||
//
|
||||
// A delta that isn't distinguishable from zero is prefixed "≈" and dimmed.
|
||||
// That is the point of this whole change — the card used to render a −12 on
|
||||
// 59 plays exactly as boldly as a −6 on 400, and the first of those is noise.
|
||||
function deltaText(d: MetricDelta | undefined): string {
|
||||
if (!d) return '';
|
||||
const pts = Math.round(d.delta_pp);
|
||||
const signed = pts > 0 ? `+${pts}` : `${pts}`;
|
||||
return d.distinguishable ? signed : `≈${signed}`;
|
||||
}
|
||||
|
||||
function deltaTitle(d: MetricDelta | undefined): string | undefined {
|
||||
if (!d) return undefined;
|
||||
const range = `${d.delta_pp.toFixed(1)} ± ${d.margin_pp.toFixed(1)} points vs baseline`;
|
||||
return d.distinguishable
|
||||
? `${range} (95% confidence)`
|
||||
: `${range} — not distinguishable from zero at 95% confidence, so read this as no measured difference.`;
|
||||
}
|
||||
|
||||
// A skip delta is "worse" above the baseline; a completion delta is "worse"
|
||||
// below it. Neither gets a colour unless it's distinguishable — colouring
|
||||
// noise red is what made the old card misleading.
|
||||
function skipDeltaClass(d: MetricDelta | undefined): string {
|
||||
if (!d?.distinguishable) return 'text-text-secondary opacity-60';
|
||||
return d.delta_pp > 0 ? 'text-danger' : 'text-text-secondary';
|
||||
}
|
||||
|
||||
function completionDeltaClass(d: MetricDelta | undefined): string {
|
||||
if (!d?.distinguishable) return 'text-text-secondary opacity-60';
|
||||
return d.delta_pp < 0 ? 'text-danger' : 'text-text-secondary';
|
||||
}
|
||||
|
||||
// Pick-kind breakdowns are collapsed by default (#1270): with every
|
||||
// system mix stamping provenance, always-open sub-rows would triple
|
||||
// the table height and bury the surface-level comparison.
|
||||
let expandedBreakdowns = $state(new Set<string>());
|
||||
|
||||
function toggleBreakdown(key: string) {
|
||||
const next = new Set(expandedBreakdowns);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
expandedBreakdowns = next;
|
||||
}
|
||||
const tokenMutation = createTokenMutation(queryClient);
|
||||
const enabledMutation = createEnabledMutation(queryClient);
|
||||
|
||||
let tokenInput = $state('');
|
||||
|
||||
function saveToken() {
|
||||
const t = tokenInput.trim();
|
||||
if (!t) return;
|
||||
$tokenMutation.mutate(t, { onSuccess: () => { tokenInput = ''; } });
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
$tokenMutation.mutate('');
|
||||
}
|
||||
|
||||
function toggleEnabled() {
|
||||
if (!$status.data) return;
|
||||
$enabledMutation.mutate(!$status.data.enabled);
|
||||
}
|
||||
|
||||
// Profile card ------------------------------------------------------------
|
||||
|
||||
let profileForm = $state<{ displayName: string; email: string }>({
|
||||
displayName: '',
|
||||
email: '',
|
||||
});
|
||||
let profileSaving = $state(false);
|
||||
|
||||
async function onSaveProfile() {
|
||||
profileSaving = true;
|
||||
try {
|
||||
await updateProfile({
|
||||
display_name: profileForm.displayName,
|
||||
email: profileForm.email,
|
||||
});
|
||||
pushToast('Profile saved.');
|
||||
// Whether email notifications are possible depends on the address.
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationSettings'] });
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
if (code === 'email_taken') pushToast('That email is already in use.', 'error');
|
||||
else if (code === 'email_invalid') pushToast('Email format is invalid.', 'error');
|
||||
else pushToast(`Save failed: ${code}`, 'error');
|
||||
} finally {
|
||||
profileSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Password card -----------------------------------------------------------
|
||||
|
||||
let passwordForm = $state<{ current: string; new: string; confirm: string }>({
|
||||
current: '', new: '', confirm: '',
|
||||
});
|
||||
let passwordSaving = $state(false);
|
||||
|
||||
async function onSavePassword(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (passwordForm.new !== passwordForm.confirm) {
|
||||
pushToast('New passwords do not match.', 'error');
|
||||
return;
|
||||
}
|
||||
passwordSaving = true;
|
||||
try {
|
||||
await changePassword(passwordForm.current, passwordForm.new);
|
||||
pushToast('Password changed. Your other devices have been signed out.');
|
||||
passwordForm = { current: '', new: '', confirm: '' };
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
if (code === 'wrong_password') pushToast('Current password is incorrect.', 'error');
|
||||
else if (code === 'password_too_short') pushToast('Password must be at least 8 characters.', 'error');
|
||||
else pushToast(`Change failed: ${code}`, 'error');
|
||||
} finally {
|
||||
passwordSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// API Token card ----------------------------------------------------------
|
||||
|
||||
// Only set right after Regenerate: the server stores the key's hash, so
|
||||
// this is the one moment the key exists outside the client it goes into.
|
||||
let apiToken = $state<string | null>(null);
|
||||
let tokenSaving = $state(false);
|
||||
let confirmRegen = $state(false);
|
||||
let regenTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function copyToken() {
|
||||
if (!apiToken) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiToken);
|
||||
pushToast('Token copied to clipboard.');
|
||||
} catch {
|
||||
pushToast('Copy failed.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegenerateToken() {
|
||||
if (!confirmRegen) {
|
||||
confirmRegen = true;
|
||||
if (regenTimer) clearTimeout(regenTimer);
|
||||
regenTimer = setTimeout(() => { confirmRegen = false; }, 5000);
|
||||
return;
|
||||
}
|
||||
if (regenTimer) clearTimeout(regenTimer);
|
||||
confirmRegen = false;
|
||||
tokenSaving = true;
|
||||
try {
|
||||
const r = await regenerateAPIToken();
|
||||
apiToken = r.api_token;
|
||||
pushToast('New API token created. Copy it now; it will not be shown again.');
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Regenerate failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
tokenSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Subsonic password card (#5026) ------------------------------------------
|
||||
|
||||
// null until the status loads. The value itself is only held right after
|
||||
// Generate, for the same reason as the API token above.
|
||||
let subsonicEnabled = $state<boolean | null>(null);
|
||||
let subsonicPassword = $state<string | null>(null);
|
||||
let subsonicSaving = $state(false);
|
||||
let confirmSubsonic = $state<'generate' | 'clear' | null>(null);
|
||||
let subsonicTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
subsonicEnabled = (await getSubsonicPasswordStatus()).enabled;
|
||||
} catch {
|
||||
// Leave it unknown; the buttons still work.
|
||||
}
|
||||
});
|
||||
|
||||
// Replacing or removing a password breaks clients already using it, so
|
||||
// both ask for a second click. A first-time Generate breaks nothing.
|
||||
function armSubsonic(action: 'generate' | 'clear'): boolean {
|
||||
if (confirmSubsonic === action) {
|
||||
if (subsonicTimer) clearTimeout(subsonicTimer);
|
||||
confirmSubsonic = null;
|
||||
return true;
|
||||
}
|
||||
confirmSubsonic = action;
|
||||
if (subsonicTimer) clearTimeout(subsonicTimer);
|
||||
subsonicTimer = setTimeout(() => { confirmSubsonic = null; }, 5000);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function onGenerateSubsonic() {
|
||||
if (subsonicEnabled && !armSubsonic('generate')) return;
|
||||
subsonicSaving = true;
|
||||
try {
|
||||
subsonicPassword = (await generateSubsonicPassword()).password;
|
||||
subsonicEnabled = true;
|
||||
pushToast('Subsonic password created. Copy it now; it will not be shown again.');
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Generate failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
subsonicSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClearSubsonic() {
|
||||
if (!armSubsonic('clear')) return;
|
||||
subsonicSaving = true;
|
||||
try {
|
||||
await clearSubsonicPassword();
|
||||
subsonicPassword = null;
|
||||
subsonicEnabled = false;
|
||||
pushToast('Subsonic password turned off.');
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Turn off failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
subsonicSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copySubsonic() {
|
||||
if (!subsonicPassword) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(subsonicPassword);
|
||||
pushToast('Password copied to clipboard.');
|
||||
} catch {
|
||||
pushToast('Copy failed.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-semibold">Settings</h1>
|
||||
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Appearance</h2>
|
||||
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="block text-sm text-text-secondary">Theme</legend>
|
||||
<div class="flex gap-2">
|
||||
{#each ['dark', 'light', 'system'] as opt (opt)}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded border border-border px-3 py-1.5 text-sm
|
||||
{theme.value === opt ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value={opt}
|
||||
checked={theme.value === opt}
|
||||
onchange={() => setTheme(opt as ThemePreference)}
|
||||
class="sr-only"
|
||||
/>
|
||||
<span class="capitalize">{opt}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p class="text-xs text-text-secondary">
|
||||
System follows your device's light/dark preference.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Playback</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label for="crossfade-slider" class="text-sm text-text-secondary">Crossfade</label>
|
||||
<span class="text-sm tabular-nums text-text-primary">
|
||||
{player.crossfadeSec === 0 ? 'Off' : `${player.crossfadeSec}s`}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="crossfade-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value={player.crossfadeSec}
|
||||
oninput={(e) => setCrossfade(Number((e.currentTarget as HTMLInputElement).value))}
|
||||
aria-label="Crossfade duration in seconds"
|
||||
class="w-full accent-accent"
|
||||
/>
|
||||
<p class="text-xs text-text-secondary">
|
||||
Fades the end of one track into the start of the next.
|
||||
0 = off · most albums sound best at 0.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NormalizationSetting />
|
||||
</section>
|
||||
|
||||
<NotificationSettings />
|
||||
|
||||
<section class="space-y-4 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">ListenBrainz</h2>
|
||||
|
||||
{#if $status.isPending}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if $status.isError}
|
||||
<p class="text-text-secondary">Couldn't load status.</p>
|
||||
{:else if $status.data}
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm">
|
||||
<span class="block text-text-secondary mb-1">User token</span>
|
||||
{#if $status.data.token_set}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-primary">••••••••••• (set)</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearToken}
|
||||
class="text-sm text-accent-fg hover:underline"
|
||||
disabled={$tokenMutation.isPending}
|
||||
>clear</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={tokenInput}
|
||||
placeholder="paste your LB token"
|
||||
class="flex-1 rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveToken}
|
||||
disabled={!tokenInput.trim() || $tokenMutation.isPending}
|
||||
class="rounded bg-accent px-3 py-1 text-sm text-background disabled:opacity-50"
|
||||
>Save</button>
|
||||
</div>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={$status.data.enabled}
|
||||
disabled={!$status.data.token_set || $enabledMutation.isPending}
|
||||
onchange={toggleEnabled}
|
||||
/>
|
||||
<span>Send my plays to ListenBrainz</span>
|
||||
</label>
|
||||
|
||||
{#if $status.data.last_scrobbled_at}
|
||||
<p class="text-sm text-text-secondary">
|
||||
Last scrobbled: {new Date($status.data.last_scrobbled_at).toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-text-secondary">
|
||||
Get a token at <a href="https://listenbrainz.org/profile/" target="_blank" rel="noopener" class="text-accent-fg hover:underline">listenbrainz.org/profile</a>.
|
||||
Tokens are stored unencrypted in this server's database — treat as sensitive.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Recommendation metrics card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Recommendation metrics</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
How plays launched from each recommendation surface land, over the last
|
||||
{$metrics.data?.window_days ?? 30} days — compared against the baseline of
|
||||
music you picked yourself. Deltas are percentage points vs that baseline.
|
||||
</p>
|
||||
{#if $metrics.isPending}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
{:else if $metrics.isError}
|
||||
<p class="text-sm text-text-secondary">Couldn't load metrics.</p>
|
||||
{:else if $metrics.data && ($metrics.data.groups.length > 0 || $metrics.data.baseline)}
|
||||
{@const baseline = $metrics.data.baseline}
|
||||
{#if baseline}
|
||||
<p class="rounded bg-background px-3 py-2 text-sm">
|
||||
<span class="font-medium">Baseline — manual plays:</span>
|
||||
<span class="text-text-secondary">
|
||||
{baseline.plays} plays · {pct(baseline.skip_rate)} skip ·
|
||||
{pct(baseline.avg_completion)} completion
|
||||
</span>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">
|
||||
No manual plays in this window yet, so deltas are hidden — raw rates only.
|
||||
</p>
|
||||
{/if}
|
||||
{#each $metrics.data.groups as group (group.intent)}
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-medium">{group.label}</h3>
|
||||
<p class="text-xs text-text-secondary">{intentHints[group.intent]}</p>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-text-secondary">
|
||||
<th class="py-1 font-medium">Surface</th>
|
||||
<th class="py-1 text-right font-medium">Plays</th>
|
||||
<th class="py-1 text-right font-medium">Skip rate</th>
|
||||
<th class="py-1 text-right font-medium">Avg completion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each group.surfaces as m (m.key)}
|
||||
<tr
|
||||
class="border-t border-border"
|
||||
class:opacity-60={m.low_confidence}
|
||||
title={m.low_confidence
|
||||
? 'Fewer than 20 plays — treat these rates as anecdote, not signal.'
|
||||
: undefined}
|
||||
>
|
||||
<td class="py-1">
|
||||
{#if (m.breakdown ?? []).length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded outline-hidden focus-visible:ring-1 focus-visible:ring-accent"
|
||||
aria-expanded={expandedBreakdowns.has(m.key)}
|
||||
onclick={() => toggleBreakdown(m.key)}
|
||||
>
|
||||
<span class="text-xs text-text-secondary" aria-hidden="true">
|
||||
{expandedBreakdowns.has(m.key) ? '▾' : '▸'}
|
||||
</span>
|
||||
{m.label}
|
||||
</button>
|
||||
{:else}
|
||||
{m.label}
|
||||
{/if}{#if m.low_confidence}<span class="ml-1 text-xs text-text-secondary">· low data</span>{/if}
|
||||
</td>
|
||||
<td class="py-1 text-right tabular-nums">{m.plays}</td>
|
||||
<td class="py-1 text-right tabular-nums">
|
||||
{pct(m.skip_rate)}
|
||||
{#if m.skip_delta}
|
||||
<span class="ml-1 text-xs {skipDeltaClass(m.skip_delta)}"
|
||||
data-testid="skip-delta-{m.key}"
|
||||
title={deltaTitle(m.skip_delta)}>{deltaText(m.skip_delta)}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-1 text-right tabular-nums">
|
||||
{pct(m.avg_completion)}
|
||||
{#if m.completion_delta}
|
||||
<span class="ml-1 text-xs {completionDeltaClass(m.completion_delta)}"
|
||||
data-testid="completion-delta-{m.key}"
|
||||
title={deltaTitle(m.completion_delta)}>{deltaText(m.completion_delta)}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Pick-kind provenance sub-rows (#1249/#1270): each
|
||||
surface splits into the populations its builder
|
||||
stamped — For You's taste vs freshness injection,
|
||||
Discover's buckets, the tiered mixes' tiers — the
|
||||
numbers that say WHERE a surface's skips come from.
|
||||
Toggled per surface to keep the default view flat. -->
|
||||
{#if expandedBreakdowns.has(m.key)}
|
||||
{#each m.breakdown ?? [] as b (b.key)}
|
||||
<tr
|
||||
class="border-t border-border/50 text-text-secondary"
|
||||
class:opacity-60={b.low_confidence}
|
||||
title={b.low_confidence
|
||||
? 'Fewer than 20 plays — treat these rates as anecdote, not signal.'
|
||||
: undefined}
|
||||
>
|
||||
<td class="py-1 pl-4 text-xs">
|
||||
↳ {b.label}{#if b.low_confidence}<span class="ml-1">· low data</span>{/if}
|
||||
</td>
|
||||
<td class="py-1 text-right text-xs tabular-nums">{b.plays}</td>
|
||||
<td class="py-1 text-right text-xs tabular-nums">
|
||||
{pct(b.skip_rate)}
|
||||
{#if b.skip_delta}
|
||||
<span class="ml-1 {skipDeltaClass(b.skip_delta)}"
|
||||
data-testid="skip-delta-{b.key}"
|
||||
title={deltaTitle(b.skip_delta)}>{deltaText(b.skip_delta)}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-1 text-right text-xs tabular-nums">
|
||||
{pct(b.avg_completion)}
|
||||
{#if b.completion_delta}
|
||||
<span class="ml-1 {completionDeltaClass(b.completion_delta)}"
|
||||
data-testid="completion-delta-{b.key}"
|
||||
title={deltaTitle(b.completion_delta)}>{deltaText(b.completion_delta)}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
<p class="text-xs text-text-secondary">
|
||||
Deltas compare each surface with your manual plays. A delta marked
|
||||
<span class="opacity-60">≈</span> is smaller than its own margin of error at this
|
||||
sample size — it can't be told apart from no difference, however big it looks.
|
||||
Hover any delta for its range.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">
|
||||
No plays recorded yet. Play something from For You, Discover, or a mix.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Profile card -->
|
||||
<section id="profile" class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Profile</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Your display name and recovery email.
|
||||
</p>
|
||||
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
|
||||
<label class="text-sm text-text-secondary" for="profile-displayname">Display name</label>
|
||||
<input id="profile-displayname" type="text" maxlength="64"
|
||||
bind:value={profileForm.displayName}
|
||||
class="w-full rounded border border-border bg-background px-3 py-2 outline-hidden focus:border-accent" />
|
||||
|
||||
<label class="text-sm text-text-secondary" for="profile-email">Email</label>
|
||||
<input id="profile-email" type="email" autocomplete="email"
|
||||
bind:value={profileForm.email}
|
||||
class="w-full rounded border border-border bg-background px-3 py-2 outline-hidden focus:border-accent" />
|
||||
</div>
|
||||
<button type="button" disabled={profileSaving}
|
||||
onclick={onSaveProfile}
|
||||
class="rounded bg-accent px-3 py-2 text-sm font-medium text-background disabled:opacity-60">
|
||||
{profileSaving ? 'Saving…' : 'Save profile'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Password card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Password</h2>
|
||||
<form onsubmit={onSavePassword} class="space-y-3">
|
||||
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
|
||||
<label class="text-sm text-text-secondary" for="password-current">Current password</label>
|
||||
<input id="password-current" type="password" required minlength="8"
|
||||
autocomplete="current-password"
|
||||
bind:value={passwordForm.current}
|
||||
class="w-full rounded border border-border bg-background px-3 py-2 outline-hidden focus:border-accent" />
|
||||
|
||||
<label class="text-sm text-text-secondary" for="password-new">New password</label>
|
||||
<input id="password-new" type="password" required minlength="8"
|
||||
autocomplete="new-password"
|
||||
bind:value={passwordForm.new}
|
||||
class="w-full rounded border border-border bg-background px-3 py-2 outline-hidden focus:border-accent" />
|
||||
|
||||
<label class="text-sm text-text-secondary" for="password-confirm">Confirm</label>
|
||||
<input id="password-confirm" type="password" required minlength="8"
|
||||
bind:value={passwordForm.confirm}
|
||||
class="w-full rounded border border-border bg-background px-3 py-2 outline-hidden focus:border-accent" />
|
||||
</div>
|
||||
<button type="submit" disabled={passwordSaving}
|
||||
class="rounded bg-accent px-3 py-2 text-sm font-medium text-background disabled:opacity-60">
|
||||
{passwordSaving ? 'Saving…' : 'Change password'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- API Token card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">API Token</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Used by Subsonic clients that sign in with an API key (OpenSubsonic apiKey).
|
||||
Minstrel keeps only a fingerprint of the token, so it can't show you the current one.
|
||||
Regenerate to get a new token; clients using the old one will need the new one.
|
||||
</p>
|
||||
{#if apiToken}
|
||||
<code class="block break-all rounded bg-background p-2 text-xs">
|
||||
{apiToken}
|
||||
</code>
|
||||
<p class="text-xs text-text-secondary">Copy this now. It won't be shown again.</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
{#if apiToken}
|
||||
<button type="button" onclick={copyToken}
|
||||
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
||||
Copy
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" disabled={tokenSaving}
|
||||
onclick={onRegenerateToken}
|
||||
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
||||
{confirmRegen ? 'Click again to confirm' : (tokenSaving ? 'Regenerating…' : 'Regenerate')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Subsonic password card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Subsonic password</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
For Subsonic apps that can't use an API token and ask for a username and password instead.
|
||||
Sign those apps in with your username and this password, not your login password.
|
||||
Minstrel generates it, and has to store it readable for these apps to work, so it is never
|
||||
your login password. Use the API token where the app supports it.
|
||||
</p>
|
||||
{#if subsonicEnabled !== null}
|
||||
<p class="text-sm">
|
||||
{subsonicEnabled ? 'A Subsonic password is set.' : "No Subsonic password is set; these apps can't sign in."}
|
||||
</p>
|
||||
{/if}
|
||||
{#if subsonicPassword}
|
||||
<code class="block break-all rounded bg-background p-2 text-xs">
|
||||
{subsonicPassword}
|
||||
</code>
|
||||
<p class="text-xs text-text-secondary">Copy this now. It won't be shown again.</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
{#if subsonicPassword}
|
||||
<button type="button" onclick={copySubsonic}
|
||||
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
||||
Copy
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" disabled={subsonicSaving}
|
||||
onclick={onGenerateSubsonic}
|
||||
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
||||
{confirmSubsonic === 'generate' ? 'Click again to confirm' : (subsonicEnabled ? 'Regenerate' : 'Generate')}
|
||||
</button>
|
||||
{#if subsonicEnabled}
|
||||
<button type="button" disabled={subsonicSaving}
|
||||
onclick={onClearSubsonic}
|
||||
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
||||
{confirmSubsonic === 'clear' ? 'Click again to confirm' : 'Turn off'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sits with Password, API Token and Subsonic password rather than near
|
||||
the bottom: these are the account-security group, and this is the one
|
||||
that tells you the others need attention. -->
|
||||
<ActiveSessions />
|
||||
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Library</h2>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li>
|
||||
<a href="/library/hidden" class="text-accent-fg hover:underline">Hidden tracks</a>
|
||||
<span class="text-text-secondary"> — tracks you've flagged as bad rips, wrong tags, or otherwise unplayable.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<MobileAppDownload />
|
||||
|
||||
<div class="pt-2 text-center">
|
||||
<ServerVersion />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
// /settings has no page of its own: it opens on Account, the first section
|
||||
// (#5595). A universal load, as the app is adapter-static with ssr off.
|
||||
export const load = () => {
|
||||
throw redirect(308, '/settings/account');
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
|
||||
vi.mock('#lib/api/listenbrainz.js', () => {
|
||||
// Mocks must look like a Svelte readable store. The page reads e.g.
|
||||
// $status.isPending, which is shorthand for "subscribe to status,
|
||||
// then read .isPending on the emitted value." Returning an object
|
||||
// that ALSO carries isPending/etc. as bare properties (without a
|
||||
// working subscribe) makes $status resolve to undefined.
|
||||
const stub = <T extends Record<string, unknown>>(value: T) => ({
|
||||
subscribe: (run: (v: T) => void) => {
|
||||
run(value);
|
||||
return () => {};
|
||||
}
|
||||
});
|
||||
return {
|
||||
createLBStatusQuery: () =>
|
||||
stub({
|
||||
isPending: false,
|
||||
isError: false,
|
||||
data: { token_set: false, enabled: false }
|
||||
}),
|
||||
createTokenMutation: () => stub({ mutate: vi.fn(), isPending: false }),
|
||||
createEnabledMutation: () => stub({ mutate: vi.fn(), isPending: false })
|
||||
};
|
||||
});
|
||||
|
||||
// The Notifications section has its own tests (NotificationSettings.test.ts);
|
||||
// here it only needs a settled query so the page renders.
|
||||
vi.mock('#lib/api/notifications.js', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('#lib/api/notifications.js')>();
|
||||
const { mockQuery } = await import('../../test-utils/query');
|
||||
return {
|
||||
...mod,
|
||||
createNotificationSettingsQuery: () =>
|
||||
mockQuery({ data: { kinds: [], email_available: true } }),
|
||||
putNotificationSettings: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
// The Volume leveling setting loads on mount; keep it off the network.
|
||||
vi.mock('#lib/api/normalization.js', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('#lib/api/normalization.js')>();
|
||||
return {
|
||||
...mod,
|
||||
getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
|
||||
putNormalization: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('#lib/api/metrics.js', () => ({
|
||||
createRecommendationMetricsQuery: () => ({
|
||||
subscribe: (run: (v: unknown) => void) => {
|
||||
run({ isPending: false, isError: false, data: { window_days: 30, baseline: null, groups: [] } });
|
||||
return () => {};
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
});
|
||||
|
||||
import SettingsPage from './+page.svelte';
|
||||
|
||||
describe('Settings → Appearance', () => {
|
||||
test('renders three theme options', () => {
|
||||
render(SettingsPage);
|
||||
expect(screen.getByText(/^Appearance$/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('dark', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('light', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('system', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking light writes localStorage and sets data-theme', async () => {
|
||||
render(SettingsPage);
|
||||
const lightRadio = screen.getByLabelText('light', { exact: false }) as HTMLInputElement;
|
||||
await fireEvent.click(lightRadio);
|
||||
expect(localStorage.getItem('minstrel:theme')).toBe('light');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||
});
|
||||
|
||||
test('clicking dark writes localStorage and sets data-theme', async () => {
|
||||
render(SettingsPage);
|
||||
const darkRadio = screen.getByLabelText('dark', { exact: false }) as HTMLInputElement;
|
||||
await fireEvent.click(darkRadio);
|
||||
expect(localStorage.getItem('minstrel:theme')).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { user } from '#lib/auth/user.svelte.js';
|
||||
import ServerVersion from '#lib/components/ServerVersion.svelte';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings · About')}</title></svelte:head>
|
||||
|
||||
<SettingsGroup title="About">
|
||||
<SettingRow label="Server version">
|
||||
<ServerVersion />
|
||||
</SettingRow>
|
||||
{#if user.value}
|
||||
<SettingRow label="Signed in as">
|
||||
<span class="text-sm text-text-secondary">{user.value.username}</span>
|
||||
</SettingRow>
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { getMyProfile, updateProfile, changePassword, type MyProfile } from '#lib/api/me.js';
|
||||
import { errCode } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import MobileAppDownload from '#lib/components/MobileAppDownload.svelte';
|
||||
import ActiveSessions from '#lib/components/ActiveSessions.svelte';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
|
||||
// Settings › Account (#5595), where /settings lands: the Android app,
|
||||
// the profile, the password and the devices signed in.
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const input =
|
||||
'w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm outline-hidden focus:border-accent disabled:opacity-60';
|
||||
const primary =
|
||||
'rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg hover:opacity-90 disabled:opacity-50';
|
||||
|
||||
// Profile -------------------------------------------------------------------
|
||||
// The form is filled from what is stored before it can be saved, and a
|
||||
// save sends only what changed (#5596): it used to start blank, and a
|
||||
// blank field clears that field on the server.
|
||||
|
||||
let stored = $state<MyProfile | null>(null);
|
||||
let loadFailed = $state(false);
|
||||
let displayName = $state('');
|
||||
let email = $state('');
|
||||
let profileSaving = $state(false);
|
||||
|
||||
async function loadProfile() {
|
||||
loadFailed = false;
|
||||
try {
|
||||
stored = await getMyProfile();
|
||||
displayName = stored.display_name ?? '';
|
||||
email = stored.email ?? '';
|
||||
} catch {
|
||||
loadFailed = true;
|
||||
}
|
||||
}
|
||||
onMount(loadProfile);
|
||||
|
||||
const changes = $derived.by(() => {
|
||||
if (!stored) return {};
|
||||
const c: { display_name?: string; email?: string } = {};
|
||||
if (displayName.trim() !== (stored.display_name ?? '')) c.display_name = displayName;
|
||||
if (email.trim().toLowerCase() !== (stored.email ?? '')) c.email = email;
|
||||
return c;
|
||||
});
|
||||
const dirty = $derived(Object.keys(changes).length > 0);
|
||||
|
||||
async function onSaveProfile(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!dirty) return;
|
||||
profileSaving = true;
|
||||
try {
|
||||
stored = await updateProfile(changes);
|
||||
displayName = stored.display_name ?? '';
|
||||
email = stored.email ?? '';
|
||||
pushToast('Profile saved.');
|
||||
// Whether email notifications are possible depends on the address.
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationSettings'] });
|
||||
} catch (err: unknown) {
|
||||
const code = errCode(err);
|
||||
if (code === 'email_taken') pushToast('That email is already in use.', 'error');
|
||||
else if (code === 'email_invalid') pushToast('Email format is invalid.', 'error');
|
||||
else pushToast(`Save failed: ${code}`, 'error');
|
||||
} finally {
|
||||
profileSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Password ------------------------------------------------------------------
|
||||
|
||||
let current = $state('');
|
||||
let next = $state('');
|
||||
let confirm = $state('');
|
||||
let passwordSaving = $state(false);
|
||||
|
||||
async function onSavePassword(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (next !== confirm) {
|
||||
pushToast('New passwords do not match.', 'error');
|
||||
return;
|
||||
}
|
||||
passwordSaving = true;
|
||||
try {
|
||||
await changePassword(current, next);
|
||||
pushToast('Password changed. Your other devices have been signed out.');
|
||||
current = next = confirm = '';
|
||||
} catch (err: unknown) {
|
||||
const code = errCode(err);
|
||||
if (code === 'wrong_password') pushToast('Current password is incorrect.', 'error');
|
||||
else if (code === 'password_too_short') pushToast('Password must be at least 8 characters.', 'error');
|
||||
else pushToast(`Change failed: ${code}`, 'error');
|
||||
} finally {
|
||||
passwordSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings · Account')}</title></svelte:head>
|
||||
|
||||
<MobileAppDownload />
|
||||
|
||||
<form onsubmit={onSaveProfile}>
|
||||
<SettingsGroup title="Profile" id="profile">
|
||||
{#if loadFailed}
|
||||
<div class="px-4 py-3 text-sm text-text-secondary">
|
||||
Couldn't load your profile.
|
||||
<button type="button" class="underline hover:text-text-primary" onclick={loadProfile}>Try again</button>
|
||||
</div>
|
||||
{:else}
|
||||
<SettingRow label="Username">
|
||||
<span class="text-sm text-text-secondary">{stored?.username ?? '…'}</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="Display name" hint="Shown in place of your username." for="profile-displayname">
|
||||
<input
|
||||
id="profile-displayname"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
disabled={!stored}
|
||||
bind:value={displayName}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Email" hint="For password resets and email notifications." for="profile-email">
|
||||
<input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
disabled={!stored}
|
||||
bind:value={email}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div class="flex justify-end px-4 py-3">
|
||||
<button type="submit" disabled={!dirty || profileSaving} class={primary}>
|
||||
{profileSaving ? 'Saving…' : 'Save profile'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
</form>
|
||||
|
||||
<form onsubmit={onSavePassword}>
|
||||
<SettingsGroup title="Password" hint="Changing it signs out your other devices.">
|
||||
<SettingRow label="Current password" for="password-current">
|
||||
<input
|
||||
id="password-current"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="current-password"
|
||||
bind:value={current}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="New password" hint="At least 8 characters." for="password-new">
|
||||
<input
|
||||
id="password-new"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
bind:value={next}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Confirm new password" for="password-confirm">
|
||||
<input
|
||||
id="password-confirm"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
bind:value={confirm}
|
||||
class={input}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div class="flex justify-end px-4 py-3">
|
||||
<button type="submit" disabled={passwordSaving || !current || !next || !confirm} class={primary}>
|
||||
{passwordSaving ? 'Saving…' : 'Change password'}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</form>
|
||||
|
||||
<ActiveSessions />
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
vi.mock('#lib/api/me.js', () => ({
|
||||
getMyProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
// ActiveSessions, the page's last group.
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
revokeSession: vi.fn(),
|
||||
revokeOtherSessions: vi.fn()
|
||||
}));
|
||||
vi.mock('#lib/api/client.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('#lib/api/client.js')>()),
|
||||
api: { get: vi.fn() }
|
||||
}));
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import AccountPage from './+page.svelte';
|
||||
import { getMyProfile, updateProfile, changePassword } from '#lib/api/me.js';
|
||||
import { api } from '#lib/api/client.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
|
||||
const alice = { id: '1', username: 'alice', display_name: 'Alice', email: 'alice@example.com', is_admin: false };
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(profile: typeof alice | Error = alice) {
|
||||
if (profile instanceof Error) vi.mocked(getMyProfile).mockRejectedValue(profile);
|
||||
else vi.mocked(getMyProfile).mockResolvedValue(profile);
|
||||
vi.mocked(api.get).mockRejectedValue(new Error('no apk'));
|
||||
return render(AccountPage);
|
||||
}
|
||||
|
||||
describe('Settings › Account', () => {
|
||||
test('the form holds what is stored, and Save is off until something changes', async () => {
|
||||
setup();
|
||||
await waitFor(() => expect(screen.getByLabelText(/display name/i)).toHaveValue('Alice'));
|
||||
expect(screen.getByLabelText(/^email/i)).toHaveValue('alice@example.com');
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /save profile/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
// #5596: the form used to start blank, and Save sent the blanks, which
|
||||
// the server reads as "clear": one click wiped the name and the email.
|
||||
test('changing the display name sends the display name alone, never a blank email', async () => {
|
||||
setup();
|
||||
vi.mocked(updateProfile).mockResolvedValue({ ...alice, display_name: 'Al' });
|
||||
const name = screen.getByLabelText(/display name/i);
|
||||
await waitFor(() => expect(name).toHaveValue('Alice'));
|
||||
await fireEvent.input(name, { target: { value: 'Al' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
||||
await waitFor(() => expect(updateProfile).toHaveBeenCalledWith({ display_name: 'Al' }));
|
||||
expect(pushToast).toHaveBeenCalledWith('Profile saved.');
|
||||
});
|
||||
|
||||
test('a profile that would not load offers a retry and no Save', async () => {
|
||||
setup(new Error('down'));
|
||||
expect(await screen.findByText(/couldn't load your profile/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /save profile/i })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('mismatched passwords are refused before the server is asked', async () => {
|
||||
setup();
|
||||
await fireEvent.input(screen.getByLabelText(/current password/i), { target: { value: 'oldpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/^new password/i), { target: { value: 'newpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/confirm new password/i), { target: { value: 'different1' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(changePassword).not.toHaveBeenCalled();
|
||||
expect(pushToast).toHaveBeenCalledWith('New passwords do not match.', 'error');
|
||||
});
|
||||
|
||||
test('matched passwords call changePassword(current, new)', async () => {
|
||||
setup();
|
||||
vi.mocked(changePassword).mockResolvedValue(undefined);
|
||||
await fireEvent.input(screen.getByLabelText(/current password/i), { target: { value: 'oldpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/^new password/i), { target: { value: 'newpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/confirm new password/i), { target: { value: 'newpw1234' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change password/i }));
|
||||
await waitFor(() => expect(changePassword).toHaveBeenCalledWith('oldpw1234', 'newpw1234'));
|
||||
});
|
||||
|
||||
test('the Android app leads the page when the server bundles it', async () => {
|
||||
vi.mocked(getMyProfile).mockResolvedValue(alice);
|
||||
vi.mocked(api.get).mockResolvedValue({ version: '2026.10.10.2300', apk_url: '/api/client/apk', size_bytes: 31_457_280 });
|
||||
const { container } = render(AccountPage);
|
||||
const download = await screen.findByRole('link', { name: /download/i });
|
||||
expect(download).toHaveAttribute('href', '/api/client/apk');
|
||||
expect(screen.getByText(/2026\.10\.10\.2300 · 30\.0 MB/)).toBeInTheDocument();
|
||||
const titles = Array.from(container.querySelectorAll('h2')).map((h) => h.textContent?.trim());
|
||||
expect(titles[0]).toBe('Android app');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { theme, setTheme, type ThemePreference } from '#lib/stores/theme.svelte.js';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
import Segmented from '#lib/components/settings/Segmented.svelte';
|
||||
|
||||
const themes: { value: ThemePreference; label: string }[] = [
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'system', label: 'System' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings · Appearance')}</title></svelte:head>
|
||||
|
||||
<SettingsGroup title="Appearance">
|
||||
<SettingRow label="Theme" hint="System follows your device's light or dark setting.">
|
||||
<Segmented name="theme" label="Theme" options={themes} value={theme.value} onchange={setTheme} />
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
|
||||
import AppearancePage from './+page.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
});
|
||||
|
||||
describe('Settings › Appearance', () => {
|
||||
test('the theme is one choice of three', () => {
|
||||
render(AppearancePage);
|
||||
expect(screen.getByRole('radiogroup', { name: 'Theme' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('radio').map((r) => r.parentElement?.textContent?.trim())).toEqual(['Dark', 'Light', 'System']);
|
||||
});
|
||||
|
||||
test('choosing light stores it and sets data-theme', async () => {
|
||||
render(AppearancePage);
|
||||
await fireEvent.click(screen.getByRole('radio', { name: 'Light' }));
|
||||
expect(localStorage.getItem('minstrel:theme')).toBe('light');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||
});
|
||||
|
||||
test('choosing dark stores it and sets data-theme', async () => {
|
||||
render(AppearancePage);
|
||||
await fireEvent.click(screen.getByRole('radio', { name: 'Dark' }));
|
||||
expect(localStorage.getItem('minstrel:theme')).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import type { CreateQueryResult } from '@tanstack/svelte-query';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import {
|
||||
createLBStatusQuery,
|
||||
createTokenMutation,
|
||||
createEnabledMutation,
|
||||
type LBStatus
|
||||
} from '#lib/api/listenbrainz.js';
|
||||
import {
|
||||
regenerateAPIToken,
|
||||
getSubsonicPasswordStatus,
|
||||
generateSubsonicPassword,
|
||||
clearSubsonicPassword
|
||||
} from '#lib/api/me.js';
|
||||
import { errCode } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
|
||||
// Settings › Connections (#5595): what reaches this account from outside
|
||||
// the web app. ListenBrainz leads with one line saying whether plays are
|
||||
// going out (#189); the Subsonic apps' two ways to sign in follow.
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const button =
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||||
|
||||
// ListenBrainz ------------------------------------------------------------
|
||||
|
||||
const status = createLBStatusQuery() as CreateQueryResult<LBStatus>;
|
||||
const tokenMutation = createTokenMutation(queryClient);
|
||||
const enabledMutation = createEnabledMutation(queryClient);
|
||||
let tokenInput = $state('');
|
||||
|
||||
function lbState(s: LBStatus): string {
|
||||
if (!s.token_set) return 'Not connected';
|
||||
if (!s.enabled) return 'Connected · not sending plays';
|
||||
return s.last_scrobbled_at
|
||||
? `Sending plays · last one ${relativeTime(s.last_scrobbled_at)}`
|
||||
: 'Sending plays · none sent yet';
|
||||
}
|
||||
|
||||
function saveToken() {
|
||||
const t = tokenInput.trim();
|
||||
if (!t) return;
|
||||
$tokenMutation.mutate(t, { onSuccess: () => { tokenInput = ''; } });
|
||||
}
|
||||
|
||||
// API token ---------------------------------------------------------------
|
||||
// Only held right after Regenerate: the server stores the key's hash, so
|
||||
// this is the one moment the key exists outside the app it goes into.
|
||||
|
||||
let apiToken = $state<string | null>(null);
|
||||
let tokenSaving = $state(false);
|
||||
let confirmRegen = $state(false);
|
||||
let regenTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function onRegenerateToken() {
|
||||
if (!confirmRegen) {
|
||||
confirmRegen = true;
|
||||
if (regenTimer) clearTimeout(regenTimer);
|
||||
regenTimer = setTimeout(() => { confirmRegen = false; }, 5000);
|
||||
return;
|
||||
}
|
||||
if (regenTimer) clearTimeout(regenTimer);
|
||||
confirmRegen = false;
|
||||
tokenSaving = true;
|
||||
try {
|
||||
apiToken = (await regenerateAPIToken()).api_token;
|
||||
pushToast('New API token created. Copy it now; it will not be shown again.');
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Regenerate failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
tokenSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Subsonic password (#5026) -------------------------------------------------
|
||||
// null until the status loads. The value is only held right after
|
||||
// Generate, for the same reason as the API token.
|
||||
|
||||
let subsonicEnabled = $state<boolean | null>(null);
|
||||
let subsonicPassword = $state<string | null>(null);
|
||||
let subsonicSaving = $state(false);
|
||||
let confirmSubsonic = $state<'generate' | 'clear' | null>(null);
|
||||
let subsonicTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
subsonicEnabled = (await getSubsonicPasswordStatus()).enabled;
|
||||
} catch {
|
||||
// Leave it unknown; the buttons still work.
|
||||
}
|
||||
});
|
||||
|
||||
// Replacing or removing a password breaks apps already using it, so both
|
||||
// ask for a second click. A first-time Generate breaks nothing.
|
||||
function armSubsonic(action: 'generate' | 'clear'): boolean {
|
||||
if (confirmSubsonic === action) {
|
||||
if (subsonicTimer) clearTimeout(subsonicTimer);
|
||||
confirmSubsonic = null;
|
||||
return true;
|
||||
}
|
||||
confirmSubsonic = action;
|
||||
if (subsonicTimer) clearTimeout(subsonicTimer);
|
||||
subsonicTimer = setTimeout(() => { confirmSubsonic = null; }, 5000);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function onGenerateSubsonic() {
|
||||
if (subsonicEnabled && !armSubsonic('generate')) return;
|
||||
subsonicSaving = true;
|
||||
try {
|
||||
subsonicPassword = (await generateSubsonicPassword()).password;
|
||||
subsonicEnabled = true;
|
||||
pushToast('Subsonic password created. Copy it now; it will not be shown again.');
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Generate failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
subsonicSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClearSubsonic() {
|
||||
if (!armSubsonic('clear')) return;
|
||||
subsonicSaving = true;
|
||||
try {
|
||||
await clearSubsonicPassword();
|
||||
subsonicPassword = null;
|
||||
subsonicEnabled = false;
|
||||
pushToast('Subsonic password turned off.');
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Turn off failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
subsonicSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(text: string | null) {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
pushToast('Copied to clipboard.');
|
||||
} catch {
|
||||
pushToast('Copy failed.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings · Connections')}</title></svelte:head>
|
||||
|
||||
{#snippet lbHint()}
|
||||
Get one at <a
|
||||
href="https://listenbrainz.org/profile/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-accent-fg hover:underline">listenbrainz.org/profile</a
|
||||
>. It's stored on this server unencrypted.
|
||||
{/snippet}
|
||||
|
||||
{#snippet secret(value: string, label: string)}
|
||||
<div class="flex flex-wrap items-center gap-2 px-4 py-3">
|
||||
<code class="min-w-0 flex-1 break-all rounded bg-background px-2 py-1.5 text-xs" data-testid="secret">{value}</code>
|
||||
<button type="button" onclick={() => copy(value)} aria-label="Copy {label}" class={button}>Copy</button>
|
||||
<p class="w-full text-xs text-text-secondary">Copy it now. It won't be shown again.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<SettingsGroup title="ListenBrainz" hint="Sends what you play to your ListenBrainz profile.">
|
||||
{#if $status.isPending}
|
||||
<div class="px-4 py-3 text-sm text-text-secondary">Loading…</div>
|
||||
{:else if $status.isError}
|
||||
<div class="px-4 py-3 text-sm text-text-secondary">Couldn't load ListenBrainz status.</div>
|
||||
{:else if $status.data}
|
||||
{@const s = $status.data}
|
||||
<SettingRow label="Status">
|
||||
<span class="text-sm {s.token_set && s.enabled ? 'text-text-primary' : 'text-text-secondary'}" data-testid="lb-state"
|
||||
>{lbState(s)}</span
|
||||
>
|
||||
</SettingRow>
|
||||
<SettingRow label="User token" hint={s.token_set ? undefined : lbHint} for={s.token_set ? undefined : 'lb-token'}>
|
||||
{#if s.token_set}
|
||||
<span class="text-sm text-text-secondary">Saved</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => $tokenMutation.mutate('')}
|
||||
disabled={$tokenMutation.isPending}
|
||||
class={button}>Remove</button
|
||||
>
|
||||
{:else}
|
||||
<input
|
||||
id="lb-token"
|
||||
type="password"
|
||||
bind:value={tokenInput}
|
||||
placeholder="Paste your token"
|
||||
class="w-56 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveToken}
|
||||
disabled={!tokenInput.trim() || $tokenMutation.isPending}
|
||||
class={button}>Save</button
|
||||
>
|
||||
{/if}
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Send my plays"
|
||||
hint={s.token_set ? undefined : 'Save a token first.'}
|
||||
for="lb-enabled"
|
||||
>
|
||||
<input
|
||||
id="lb-enabled"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={s.enabled}
|
||||
checked={s.enabled}
|
||||
disabled={!s.token_set || $enabledMutation.isPending}
|
||||
onchange={() => $enabledMutation.mutate(!s.enabled)}
|
||||
class="h-4 w-4 cursor-pointer accent-accent disabled:cursor-not-allowed"
|
||||
/>
|
||||
</SettingRow>
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Subsonic apps" hint="How third-party Subsonic apps sign in as you.">
|
||||
<SettingRow label="API token" hint="For apps that sign in with an API key. Use this where you can.">
|
||||
<button type="button" disabled={tokenSaving} onclick={onRegenerateToken} class={button}>
|
||||
{confirmRegen ? 'Click again to confirm' : tokenSaving ? 'Regenerating…' : 'Regenerate'}
|
||||
</button>
|
||||
</SettingRow>
|
||||
{#if apiToken}
|
||||
{@render secret(apiToken, 'API token')}
|
||||
{/if}
|
||||
|
||||
<SettingRow
|
||||
label="Subsonic password"
|
||||
hint="For apps that ask for a username and password. Generated, never your login password."
|
||||
>
|
||||
{#if subsonicEnabled !== null}
|
||||
<span class="text-sm text-text-secondary" data-testid="subsonic-state">{subsonicEnabled ? 'On' : 'Off'}</span>
|
||||
{/if}
|
||||
<span class="flex gap-2" data-testid="subsonic-actions">
|
||||
<button type="button" disabled={subsonicSaving} onclick={onGenerateSubsonic} class={button}>
|
||||
{confirmSubsonic === 'generate' ? 'Click again to confirm' : subsonicEnabled ? 'Regenerate' : 'Generate'}
|
||||
</button>
|
||||
{#if subsonicEnabled}
|
||||
<button type="button" disabled={subsonicSaving} onclick={onClearSubsonic} class={button}>
|
||||
{confirmSubsonic === 'clear' ? 'Click again to confirm' : 'Turn off'}
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</SettingRow>
|
||||
{#if subsonicPassword}
|
||||
{@render secret(subsonicPassword, 'Subsonic password')}
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import type { LBStatus } from '#lib/api/listenbrainz.js';
|
||||
|
||||
vi.mock('#lib/api/listenbrainz.js', () => ({
|
||||
createLBStatusQuery: vi.fn(),
|
||||
createTokenMutation: vi.fn(),
|
||||
createEnabledMutation: vi.fn()
|
||||
}));
|
||||
vi.mock('#lib/api/me.js', () => ({
|
||||
regenerateAPIToken: vi.fn(),
|
||||
getSubsonicPasswordStatus: vi.fn().mockResolvedValue({ enabled: false }),
|
||||
generateSubsonicPassword: vi.fn(),
|
||||
clearSubsonicPassword: vi.fn()
|
||||
}));
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import ConnectionsPage from './+page.svelte';
|
||||
import { createLBStatusQuery, createTokenMutation, createEnabledMutation } from '#lib/api/listenbrainz.js';
|
||||
import {
|
||||
regenerateAPIToken,
|
||||
getSubsonicPasswordStatus,
|
||||
generateSubsonicPassword,
|
||||
clearSubsonicPassword
|
||||
} from '#lib/api/me.js';
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
function setup(lb: LBStatus = { enabled: false, token_set: false, last_scrobbled_at: null }, mutate = vi.fn()) {
|
||||
vi.mocked(createLBStatusQuery).mockReturnValue(
|
||||
readable({ data: lb, isPending: false, isError: false }) as unknown as ReturnType<typeof createLBStatusQuery>
|
||||
);
|
||||
const mutation = () =>
|
||||
readable({ isPending: false, mutate, mutateAsync: vi.fn() }) as unknown as ReturnType<typeof createTokenMutation>;
|
||||
vi.mocked(createTokenMutation).mockReturnValue(mutation());
|
||||
vi.mocked(createEnabledMutation).mockReturnValue(mutation() as unknown as ReturnType<typeof createEnabledMutation>);
|
||||
return render(ConnectionsPage);
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('Settings › Connections — ListenBrainz', () => {
|
||||
test('with no token: says not connected, asks for one, and sending is off', () => {
|
||||
setup();
|
||||
expect(screen.getByTestId('lb-state')).toHaveTextContent('Not connected');
|
||||
expect(screen.getByPlaceholderText(/paste your token/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'listenbrainz.org/profile' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: /send my plays/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('Save sends the typed token', async () => {
|
||||
const mutate = vi.fn();
|
||||
setup(undefined, mutate);
|
||||
await fireEvent.input(screen.getByPlaceholderText(/paste your token/i), { target: { value: 'mytoken' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
expect(mutate).toHaveBeenCalledWith('mytoken', expect.anything());
|
||||
});
|
||||
|
||||
test('a saved token can be removed', async () => {
|
||||
const mutate = vi.fn();
|
||||
setup({ enabled: false, token_set: true, last_scrobbled_at: null }, mutate);
|
||||
expect(screen.getByTestId('lb-state')).toHaveTextContent('Connected · not sending plays');
|
||||
expect(screen.getByText('Saved')).toBeInTheDocument();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /remove/i }));
|
||||
expect(mutate).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
test('sending plays says when the last one went', () => {
|
||||
setup({ enabled: true, token_set: true, last_scrobbled_at: new Date(Date.now() - 3 * HOUR).toISOString() });
|
||||
expect(screen.getByTestId('lb-state')).toHaveTextContent('Sending plays · last one 3h ago');
|
||||
expect(screen.getByRole('switch', { name: /send my plays/i })).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings › Connections — Subsonic apps', () => {
|
||||
test('Regenerate asks for a second click, then shows the new token once', async () => {
|
||||
setup();
|
||||
vi.mocked(regenerateAPIToken).mockResolvedValue({ api_token: 'new_tok_xyz' });
|
||||
expect(screen.queryByTestId('secret')).toBeNull();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^regenerate$/i }));
|
||||
expect(regenerateAPIToken).not.toHaveBeenCalled();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /click again to confirm/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('secret')).toHaveTextContent('new_tok_xyz'));
|
||||
expect(screen.getByRole('button', { name: 'Copy API token' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('with no Subsonic password, Generate makes one on the first click and shows it once', async () => {
|
||||
vi.mocked(getSubsonicPasswordStatus).mockResolvedValue({ enabled: false });
|
||||
vi.mocked(generateSubsonicPassword).mockResolvedValue({ password: 'gen_pw_123' });
|
||||
setup();
|
||||
await waitFor(() => expect(screen.getByTestId('subsonic-state')).toHaveTextContent('Off'));
|
||||
expect(screen.queryByRole('button', { name: /turn off/i })).toBeNull();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^generate$/i }));
|
||||
await waitFor(() => expect(generateSubsonicPassword).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(screen.getByTestId('secret')).toHaveTextContent('gen_pw_123'));
|
||||
expect(screen.getByTestId('subsonic-state')).toHaveTextContent('On');
|
||||
});
|
||||
|
||||
test('with one set, Regenerate and Turn off each need a second click', async () => {
|
||||
vi.mocked(getSubsonicPasswordStatus).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(clearSubsonicPassword).mockResolvedValue(undefined);
|
||||
setup();
|
||||
const actions = await screen.findByTestId('subsonic-actions');
|
||||
const turnOff = await within(actions).findByRole('button', { name: /turn off/i });
|
||||
const regen = within(actions).getByRole('button', { name: /^regenerate$/i });
|
||||
|
||||
await fireEvent.click(regen);
|
||||
expect(generateSubsonicPassword).not.toHaveBeenCalled();
|
||||
expect(regen).toHaveTextContent(/click again to confirm/i);
|
||||
|
||||
await fireEvent.click(turnOff);
|
||||
expect(clearSubsonicPassword).not.toHaveBeenCalled();
|
||||
await fireEvent.click(turnOff);
|
||||
await waitFor(() => expect(clearSubsonicPassword).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(screen.getByTestId('subsonic-state')).toHaveTextContent('Off'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import NotificationSettings from '#lib/components/NotificationSettings.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings · Notifications')}</title></svelte:head>
|
||||
|
||||
<NotificationSettings />
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { player, setCrossfade } from '#lib/player/store.svelte.js';
|
||||
import NormalizationSetting from '#lib/components/NormalizationSetting.svelte';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings · Playback')}</title></svelte:head>
|
||||
|
||||
<SettingsGroup title="Playback">
|
||||
<SettingRow
|
||||
label="Crossfade"
|
||||
hint="Fades one track into the next. Most albums sound best with it off."
|
||||
for="crossfade-slider"
|
||||
>
|
||||
<input
|
||||
id="crossfade-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value={player.crossfadeSec}
|
||||
oninput={(e) => setCrossfade(Number((e.currentTarget as HTMLInputElement).value))}
|
||||
class="w-40 accent-accent"
|
||||
/>
|
||||
<span class="w-8 text-right text-sm tabular-nums text-text-primary">
|
||||
{player.crossfadeSec === 0 ? 'Off' : `${player.crossfadeSec}s`}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<NormalizationSetting />
|
||||
</SettingsGroup>
|
||||
@@ -1,437 +1,49 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
||||
import { readable, writable } from 'svelte/store';
|
||||
import type { LBStatus } from '#lib/api/listenbrainz.js';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||
|
||||
vi.mock('#lib/api/listenbrainz.js', () => ({
|
||||
createLBStatusQuery: vi.fn(),
|
||||
createTokenMutation: vi.fn(),
|
||||
createEnabledMutation: vi.fn(),
|
||||
setListenBrainzToken: vi.fn(),
|
||||
setListenBrainzEnabled: vi.fn()
|
||||
}));
|
||||
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/settings/account') }));
|
||||
vi.mock('$app/state', () => pageUrlModule(state));
|
||||
|
||||
// The Notifications section has its own tests (NotificationSettings.test.ts);
|
||||
// here it only needs a settled query so the page renders.
|
||||
vi.mock('#lib/api/notifications.js', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('#lib/api/notifications.js')>();
|
||||
const { mockQuery } = await import('../../test-utils/query');
|
||||
return {
|
||||
...mod,
|
||||
createNotificationSettingsQuery: () =>
|
||||
mockQuery({ data: { kinds: [], email_available: true } }),
|
||||
putNotificationSettings: vi.fn()
|
||||
};
|
||||
});
|
||||
import SettingsLayout from './+layout.svelte';
|
||||
import { load } from './+page';
|
||||
|
||||
// The Volume leveling setting loads on mount; keep it off the network.
|
||||
vi.mock('#lib/api/normalization.js', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('#lib/api/normalization.js')>();
|
||||
return {
|
||||
...mod,
|
||||
getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
|
||||
putNormalization: vi.fn()
|
||||
};
|
||||
});
|
||||
const children = createRawSnippet(() => ({ render: () => `<p data-testid="section-body">body</p>` }));
|
||||
|
||||
vi.mock('#lib/api/me.js', () => ({
|
||||
updateProfile: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
// Default to a resolved value so the page's $effect doesn't crash
|
||||
// on `.then()` of undefined when individual tests don't override.
|
||||
regenerateAPIToken: vi.fn(),
|
||||
getSubsonicPasswordStatus: vi.fn(),
|
||||
generateSubsonicPassword: vi.fn(),
|
||||
clearSubsonicPassword: vi.fn()
|
||||
}));
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
// Mutable holder so individual tests can inject populated metrics;
|
||||
// vi.mock is hoisted, hence vi.hoisted for the shared reference.
|
||||
const metricsMock = vi.hoisted(() => ({
|
||||
data: { window_days: 30, baseline: null, groups: [] } as unknown
|
||||
}));
|
||||
function setup(path: string) {
|
||||
state.pageUrl = new URL(`http://localhost${path}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return render(SettingsLayout, { children } as any);
|
||||
}
|
||||
|
||||
vi.mock('#lib/api/metrics.js', () => ({
|
||||
createRecommendationMetricsQuery: () => ({
|
||||
subscribe: (run: (v: unknown) => void) => {
|
||||
run({ isPending: false, isError: false, data: metricsMock.data });
|
||||
return () => {};
|
||||
describe('/settings', () => {
|
||||
test('the sections run down the left, Account first, the current one marked', () => {
|
||||
setup('/settings/connections');
|
||||
const nav = screen.getByRole('navigation', { name: /settings sections/i });
|
||||
const links = Array.from(nav.querySelectorAll('a')).map((a) => [a.getAttribute('href'), a.textContent?.trim()]);
|
||||
expect(links).toEqual([
|
||||
['/settings/account', 'Account'],
|
||||
['/settings/playback', 'Playback'],
|
||||
['/settings/appearance', 'Appearance'],
|
||||
['/settings/notifications', 'Notifications'],
|
||||
['/settings/connections', 'Connections'],
|
||||
['/settings/about', 'About']
|
||||
]);
|
||||
expect(screen.getByRole('link', { name: 'Connections' })).toHaveAttribute('aria-current', 'page');
|
||||
expect(screen.getByRole('link', { name: 'Account' })).not.toHaveAttribute('aria-current');
|
||||
expect(screen.getByTestId('section-body')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a bare /settings lands on Account', () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
load();
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
import SettingsPage from './+page.svelte';
|
||||
import {
|
||||
createLBStatusQuery,
|
||||
createTokenMutation,
|
||||
createEnabledMutation,
|
||||
setListenBrainzToken,
|
||||
setListenBrainzEnabled
|
||||
} from '#lib/api/listenbrainz.js';
|
||||
import {
|
||||
updateProfile,
|
||||
changePassword,
|
||||
regenerateAPIToken,
|
||||
getSubsonicPasswordStatus,
|
||||
generateSubsonicPassword,
|
||||
clearSubsonicPassword
|
||||
} from '#lib/api/me.js';
|
||||
|
||||
function mockStatusStore(data: LBStatus) {
|
||||
return readable({ data, isPending: false, isError: false });
|
||||
}
|
||||
|
||||
function mockMutationStore(mutateFn: (vars: unknown) => void = vi.fn()) {
|
||||
return readable({ isPending: false, mutate: mutateFn, mutateAsync: vi.fn() });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
metricsMock.data = { window_days: 30, baseline: null, groups: [] };
|
||||
});
|
||||
|
||||
describe('Settings page — ListenBrainz', () => {
|
||||
test('token-not-set state renders input + Save button', async () => {
|
||||
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
||||
);
|
||||
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
render(SettingsPage);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByPlaceholderText(/paste your lb token/i)).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('token-set state renders masked + Clear button', async () => {
|
||||
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockStatusStore({ enabled: false, token_set: true, last_scrobbled_at: null })
|
||||
);
|
||||
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
render(SettingsPage);
|
||||
await waitFor(() => expect(screen.getByText(/\(set\)/)).toBeInTheDocument());
|
||||
expect(screen.getByText('clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Save button calls setListenBrainzToken with typed value', async () => {
|
||||
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
||||
);
|
||||
const mutateFn = vi.fn();
|
||||
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore(mutateFn));
|
||||
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
render(SettingsPage);
|
||||
const input = await screen.findByPlaceholderText(/paste your lb token/i);
|
||||
await fireEvent.input(input, { target: { value: 'mytoken' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(mutateFn).toHaveBeenCalledWith('mytoken', expect.anything()));
|
||||
});
|
||||
|
||||
test('Enabled checkbox is disabled when no token', async () => {
|
||||
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
||||
);
|
||||
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
render(SettingsPage);
|
||||
const checkbox = await screen.findByRole('checkbox');
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
|
||||
test('Last-scrobbled-at renders when present', async () => {
|
||||
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockStatusStore({ enabled: true, token_set: true, last_scrobbled_at: '2026-04-28T03:00:00Z' })
|
||||
);
|
||||
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
render(SettingsPage);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/last scrobbled:/i)).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Shared setup for cards that don't need LB state
|
||||
function setupPage() {
|
||||
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
||||
);
|
||||
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
}
|
||||
|
||||
describe('Settings page — Profile card', () => {
|
||||
test('Save profile calls updateProfile with form values', async () => {
|
||||
setupPage();
|
||||
(updateProfile as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
render(SettingsPage);
|
||||
await fireEvent.input(screen.getByLabelText(/display name/i), { target: { value: 'Alice' } });
|
||||
await fireEvent.input(screen.getByLabelText(/email/i), { target: { value: 'alice@example.com' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
||||
await waitFor(() =>
|
||||
expect(updateProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ display_name: 'Alice', email: 'alice@example.com' })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('Save profile shows toast on success', async () => {
|
||||
setupPage();
|
||||
(updateProfile as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
render(SettingsPage);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/profile saved/i));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings page — Password card', () => {
|
||||
test('mismatched passwords show toast and do not call changePassword', async () => {
|
||||
setupPage();
|
||||
render(SettingsPage);
|
||||
await fireEvent.input(screen.getByLabelText(/current password/i), { target: { value: 'oldpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/new password/i), { target: { value: 'newpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/confirm/i), { target: { value: 'different1' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(changePassword).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/do not match/i));
|
||||
});
|
||||
|
||||
test('matched passwords call changePassword(current, new)', async () => {
|
||||
setupPage();
|
||||
(changePassword as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
render(SettingsPage);
|
||||
await fireEvent.input(screen.getByLabelText(/current password/i), { target: { value: 'oldpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/new password/i), { target: { value: 'newpw1234' } });
|
||||
await fireEvent.input(screen.getByLabelText(/confirm/i), { target: { value: 'newpw1234' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change password/i }));
|
||||
await waitFor(() =>
|
||||
expect(changePassword).toHaveBeenCalledWith('oldpw1234', 'newpw1234')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings page — Recommendation metrics card', () => {
|
||||
const metric = (key: string, label: string, over: Record<string, unknown> = {}) => ({
|
||||
key,
|
||||
label,
|
||||
plays: 30,
|
||||
skips: 3,
|
||||
skip_rate: 0.1,
|
||||
avg_completion: 0.9,
|
||||
low_confidence: false,
|
||||
...over
|
||||
});
|
||||
|
||||
test('breakdown rows are collapsed by default and expand on toggle (#1249/#1270)', async () => {
|
||||
setupPage();
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
baseline: metric('manual', 'Manual library plays', { plays: 100 }),
|
||||
groups: [
|
||||
{
|
||||
intent: 'go_to',
|
||||
label: 'Go-to surfaces',
|
||||
surfaces: [
|
||||
metric('for_you', 'For You', {
|
||||
plays: 45,
|
||||
breakdown: [
|
||||
metric('for_you_taste', 'Taste picks'),
|
||||
metric('for_you_fresh', 'Fresh picks', {
|
||||
plays: 10,
|
||||
skip_rate: 0.4,
|
||||
low_confidence: true
|
||||
}),
|
||||
metric('for_you_unattributed', 'Earlier plays', { plays: 5 })
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
render(SettingsPage);
|
||||
const toggle = await screen.findByRole('button', { name: /for you/i });
|
||||
// Collapsed by default: every stamping mix now carries a breakdown,
|
||||
// so always-open sub-rows would swamp the surface-level view.
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText(/Taste picks/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Fresh picks/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Earlier plays/)).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(toggle);
|
||||
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// #2495: the card used to render a delta computed client-side with no notion
|
||||
// of uncertainty, so a -12 on 59 plays looked exactly as solid as a -6 on 400.
|
||||
// Deltas now arrive from the server with a margin, and an indistinguishable
|
||||
// one is marked with "≈" and dimmed rather than coloured.
|
||||
test('a delta smaller than its margin is marked as indistinguishable', async () => {
|
||||
setupPage();
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
baseline: metric('manual', 'Manual library plays', { plays: 400, skip_rate: 0.27 }),
|
||||
groups: [
|
||||
{
|
||||
intent: 'discovery',
|
||||
label: 'Discovery mixes',
|
||||
surfaces: [
|
||||
metric('discover', 'Discover', {
|
||||
plays: 59,
|
||||
skip_rate: 0.153,
|
||||
// 13.3pp gap, but the margin at n=59 is wider than the gap.
|
||||
skip_delta: { delta_pp: -13.3, margin_pp: 14.5, distinguishable: false },
|
||||
completion_delta: { delta_pp: 28.0, margin_pp: 12.1, distinguishable: true }
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
render(SettingsPage);
|
||||
await waitFor(() => expect(screen.getByText('Discover')).toBeInTheDocument());
|
||||
|
||||
// Targeted by test id rather than text: the legend below the table also
|
||||
// contains a "≈", so matching on the glyph finds the explanation instead of
|
||||
// the delta. (It did, on the first attempt at this test.)
|
||||
const skip = screen.getByTestId('skip-delta-discover');
|
||||
expect(skip).toHaveTextContent('≈-13');
|
||||
expect(skip).toHaveAttribute('title', expect.stringContaining('not distinguishable from zero'));
|
||||
// It must NOT be coloured as a real regression/improvement.
|
||||
expect(skip.className).toContain('opacity-60');
|
||||
|
||||
// The completion delta clears its margin, so it renders plainly.
|
||||
const completion = screen.getByTestId('completion-delta-discover');
|
||||
expect(completion).toHaveTextContent('+28');
|
||||
expect(completion.className).not.toContain('opacity-60');
|
||||
|
||||
// And the legend explains the glyph rather than leaving it a mystery.
|
||||
expect(screen.getByText(/smaller than its own margin of error/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A delta is omitted entirely when the samples are too thin for a margin to
|
||||
// mean anything — the server decides that, and the cell must simply show the
|
||||
// rate rather than a bare "0".
|
||||
test('a surface with no delta shows its rate and nothing else', async () => {
|
||||
setupPage();
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
baseline: metric('manual', 'Manual library plays', { plays: 400 }),
|
||||
groups: [
|
||||
{
|
||||
intent: 'go_to',
|
||||
label: 'Go-to surfaces',
|
||||
surfaces: [metric('radio', 'Radio', { plays: 1, skip_rate: 0 })]
|
||||
}
|
||||
]
|
||||
};
|
||||
render(SettingsPage);
|
||||
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
||||
expect(screen.queryByTestId('skip-delta-radio')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('completion-delta-radio')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('surfaces without a breakdown render no toggle and no sub-rows', async () => {
|
||||
setupPage();
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
baseline: null,
|
||||
groups: [
|
||||
{
|
||||
intent: 'go_to',
|
||||
label: 'Go-to surfaces',
|
||||
surfaces: [metric('radio', 'Radio')]
|
||||
}
|
||||
]
|
||||
};
|
||||
render(SettingsPage);
|
||||
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('button', { name: /radio/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings page — API Token card', () => {
|
||||
test('first click on Regenerate shows "Click again to confirm"', async () => {
|
||||
setupPage();
|
||||
render(SettingsPage);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /regenerate/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /click again to confirm/i })).toBeInTheDocument()
|
||||
);
|
||||
expect(regenerateAPIToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('second click within 5s calls regenerateAPIToken', async () => {
|
||||
setupPage();
|
||||
(regenerateAPIToken as ReturnType<typeof vi.fn>).mockResolvedValue({ api_token: 'new_tok_xyz' });
|
||||
render(SettingsPage);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /regenerate/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /click again to confirm/i })).toBeInTheDocument()
|
||||
);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /click again to confirm/i }));
|
||||
await waitFor(() => expect(regenerateAPIToken).toHaveBeenCalled());
|
||||
// The new key is shown once, with a way to copy it.
|
||||
expect(await screen.findByText('new_tok_xyz')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^copy$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('no token is shown or fetched before Regenerate', async () => {
|
||||
setupPage();
|
||||
render(SettingsPage);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /regenerate/i })).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /^copy$/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings page — Subsonic password card', () => {
|
||||
function mockLB() {
|
||||
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
||||
);
|
||||
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||
}
|
||||
|
||||
test('with none set, Generate creates one on the first click and shows it once', async () => {
|
||||
mockLB();
|
||||
(getSubsonicPasswordStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ enabled: false });
|
||||
(generateSubsonicPassword as ReturnType<typeof vi.fn>).mockResolvedValue({ password: 'gen_pw_123' });
|
||||
render(SettingsPage);
|
||||
await waitFor(() => expect(screen.getByText(/no subsonic password is set/i)).toBeInTheDocument());
|
||||
expect(screen.queryByRole('button', { name: /turn off/i })).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^generate$/i }));
|
||||
await waitFor(() => expect(generateSubsonicPassword).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(screen.getByText('gen_pw_123')).toBeInTheDocument());
|
||||
expect(screen.getByText(/a subsonic password is set/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('with one set, Regenerate and Turn off each need a second click', async () => {
|
||||
mockLB();
|
||||
(getSubsonicPasswordStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ enabled: true });
|
||||
(clearSubsonicPassword as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
render(SettingsPage);
|
||||
// The API token card has a Regenerate button too; the Subsonic card's is
|
||||
// the one beside Turn off.
|
||||
const turnOff = await screen.findByRole('button', { name: /turn off/i });
|
||||
const subsonicRegen = within(turnOff.parentElement!).getByRole('button', { name: /^regenerate$/i });
|
||||
|
||||
await fireEvent.click(subsonicRegen);
|
||||
expect(generateSubsonicPassword).not.toHaveBeenCalled();
|
||||
expect(subsonicRegen).toHaveTextContent(/click again to confirm/i);
|
||||
|
||||
await fireEvent.click(turnOff);
|
||||
expect(clearSubsonicPassword).not.toHaveBeenCalled();
|
||||
await fireEvent.click(turnOff);
|
||||
await waitFor(() => expect(clearSubsonicPassword).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(screen.getByText(/no subsonic password is set/i)).toBeInTheDocument());
|
||||
expect(thrown).toMatchObject({ status: 308, location: '/settings/account' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user