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