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
|
// renderDigest renders one group's email. base is the public address; with
|
||||||
// none set, the email still goes out, without links.
|
// none set, the email still goes out, without links.
|
||||||
func renderDigest(group EmailGroup, name string, items []pendingItem, base string) (digestEmail, error) {
|
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
|
var subject string
|
||||||
if group == EmailSummary {
|
if group == EmailSummary {
|
||||||
vars.Artists = summaryArtists(items, base)
|
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, " - 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, "Boards of Canada\n https://music.example/artists/ar-2")
|
||||||
require.Contains(t, e.Text, "Tycho\n - Awake")
|
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"`)
|
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> {
|
export async function updateProfile(input: { display_name?: string; email?: string }): Promise<MyProfile> {
|
||||||
return api.put<MyProfile>('/api/me/profile', input);
|
return api.put<MyProfile>('/api/me/profile', input);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,12 +119,14 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
<!-- Settings › Account's last group (#5595), styled as SettingsGroup is. -->
|
||||||
<h2 class="text-lg font-semibold">Active sessions</h2>
|
<section class="space-y-3 rounded-lg border border-border bg-surface px-4 pt-3 pb-4">
|
||||||
<p class="text-sm text-text-secondary">
|
<header>
|
||||||
Every device signed in to your account. If you see one you don't recognise — especially
|
<h2 class="text-base font-medium text-text-primary">Signed-in devices</h2>
|
||||||
one marked as having moved — sign it out and change your password.
|
<p class="text-xs text-text-secondary">
|
||||||
</p>
|
One you don't recognise? Sign it out and change your password.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
{#if loadError}
|
{#if loadError}
|
||||||
<p class="text-sm text-action-destructive">
|
<p class="text-sm text-action-destructive">
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Smartphone } from 'lucide-svelte';
|
import { Download, Smartphone } from 'lucide-svelte';
|
||||||
import { api } from '#lib/api/client.js';
|
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
|
// The Android app this server bundles, at the top of Settings › Account
|
||||||
// (no bundled APK — graceful degradation in dev / pre-CI images)
|
// (#5595). Polls /api/client/version once on mount. Renders nothing on
|
||||||
// or 401 (defensive — caller must be logged in).
|
// 404 (no bundled APK: dev and pre-CI images) or 401.
|
||||||
//
|
//
|
||||||
// The /api/client/* endpoints are auth-gated to prevent anonymous
|
// The /api/client/* endpoints are auth-gated to prevent anonymous
|
||||||
// bandwidth abuse on the APK stream, so this component only shows
|
// bandwidth abuse on the APK stream, so this only shows to a signed-in
|
||||||
// for logged-in users. Mount in Settings, never in pre-auth surfaces.
|
// user. Mount in Settings, never on a pre-auth page.
|
||||||
|
|
||||||
// Server returns snake_case (clientVersionResponse in
|
// Server returns snake_case (clientVersionResponse in
|
||||||
// internal/api/client_assets.go). Map to a camelCase struct for
|
// internal/api/client_assets.go).
|
||||||
// the rest of the component.
|
|
||||||
type WireInfo = { version: string; apk_url: string; size_bytes: number };
|
type WireInfo = { version: string; apk_url: string; size_bytes: number };
|
||||||
type Info = { version: string; apkUrl: string; sizeBytes: number };
|
type Info = { version: string; apkUrl: string; sizeBytes: number };
|
||||||
|
|
||||||
@@ -40,23 +41,19 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if info}
|
{#if info}
|
||||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
<SettingsGroup title="Android app">
|
||||||
<h2 class="text-lg font-semibold">Mobile app</h2>
|
<SettingRow
|
||||||
<p class="text-sm text-text-secondary">
|
label="Minstrel for Android"
|
||||||
Install the Android app paired to this server. Sign in with the same account.
|
hint="{info.version}{info.sizeBytes > 0 ? ` · ${formatSize(info.sizeBytes)}` : ''} · sign in with this 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"
|
|
||||||
>
|
>
|
||||||
<Smartphone size={18} strokeWidth={1.5} class="shrink-0 text-accent-fg" />
|
<Smartphone size={18} strokeWidth={1.5} class="text-text-secondary" aria-hidden="true" />
|
||||||
<span class="flex flex-col leading-tight">
|
<a
|
||||||
<span class="font-medium">Get the Android app</span>
|
href={info.apkUrl}
|
||||||
<span class="text-xs text-text-secondary">
|
download="minstrel.apk"
|
||||||
{info.version}{info.sizeBytes > 0 ? ` · ${formatSize(info.sizeBytes)}` : ''}
|
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"
|
||||||
</span>
|
>
|
||||||
</span>
|
<Download size={16} strokeWidth={1.75} aria-hidden="true" /> Download
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</SettingRow>
|
||||||
|
</SettingsGroup>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
setNormalization
|
setNormalization
|
||||||
} from '#lib/stores/normalization.svelte.js';
|
} from '#lib/stores/normalization.svelte.js';
|
||||||
import { pushToast } from '#lib/stores/toast.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
|
// 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.
|
// server, so it follows them to the Android app and to casts.
|
||||||
@@ -39,68 +41,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pill = (on: boolean) =>
|
const modes = NORMALIZATION_MODES.map((m) => ({ value: m, label: modeLabels[m] }));
|
||||||
`flex cursor-pointer items-center rounded border border-border px-3 py-1.5 text-sm ${
|
const targets = NORMALIZATION_TARGETS.map((t) => ({ value: t, label: `${t} LUFS` }));
|
||||||
on ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'
|
|
||||||
}`;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<!-- Rows of the Playback group (#5595); the caller wraps them in it. -->
|
||||||
<fieldset class="space-y-2">
|
<SettingRow label="Volume leveling" hint={modeHints[normalization.value.mode]}>
|
||||||
<legend class="block text-sm text-text-secondary">Volume leveling</legend>
|
<Segmented
|
||||||
<div class="flex flex-wrap gap-2">
|
name="normalization-mode"
|
||||||
{#each NORMALIZATION_MODES as m (m)}
|
label="Volume leveling"
|
||||||
<label class={pill(normalization.value.mode === m)}>
|
options={modes}
|
||||||
<input
|
value={normalization.value.mode}
|
||||||
type="radio"
|
onchange={(m) => change({ mode: m })}
|
||||||
name="normalization-mode"
|
/>
|
||||||
value={m}
|
</SettingRow>
|
||||||
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>
|
|
||||||
|
|
||||||
{#if normalization.value.mode !== 'off'}
|
{#if normalization.value.mode !== 'off'}
|
||||||
<fieldset class="space-y-2">
|
<SettingRow label="Target loudness" hint="Higher is louder; −18 leaves the most headroom.">
|
||||||
<legend class="block text-sm text-text-secondary">Target loudness</legend>
|
<Segmented
|
||||||
<div class="flex flex-wrap gap-2">
|
name="normalization-target"
|
||||||
{#each NORMALIZATION_TARGETS as t (t)}
|
label="Target loudness"
|
||||||
<label class={pill(normalization.value.target_lufs === t)}>
|
options={targets}
|
||||||
<input
|
value={normalization.value.target_lufs}
|
||||||
type="radio"
|
onchange={(t) => change({ target_lufs: t })}
|
||||||
name="normalization-target"
|
/>
|
||||||
value={t}
|
</SettingRow>
|
||||||
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>
|
|
||||||
|
|
||||||
<label class="flex cursor-pointer items-center justify-between gap-3">
|
<SettingRow label="Boost quiet tracks fully" hint="A limiter catches the peaks." for="normalization-boost">
|
||||||
<span>
|
<input
|
||||||
<span class="block text-sm text-text-primary">Boost quiet tracks fully</span>
|
id="normalization-boost"
|
||||||
<span class="block text-xs text-text-secondary">A limiter catches the peaks.</span>
|
type="checkbox"
|
||||||
</span>
|
role="switch"
|
||||||
<input
|
aria-checked={normalization.value.boost === 'limiter'}
|
||||||
type="checkbox"
|
checked={normalization.value.boost === 'limiter'}
|
||||||
role="switch"
|
onchange={(e) =>
|
||||||
aria-checked={normalization.value.boost === 'limiter'}
|
change({ boost: (e.currentTarget as HTMLInputElement).checked ? 'limiter' : 'headroom' })}
|
||||||
checked={normalization.value.boost === 'limiter'}
|
class="h-4 w-4 cursor-pointer accent-accent"
|
||||||
onchange={(e) =>
|
/>
|
||||||
change({ boost: (e.currentTarget as HTMLInputElement).checked ? 'limiter' : 'headroom' })}
|
</SettingRow>
|
||||||
class="h-4 w-4 accent-accent"
|
{/if}
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -80,53 +80,62 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet rows(list: NotificationKindSetting[])}
|
{#snippet row(r: NotificationKindSetting)}
|
||||||
<div class="grid grid-cols-[1fr_repeat(3,3.5rem)] items-center gap-y-1 text-sm">
|
<span class="py-1.5 text-text-primary">{label(r.kind)}</span>
|
||||||
<span></span>
|
{#each channels as c (c.key)}
|
||||||
{#each channels as c (c.key)}
|
<span class="flex justify-center">
|
||||||
<span class="text-center text-xs text-text-secondary">{c.label}</span>
|
<input
|
||||||
{/each}
|
type="checkbox"
|
||||||
{#each list as row (row.kind)}
|
class="h-4 w-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
<span class="py-1 text-text-primary">{label(row.kind)}</span>
|
aria-label="{label(r.kind)}: {c.label}"
|
||||||
{#each channels as c (c.key)}
|
checked={r[c.key] && !disabled(r, c.key)}
|
||||||
<span class="flex justify-center">
|
disabled={disabled(r, c.key)}
|
||||||
<input
|
onchange={() => toggle(r, c.key)}
|
||||||
type="checkbox"
|
/>
|
||||||
class="h-4 w-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
</span>
|
||||||
aria-label="{label(row.kind)}: {c.label}"
|
{/each}
|
||||||
checked={row[c.key] && !disabled(row, c.key)}
|
|
||||||
disabled={disabled(row, c.key)}
|
|
||||||
onchange={() => toggle(row, c.key)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<section id="notifications" class="space-y-3 rounded border border-border bg-surface p-4">
|
<!-- Settings › Notifications (#5595): one table, its column labels once
|
||||||
<h2 class="text-lg font-semibold">Notifications</h2>
|
(#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}
|
<div class="border-t border-border px-4 py-3">
|
||||||
<p class="text-sm text-text-secondary">Loading…</p>
|
{#if !settings}
|
||||||
{:else}
|
<p class="text-sm text-text-secondary">Loading…</p>
|
||||||
{@render rows(forEveryone)}
|
{:else}
|
||||||
|
<div class="grid grid-cols-[1fr_repeat(3,3.5rem)] items-center gap-y-1 text-sm">
|
||||||
{#if forAdmins.length > 0}
|
<span></span>
|
||||||
<h3 class="pt-2 text-sm font-medium text-text-secondary">Library health</h3>
|
{#each channels as c (c.key)}
|
||||||
{@render rows(forAdmins)}
|
<span class="text-center text-xs text-text-secondary">{c.label}</span>
|
||||||
{/if}
|
{/each}
|
||||||
|
{#each forEveryone as r (r.kind)}
|
||||||
{#if !settings.email_available}
|
{@render row(r)}
|
||||||
<p class="text-sm text-text-secondary" data-testid="email-unavailable">
|
{/each}
|
||||||
{#if settings.email_unavailable_reason === 'no_address'}
|
{#if forAdmins.length > 0}
|
||||||
Email is off: <a href="#profile" class="underline hover:text-text-primary">add an email address in your profile</a>.
|
<h3 class="col-span-4 pt-3 text-xs font-medium uppercase tracking-wide text-text-secondary">Library health</h3>
|
||||||
{:else if user.value?.is_admin}
|
{#each forAdmins as r (r.kind)}
|
||||||
Email is off: <a href="/admin/integrations" class="underline hover:text-text-primary">SMTP isn't set up</a>.
|
{@render row(r)}
|
||||||
{:else}
|
{/each}
|
||||||
Email is off: this server doesn't send email.
|
|
||||||
{/if}
|
{/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}
|
||||||
{/if}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ describe('NotificationSettings', () => {
|
|||||||
expect(screen.getByRole('checkbox', { name: 'Request approved: Email' })).toBeDisabled();
|
expect(screen.getByRole('checkbox', { name: 'Request approved: Email' })).toBeDisabled();
|
||||||
expect(screen.getByRole('link', { name: 'add an email address in your profile' })).toHaveAttribute(
|
expect(screen.getByRole('link', { name: 'add an email address in your profile' })).toHaveAttribute(
|
||||||
'href',
|
'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>
|
</script>
|
||||||
|
|
||||||
|
<!-- The value of Settings › About's "Server version" row (#5595). -->
|
||||||
{#if version}
|
{#if version}
|
||||||
<p class="text-xs text-text-secondary">
|
<span class="text-sm tabular-nums text-text-secondary" data-testid="server-version">
|
||||||
Server {version}{#if channel} · {channel}{/if}
|
{version}{#if channel} · {channel}{/if}
|
||||||
</p>
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-sm text-text-muted">—</span>
|
||||||
{/if}
|
{/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">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import SectionRail from '#lib/components/SectionRail.svelte';
|
||||||
import type { WantCounts } from '#lib/api/types.js';
|
import type { WantCounts } from '#lib/api/types.js';
|
||||||
|
|
||||||
// The Collection's sections, down the left like Lidarr's (#5572): the
|
// The Collection's sections, down the left like Lidarr's (#5572): the
|
||||||
// catalogue of artists first, then the sections that show state. On a
|
// catalogue of artists first, then the sections that show state. Activity
|
||||||
// narrow screen the rail runs across the top. Activity carries a second,
|
// carries a second, accented count when downloads are waiting on a person.
|
||||||
// accented count when downloads are waiting on a person. Unmatched, like
|
// Unmatched, like Lidarr's Unmapped Files, holds the library albums no
|
||||||
// Lidarr's Unmapped Files, holds the library albums no MusicBrainz album
|
// MusicBrainz album matched (#5579).
|
||||||
// matched (#5579).
|
|
||||||
|
|
||||||
let { counts }: { counts?: WantCounts } = $props();
|
let { counts }: { counts?: WantCounts } = $props();
|
||||||
|
|
||||||
type Item = { href: string; label: string; count?: number; attention?: number };
|
// An artist's page belongs to the catalogue.
|
||||||
|
const items = $derived([
|
||||||
const items = $derived<Item[]>([
|
{ href: '/collection', label: 'Catalogue', count: counts?.artists, exact: true, also: ['/collection/artist/'] },
|
||||||
{ href: '/collection', label: 'Catalogue', count: counts?.artists },
|
|
||||||
{ href: '/collection/wanted', label: 'Wanted', count: counts?.wanted },
|
{ href: '/collection/wanted', label: 'Wanted', count: counts?.wanted },
|
||||||
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
|
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
|
||||||
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
|
{ 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/blocked', label: 'Blocked', count: counts?.blocked },
|
||||||
{ href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched }
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<nav aria-label="Collection sections" class="border-b border-border md:border-b-0 md:border-r md:pr-3">
|
<SectionRail label="Collection sections" {items} />
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -19,7 +19,11 @@
|
|||||||
{ href: '/library/years', label: 'Years' },
|
{ href: '/library/years', label: 'Years' },
|
||||||
{ href: '/library/liked', label: 'Liked' },
|
{ href: '/library/liked', label: 'Liked' },
|
||||||
{ href: '/library/history', label: 'History' },
|
{ 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 {
|
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 { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
import { readable, writable } from 'svelte/store';
|
import { createRawSnippet } from 'svelte';
|
||||||
import type { LBStatus } from '#lib/api/listenbrainz.js';
|
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||||
|
|
||||||
vi.mock('#lib/api/listenbrainz.js', () => ({
|
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/settings/account') }));
|
||||||
createLBStatusQuery: vi.fn(),
|
vi.mock('$app/state', () => pageUrlModule(state));
|
||||||
createTokenMutation: vi.fn(),
|
|
||||||
createEnabledMutation: vi.fn(),
|
|
||||||
setListenBrainzToken: vi.fn(),
|
|
||||||
setListenBrainzEnabled: vi.fn()
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The Notifications section has its own tests (NotificationSettings.test.ts);
|
import SettingsLayout from './+layout.svelte';
|
||||||
// here it only needs a settled query so the page renders.
|
import { load } from './+page';
|
||||||
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.
|
const children = createRawSnippet(() => ({ render: () => `<p data-testid="section-body">body</p>` }));
|
||||||
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/me.js', () => ({
|
afterEach(() => vi.clearAllMocks());
|
||||||
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()
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mutable holder so individual tests can inject populated metrics;
|
function setup(path: string) {
|
||||||
// vi.mock is hoisted, hence vi.hoisted for the shared reference.
|
state.pageUrl = new URL(`http://localhost${path}`);
|
||||||
const metricsMock = vi.hoisted(() => ({
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
data: { window_days: 30, baseline: null, groups: [] } as unknown
|
return render(SettingsLayout, { children } as any);
|
||||||
}));
|
}
|
||||||
|
|
||||||
vi.mock('#lib/api/metrics.js', () => ({
|
describe('/settings', () => {
|
||||||
createRecommendationMetricsQuery: () => ({
|
test('the sections run down the left, Account first, the current one marked', () => {
|
||||||
subscribe: (run: (v: unknown) => void) => {
|
setup('/settings/connections');
|
||||||
run({ isPending: false, isError: false, data: metricsMock.data });
|
const nav = screen.getByRole('navigation', { name: /settings sections/i });
|
||||||
return () => {};
|
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;
|
||||||
}
|
}
|
||||||
})
|
expect(thrown).toMatchObject({ status: 308, location: '/settings/account' });
|
||||||
}));
|
|
||||||
|
|
||||||
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());
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user