1a7515e6ea
Per-source play outcomes so the operator can see whether each recommendation
surface is landing and tune the now-operator-tunable taste weights.
Server:
- query RecommendationSourceMetricsForUser: groups the user's play_events by
source (system-playlist surface), reporting plays / skips / avg completion
over a window; NULL-source (library/radio) plays excluded.
- GET /api/me/recommendation-metrics?days=30 (default 30, capped 365) →
{window_days, sources:[{source, plays, skips, skip_rate, avg_completion}]}.
- handler test: 401 unauth; per-source aggregation + NULL-source exclusion +
skip_rate / avg_completion math.
Web:
- lib/api/metrics.ts: query + friendly source labels.
- settings page gains a "Recommendation metrics" card (table of surface / plays
/ skip rate / avg completion), with loading/error/empty states.
- settings tests mock the new query (manual subscribe-store, hoisting-safe).
Note: You-might-like plays aren't source-tagged (it's a Home row, not a system
playlist), so this covers For-You / Discover / the mixes. Tagging YML plays
would be a client follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
410 lines
15 KiB
Svelte
410 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { pageTitle } from '$lib/branding';
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
import type { CreateQueryResult, CreateMutationResult } from '@tanstack/svelte-query';
|
|
import {
|
|
createLBStatusQuery,
|
|
createTokenMutation,
|
|
createEnabledMutation,
|
|
type LBStatus
|
|
} from '$lib/api/listenbrainz';
|
|
import {
|
|
createRecommendationMetricsQuery,
|
|
sourceLabel,
|
|
type RecommendationMetrics
|
|
} from '$lib/api/metrics';
|
|
import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte';
|
|
import { player, setCrossfade } from '$lib/player/store.svelte';
|
|
import {
|
|
updateProfile,
|
|
changePassword,
|
|
getAPIToken,
|
|
regenerateAPIToken
|
|
} from '$lib/api/me';
|
|
import { errCode } from '$lib/api/errors';
|
|
import { pushToast } from '$lib/stores/toast.svelte';
|
|
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
|
|
import ServerVersion from '$lib/components/ServerVersion.svelte';
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
const status = createLBStatusQuery() as CreateQueryResult<LBStatus>;
|
|
const metrics = createRecommendationMetricsQuery() as CreateQueryResult<RecommendationMetrics>;
|
|
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.');
|
|
} 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.');
|
|
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 ----------------------------------------------------------
|
|
|
|
let apiToken = $state<string | null>(null);
|
|
let tokenSaving = $state(false);
|
|
let confirmRegen = $state(false);
|
|
let regenTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
$effect(() => {
|
|
getAPIToken().then(r => { apiToken = r.api_token; }).catch(() => {});
|
|
});
|
|
|
|
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('API token regenerated.');
|
|
} catch (e: unknown) {
|
|
pushToast(`Regenerate failed: ${errCode(e)}`, 'error');
|
|
} finally {
|
|
tokenSaving = false;
|
|
}
|
|
}
|
|
</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>
|
|
</section>
|
|
|
|
<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 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 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. Lower skip rate and higher average
|
|
completion mean the surface is hitting.
|
|
</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.sources.length > 0}
|
|
<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 $metrics.data.sources as m (m.source)}
|
|
<tr class="border-t border-border">
|
|
<td class="py-1">{sourceLabel(m.source)}</td>
|
|
<td class="py-1 text-right tabular-nums">{m.plays}</td>
|
|
<td class="py-1 text-right tabular-nums">{(m.skip_rate * 100).toFixed(0)}%</td>
|
|
<td class="py-1 text-right tabular-nums">{(m.avg_completion * 100).toFixed(0)}%</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{:else}
|
|
<p class="text-sm text-text-secondary">
|
|
No recommendation plays yet. Play something from For You, Discover, or a mix.
|
|
</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Profile card -->
|
|
<section 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-none 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-none 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-none 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-none 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-none 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 (DSub, Symfonium, etc.) to authenticate to your library.
|
|
Regenerating invalidates clients that have the old token cached.
|
|
</p>
|
|
{#if apiToken}
|
|
<code class="block break-all rounded bg-background p-2 text-xs">
|
|
{apiToken}
|
|
</code>
|
|
{/if}
|
|
<div class="flex gap-2">
|
|
<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>
|
|
<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>
|
|
|
|
<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 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>
|