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

- /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:
2026-10-10 23:34:51 -04:00
co-authored by Claude Opus 5.5
parent c0c0f8a71f
commit c94c09aeae
32 changed files with 1552 additions and 1408 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ type digestVars struct {
// renderDigest renders one group's email. base is the public address; with
// none set, the email still goes out, without links.
func renderDigest(group EmailGroup, name string, items []pendingItem, base string) (digestEmail, error) {
vars := digestVars{Name: name, SettingsURL: joinURL(base, "/settings#notifications")}
vars := digestVars{Name: name, SettingsURL: joinURL(base, "/settings/notifications")}
var subject string
if group == EmailSummary {
vars.Artists = summaryArtists(items, base)
+1 -1
View File
@@ -154,7 +154,7 @@ func TestRenderDigest_SummaryGroupsByArtist(t *testing.T) {
require.Contains(t, e.Text, " - Pure\n https://music.example/albums/al-3")
require.Contains(t, e.Text, "Boards of Canada\n https://music.example/artists/ar-2")
require.Contains(t, e.Text, "Tycho\n - Awake")
require.Contains(t, e.Text, "https://music.example/settings#notifications")
require.Contains(t, e.Text, "https://music.example/settings/notifications")
require.Contains(t, e.HTML, `<a href="https://music.example/albums/al-1"`)
}
+6
View File
@@ -42,6 +42,12 @@ export async function changePassword(currentPassword: string, newPassword: strin
});
}
// The signed-in user's profile, to fill the Account form with what is
// stored (#5596): a form that starts blank sends blanks, and a blank clears.
export async function getMyProfile(): Promise<MyProfile> {
return api.get<MyProfile>('/api/me');
}
export async function updateProfile(input: { display_name?: string; email?: string }): Promise<MyProfile> {
return api.put<MyProfile>('/api/me/profile', input);
}
+8 -6
View File
@@ -119,12 +119,14 @@
}
</script>
<section class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Active sessions</h2>
<p class="text-sm text-text-secondary">
Every device signed in to your account. If you see one you don't recognise — especially
one marked as having moved — sign it out and change your password.
</p>
<!-- Settings › Account's last group (#5595), styled as SettingsGroup is. -->
<section class="space-y-3 rounded-lg border border-border bg-surface px-4 pt-3 pb-4">
<header>
<h2 class="text-base font-medium text-text-primary">Signed-in devices</h2>
<p class="text-xs text-text-secondary">
One you don't recognise? Sign it out and change your password.
</p>
</header>
{#if loadError}
<p class="text-sm text-action-destructive">
+23 -26
View File
@@ -1,19 +1,20 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Smartphone } from 'lucide-svelte';
import { Download, Smartphone } from 'lucide-svelte';
import { api } from '#lib/api/client.js';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// Polls /api/client/version once on mount. Renders nothing on 404
// (no bundled APK — graceful degradation in dev / pre-CI images)
// or 401 (defensive — caller must be logged in).
// The Android app this server bundles, at the top of Settings › Account
// (#5595). Polls /api/client/version once on mount. Renders nothing on
// 404 (no bundled APK: dev and pre-CI images) or 401.
//
// The /api/client/* endpoints are auth-gated to prevent anonymous
// bandwidth abuse on the APK stream, so this component only shows
// for logged-in users. Mount in Settings, never in pre-auth surfaces.
// bandwidth abuse on the APK stream, so this only shows to a signed-in
// user. Mount in Settings, never on a pre-auth page.
// Server returns snake_case (clientVersionResponse in
// internal/api/client_assets.go). Map to a camelCase struct for
// the rest of the component.
// internal/api/client_assets.go).
type WireInfo = { version: string; apk_url: string; size_bytes: number };
type Info = { version: string; apkUrl: string; sizeBytes: number };
@@ -40,23 +41,19 @@
</script>
{#if info}
<section class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Mobile app</h2>
<p class="text-sm text-text-secondary">
Install the Android app paired to this server. Sign in with the same account.
</p>
<a
href={info.apkUrl}
download="minstrel.apk"
class="mobile-app-download flex items-center gap-3 rounded-md border border-border bg-surface px-3 py-2 text-sm text-text-primary no-underline transition-colors hover:bg-surface-hover hover:text-accent-fg focus-visible:ring-2 focus-visible:ring-accent"
<SettingsGroup title="Android app">
<SettingRow
label="Minstrel for Android"
hint="{info.version}{info.sizeBytes > 0 ? ` · ${formatSize(info.sizeBytes)}` : ''} · sign in with this account"
>
<Smartphone size={18} strokeWidth={1.5} class="shrink-0 text-accent-fg" />
<span class="flex flex-col leading-tight">
<span class="font-medium">Get the Android app</span>
<span class="text-xs text-text-secondary">
{info.version}{info.sizeBytes > 0 ? ` · ${formatSize(info.sizeBytes)}` : ''}
</span>
</span>
</a>
</section>
<Smartphone size={18} strokeWidth={1.5} class="text-text-secondary" aria-hidden="true" />
<a
href={info.apkUrl}
download="minstrel.apk"
class="inline-flex items-center gap-1.5 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg hover:opacity-90 focus-visible:ring-2 focus-visible:ring-accent"
>
<Download size={16} strokeWidth={1.75} aria-hidden="true" /> Download
</a>
</SettingRow>
</SettingsGroup>
{/if}
@@ -11,6 +11,8 @@
setNormalization
} from '#lib/stores/normalization.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
import Segmented from '#lib/components/settings/Segmented.svelte';
// Volume leveling (M464 #4998). The choice is the user's, stored on the
// server, so it follows them to the Android app and to casts.
@@ -39,68 +41,42 @@
}
}
const pill = (on: boolean) =>
`flex cursor-pointer items-center rounded border border-border px-3 py-1.5 text-sm ${
on ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'
}`;
const modes = NORMALIZATION_MODES.map((m) => ({ value: m, label: modeLabels[m] }));
const targets = NORMALIZATION_TARGETS.map((t) => ({ value: t, label: `${t} LUFS` }));
</script>
<div class="space-y-3">
<fieldset class="space-y-2">
<legend class="block text-sm text-text-secondary">Volume leveling</legend>
<div class="flex flex-wrap gap-2">
{#each NORMALIZATION_MODES as m (m)}
<label class={pill(normalization.value.mode === m)}>
<input
type="radio"
name="normalization-mode"
value={m}
checked={normalization.value.mode === m}
onchange={() => change({ mode: m })}
class="sr-only"
/>
{modeLabels[m]}
</label>
{/each}
</div>
<p class="text-xs text-text-secondary">{modeHints[normalization.value.mode]}</p>
</fieldset>
<!-- Rows of the Playback group (#5595); the caller wraps them in it. -->
<SettingRow label="Volume leveling" hint={modeHints[normalization.value.mode]}>
<Segmented
name="normalization-mode"
label="Volume leveling"
options={modes}
value={normalization.value.mode}
onchange={(m) => change({ mode: m })}
/>
</SettingRow>
{#if normalization.value.mode !== 'off'}
<fieldset class="space-y-2">
<legend class="block text-sm text-text-secondary">Target loudness</legend>
<div class="flex flex-wrap gap-2">
{#each NORMALIZATION_TARGETS as t (t)}
<label class={pill(normalization.value.target_lufs === t)}>
<input
type="radio"
name="normalization-target"
value={t}
checked={normalization.value.target_lufs === t}
onchange={() => change({ target_lufs: t })}
class="sr-only"
/>
<span class="tabular-nums">{t} LUFS</span>
</label>
{/each}
</div>
<p class="text-xs text-text-secondary">Higher is louder; −18 leaves the most headroom.</p>
</fieldset>
{#if normalization.value.mode !== 'off'}
<SettingRow label="Target loudness" hint="Higher is louder; −18 leaves the most headroom.">
<Segmented
name="normalization-target"
label="Target loudness"
options={targets}
value={normalization.value.target_lufs}
onchange={(t) => change({ target_lufs: t })}
/>
</SettingRow>
<label class="flex cursor-pointer items-center justify-between gap-3">
<span>
<span class="block text-sm text-text-primary">Boost quiet tracks fully</span>
<span class="block text-xs text-text-secondary">A limiter catches the peaks.</span>
</span>
<input
type="checkbox"
role="switch"
aria-checked={normalization.value.boost === 'limiter'}
checked={normalization.value.boost === 'limiter'}
onchange={(e) =>
change({ boost: (e.currentTarget as HTMLInputElement).checked ? 'limiter' : 'headroom' })}
class="h-4 w-4 accent-accent"
/>
</label>
{/if}
</div>
<SettingRow label="Boost quiet tracks fully" hint="A limiter catches the peaks." for="normalization-boost">
<input
id="normalization-boost"
type="checkbox"
role="switch"
aria-checked={normalization.value.boost === 'limiter'}
checked={normalization.value.boost === 'limiter'}
onchange={(e) =>
change({ boost: (e.currentTarget as HTMLInputElement).checked ? 'limiter' : 'headroom' })}
class="h-4 w-4 cursor-pointer accent-accent"
/>
</SettingRow>
{/if}
@@ -80,53 +80,62 @@
}
</script>
{#snippet rows(list: NotificationKindSetting[])}
<div class="grid grid-cols-[1fr_repeat(3,3.5rem)] items-center gap-y-1 text-sm">
<span></span>
{#each channels as c (c.key)}
<span class="text-center text-xs text-text-secondary">{c.label}</span>
{/each}
{#each list as row (row.kind)}
<span class="py-1 text-text-primary">{label(row.kind)}</span>
{#each channels as c (c.key)}
<span class="flex justify-center">
<input
type="checkbox"
class="h-4 w-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{label(row.kind)}: {c.label}"
checked={row[c.key] && !disabled(row, c.key)}
disabled={disabled(row, c.key)}
onchange={() => toggle(row, c.key)}
/>
</span>
{/each}
{/each}
</div>
{#snippet row(r: NotificationKindSetting)}
<span class="py-1.5 text-text-primary">{label(r.kind)}</span>
{#each channels as c (c.key)}
<span class="flex justify-center">
<input
type="checkbox"
class="h-4 w-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{label(r.kind)}: {c.label}"
checked={r[c.key] && !disabled(r, c.key)}
disabled={disabled(r, c.key)}
onchange={() => toggle(r, c.key)}
/>
</span>
{/each}
{/snippet}
<section id="notifications" class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Notifications</h2>
<!-- Settings › Notifications (#5595): one table, its column labels once
(#189), the admins' library-health kinds under their own heading in
the same columns. -->
<section id="notifications" class="rounded-lg border border-border bg-surface">
<header class="px-4 pt-3 pb-2">
<h2 class="text-base font-medium text-text-primary">Notifications</h2>
<p class="text-xs text-text-secondary">What reaches you, and where. Phone and email follow the inbox.</p>
</header>
{#if !settings}
<p class="text-sm text-text-secondary">Loading…</p>
{:else}
{@render rows(forEveryone)}
{#if forAdmins.length > 0}
<h3 class="pt-2 text-sm font-medium text-text-secondary">Library health</h3>
{@render rows(forAdmins)}
{/if}
{#if !settings.email_available}
<p class="text-sm text-text-secondary" data-testid="email-unavailable">
{#if settings.email_unavailable_reason === 'no_address'}
Email is off: <a href="#profile" class="underline hover:text-text-primary">add an email address in your profile</a>.
{:else if user.value?.is_admin}
Email is off: <a href="/admin/integrations" class="underline hover:text-text-primary">SMTP isn't set up</a>.
{:else}
Email is off: this server doesn't send email.
<div class="border-t border-border px-4 py-3">
{#if !settings}
<p class="text-sm text-text-secondary">Loading…</p>
{:else}
<div class="grid grid-cols-[1fr_repeat(3,3.5rem)] items-center gap-y-1 text-sm">
<span></span>
{#each channels as c (c.key)}
<span class="text-center text-xs text-text-secondary">{c.label}</span>
{/each}
{#each forEveryone as r (r.kind)}
{@render row(r)}
{/each}
{#if forAdmins.length > 0}
<h3 class="col-span-4 pt-3 text-xs font-medium uppercase tracking-wide text-text-secondary">Library health</h3>
{#each forAdmins as r (r.kind)}
{@render row(r)}
{/each}
{/if}
</p>
</div>
{#if !settings.email_available}
<p class="mt-3 text-xs text-text-secondary" data-testid="email-unavailable">
{#if settings.email_unavailable_reason === 'no_address'}
Email is off: <a href="/settings/account#profile" class="underline hover:text-text-primary">add an email address in your profile</a>.
{:else if user.value?.is_admin}
Email is off: <a href="/admin/integrations" class="underline hover:text-text-primary">SMTP isn't set up</a>.
{:else}
Email is off: this server doesn't send email.
{/if}
</p>
{/if}
{/if}
{/if}
</div>
</section>
@@ -108,7 +108,7 @@ describe('NotificationSettings', () => {
expect(screen.getByRole('checkbox', { name: 'Request approved: Email' })).toBeDisabled();
expect(screen.getByRole('link', { name: 'add an email address in your profile' })).toHaveAttribute(
'href',
'#profile'
'/settings/account#profile'
);
});
@@ -0,0 +1,213 @@
<script lang="ts">
import type { CreateQueryResult } from '@tanstack/svelte-query';
import {
createRecommendationMetricsQuery,
type RecommendationMetrics,
type SurfaceIntent,
type MetricDelta
} from '#lib/api/metrics.js';
// How plays launched from each recommendation surface land against the
// music you pick yourself. Library › Your listening (#5597): a report on
// your own listening, so it moved out of Settings, which holds choices.
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;
}
</script>
<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>
@@ -0,0 +1,164 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
// Moved with the card from Settings to Library › Your listening (#5597).
// 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
}));
vi.mock('#lib/api/metrics.js', () => ({
createRecommendationMetricsQuery: () => ({
subscribe: (run: (v: unknown) => void) => {
run({ isPending: false, isError: false, data: metricsMock.data });
return () => {};
}
})
}));
import RecommendationMetrics from './RecommendationMetrics.svelte';
afterEach(() => {
metricsMock.data = { window_days: 30, baseline: null, groups: [] };
});
describe('RecommendationMetrics (Library › Your listening)', () => {
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 () => {
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(RecommendationMetrics);
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 () => {
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(RecommendationMetrics);
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 () => {
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(RecommendationMetrics);
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 () => {
metricsMock.data = {
window_days: 30,
baseline: null,
groups: [
{
intent: 'go_to',
label: 'Go-to surfaces',
surfaces: [metric('radio', 'Radio')]
}
]
};
render(RecommendationMetrics);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /radio/i })).not.toBeInTheDocument();
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
});
});
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts" module>
export type RailItem = {
href: string;
label: string;
count?: number;
attention?: number;
// Further paths that belong to this item, e.g. an artist's page under
// the Catalogue. The item is also current on any path below its href.
also?: string[];
// The item is current only on its exact href (a section's landing page,
// whose href prefixes every other item's).
exact?: boolean;
};
</script>
<script lang="ts">
import { page } from '$app/state';
// A section's pages, down the left (#5572, #5595): Collection and Settings
// both use it, so a section's navigation sits in the same place and looks
// the same everywhere (#189, "stable chrome"). On a narrow screen the rail
// runs across the top. An item can carry a count and, accented, how many
// of those need a person.
let { label, items }: { label: string; items: RailItem[] } = $props();
function isActive(item: RailItem): boolean {
const path = page.url.pathname;
if (item.also?.some((p) => path.startsWith(p))) return true;
return item.exact ? path === item.href : path === item.href || path.startsWith(item.href + '/');
}
</script>
<nav aria-label={label} class="border-b border-border md:border-b-0 md:border-r md:pr-3">
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
{#each items as item (item.href)}
{@const active = isActive(item)}
<li>
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors md:rounded-md md:border-b-0 md:border-l-2 {active
? 'border-accent text-text-primary md:bg-surface-hover'
: 'border-transparent text-text-secondary hover:text-text-primary md:hover:bg-surface-hover'}"
>
<span class="md:flex-1">{item.label}</span>
{#if item.attention}
<span
class="rounded-full bg-accent px-1.5 text-xs text-text-on-accent"
aria-label="{item.attention} need you"
data-testid="tab-attention">{item.attention}</span
>
{/if}
{#if item.count !== undefined}
<span class="text-xs tabular-nums text-text-muted">{item.count}</span>
{/if}
</a>
</li>
{/each}
</ul>
</nav>
+6 -3
View File
@@ -42,8 +42,11 @@
});
</script>
<!-- The value of Settings › About's "Server version" row (#5595). -->
{#if version}
<p class="text-xs text-text-secondary">
Server {version}{#if channel}&nbsp;·&nbsp;{channel}{/if}
</p>
<span class="text-sm tabular-nums text-text-secondary" data-testid="server-version">
{version}{#if channel}&nbsp;·&nbsp;{channel}{/if}
</span>
{:else}
<span class="text-sm text-text-muted">—</span>
{/if}
@@ -0,0 +1,39 @@
<script lang="ts" generics="V extends string | number">
// A one-of-N choice as a row of joined buttons (#5595): Theme, Volume
// leveling, Target loudness. Each option is a real radio, so it is
// reachable by keyboard and named by its own text.
let {
name,
label,
options,
value,
onchange
}: {
name: string;
label: string;
options: { value: V; label: string }[];
value: V;
onchange: (v: V) => void;
} = $props();
</script>
<div role="radiogroup" aria-label={label} class="inline-flex overflow-hidden rounded-md border border-border">
{#each options as o, i (o.value)}
<label
class="flex cursor-pointer items-center px-3 py-1.5 text-sm transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent has-[:focus-visible]:ring-inset
{i > 0 ? 'border-l border-border' : ''}
{value === o.value ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'}"
>
<input
type="radio"
{name}
value={o.value}
checked={value === o.value}
onchange={() => onchange(o.value)}
class="sr-only"
/>
<span class="whitespace-nowrap tabular-nums">{o.label}</span>
</label>
{/each}
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// One setting (#188, #5595): its name and at most one short line on the
// left, its control at the end. `for` names the control's id so the name
// labels it. `hint` is a line of text, or a snippet when it needs a link.
// A control too wide to sit beside the name (a form field on a phone)
// wraps beneath it.
let {
label,
hint,
for: forId,
children
}: { label: string; hint?: string | Snippet; for?: string; children?: Snippet } = $props();
</script>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3">
<div class="min-w-0 flex-1">
{#if forId}
<label for={forId} class="block text-sm text-text-primary">{label}</label>
{:else}
<span class="block text-sm text-text-primary">{label}</span>
{/if}
{#if typeof hint === 'function'}
<p class="text-xs text-text-secondary">{@render hint()}</p>
{:else if hint}
<p class="text-xs text-text-secondary">{hint}</p>
{/if}
</div>
{#if children}
<div class="flex shrink-0 items-center gap-2">
{@render children()}
</div>
{/if}
</div>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// A group of settings on a Settings page (#5595): a titled card whose
// rows are SettingRows, ruled apart. A settings page reads as a menu
// (#188), so a group carries a title and, at most, one short line.
let {
title,
hint,
id,
children
}: { title: string; hint?: string; id?: string; children: Snippet } = $props();
</script>
<section {id} class="rounded-lg border border-border bg-surface">
<header class="px-4 pt-3 pb-2">
<h2 class="text-base font-medium text-text-primary">{title}</h2>
{#if hint}
<p class="text-xs text-text-secondary">{hint}</p>
{/if}
</header>
<div class="divide-y divide-border border-t border-border">
{@render children()}
</div>
</section>
@@ -1,20 +1,18 @@
<script lang="ts">
import { page } from '$app/state';
import SectionRail from '#lib/components/SectionRail.svelte';
import type { WantCounts } from '#lib/api/types.js';
// The Collection's sections, down the left like Lidarr's (#5572): the
// catalogue of artists first, then the sections that show state. On a
// narrow screen the rail runs across the top. Activity carries a second,
// accented count when downloads are waiting on a person. Unmatched, like
// Lidarr's Unmapped Files, holds the library albums no MusicBrainz album
// matched (#5579).
// catalogue of artists first, then the sections that show state. Activity
// carries a second, accented count when downloads are waiting on a person.
// Unmatched, like Lidarr's Unmapped Files, holds the library albums no
// MusicBrainz album matched (#5579).
let { counts }: { counts?: WantCounts } = $props();
type Item = { href: string; label: string; count?: number; attention?: number };
const items = $derived<Item[]>([
{ href: '/collection', label: 'Catalogue', count: counts?.artists },
// An artist's page belongs to the catalogue.
const items = $derived([
{ href: '/collection', label: 'Catalogue', count: counts?.artists, exact: true, also: ['/collection/artist/'] },
{ href: '/collection/wanted', label: 'Wanted', count: counts?.wanted },
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
@@ -22,40 +20,6 @@
{ href: '/collection/blocked', label: 'Blocked', count: counts?.blocked },
{ href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched }
]);
// An artist's page belongs to the catalogue.
function isActive(href: string): boolean {
const path = page.url.pathname;
if (href === '/collection') return path === '/collection' || path.startsWith('/collection/artist/');
return path.startsWith(href);
}
</script>
<nav aria-label="Collection sections" class="border-b border-border md:border-b-0 md:border-r md:pr-3">
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
{#each items as item (item.href)}
{@const active = isActive(item.href)}
<li>
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors md:rounded-md md:border-b-0 md:border-l-2 {active
? 'border-accent text-text-primary md:bg-surface-hover'
: 'border-transparent text-text-secondary hover:text-text-primary md:hover:bg-surface-hover'}"
>
<span class="md:flex-1">{item.label}</span>
{#if item.attention}
<span
class="rounded-full bg-accent px-1.5 text-xs text-text-on-accent"
aria-label="{item.attention} need you"
data-testid="tab-attention">{item.attention}</span
>
{/if}
{#if item.count !== undefined}
<span class="text-xs tabular-nums text-text-muted">{item.count}</span>
{/if}
</a>
</li>
{/each}
</ul>
</nav>
<SectionRail label="Collection sections" {items} />
+5 -1
View File
@@ -19,7 +19,11 @@
{ href: '/library/years', label: 'Years' },
{ href: '/library/liked', label: 'Liked' },
{ href: '/library/history', label: 'History' },
{ href: '/library/playlists', label: 'Playlists' }
{ href: '/library/playlists', label: 'Playlists' },
// Moved out of Settings (#5597): a report on your listening, and the
// tracks you've hidden, belong with the rest of your library.
{ href: '/library/listening', label: 'Your listening' },
{ href: '/library/hidden', label: 'Hidden' }
];
function isActiveTab(href: string): boolean {
@@ -0,0 +1,10 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import RecommendationMetrics from '#lib/components/RecommendationMetrics.svelte';
</script>
<svelte:head><title>{pageTitle('Your listening')}</title></svelte:head>
<div class="max-w-4xl">
<RecommendationMetrics />
</div>
+32
View File
@@ -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>
-701
View File
@@ -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>
+7
View File
@@ -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>
+39 -427
View File
@@ -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' });
});
});