diff --git a/web/src/lib/api/tuning.ts b/web/src/lib/api/tuning.ts index fae0f40f..10d1979d 100644 --- a/web/src/lib/api/tuning.ts +++ b/web/src/lib/api/tuning.ts @@ -26,14 +26,24 @@ export type TasteTuning = { mood_scale: number; }; -export type TuningScope = 'radio' | 'daily_mix' | 'taste'; +// Discover request-surface knobs (#2377). Its own scope rather than part of +// taste: snooze_days belongs here, and a snooze must never be read as taste +// signal (#2374). +export type DiscoverTuning = { + tag_overlap_weight: number; + snooze_days: number; +}; + +export type TuningScope = 'radio' | 'daily_mix' | 'taste' | 'discover'; export type TuningSnapshot = { profiles: Record<'radio' | 'daily_mix', WeightProfile>; taste: TasteTuning; + discover: DiscoverTuning; shipped: { profiles: Record<'radio' | 'daily_mix', WeightProfile>; taste: TasteTuning; + discover: DiscoverTuning; }; }; diff --git a/web/src/routes/admin/tuning/+page.svelte b/web/src/routes/admin/tuning/+page.svelte index 79e96668..33bb0b19 100644 --- a/web/src/routes/admin/tuning/+page.svelte +++ b/web/src/routes/admin/tuning/+page.svelte @@ -9,6 +9,7 @@ type TuningSnapshot, type WeightProfile, type TasteTuning, + type DiscoverTuning, type TrendsResponse, type TrendSeries, type TrendMarker @@ -43,6 +44,11 @@ { key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' } ]; + const discoverFields: { key: keyof DiscoverTuning; label: string; hint: string }[] = [ + { key: 'tag_overlap_weight', label: 'Taste-tag weight', hint: "How strongly a candidate's tags matching your taste boosts it. score x (1 + w x overlap), so 0 turns the tag term off and ranks on similarity alone. An artist with no cached tags is never penalised." }, + { key: 'snooze_days', label: 'Snooze length (days)', hint: 'How long "not right now" parks a suggestion before it returns on its own. Records no opinion about the artist and never feeds the taste profile.' } + ]; + const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [ { scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' }, { scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' } @@ -55,11 +61,17 @@ let saving = $state(null); function fillForm(snap: TuningSnapshot) { - const f: Record> = { radio: {}, daily_mix: {}, taste: {} }; + const f: Record> = { + radio: {}, + daily_mix: {}, + taste: {}, + discover: {} + }; for (const p of ['radio', 'daily_mix'] as const) { for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]); } for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]); + for (const { key } of discoverFields) f.discover[key] = String(snap.discover[key]); form = f; } @@ -76,11 +88,17 @@ // A knob deviates when its CURRENT SAVED value differs from shipped; // the dot marks where this install has drifted from defaults. - function deviates(scope: 'radio' | 'daily_mix' | 'taste', key: string): boolean { + function deviates(scope: TuningScope, key: string): boolean { if (!snapshot) return false; if (scope === 'taste') { return snapshot.taste[key as keyof TasteTuning] !== snapshot.shipped.taste[key as keyof TasteTuning]; } + if (scope === 'discover') { + return ( + snapshot.discover[key as keyof DiscoverTuning] !== + snapshot.shipped.discover[key as keyof DiscoverTuning] + ); + } return ( snapshot.profiles[scope][key as keyof WeightProfile] !== snapshot.shipped.profiles[scope][key as keyof WeightProfile] @@ -90,6 +108,7 @@ function currentValue(scope: TuningScope, key: string): number { if (!snapshot) return 0; if (scope === 'taste') return snapshot.taste[key as keyof TasteTuning]; + if (scope === 'discover') return snapshot.discover[key as keyof DiscoverTuning]; return snapshot.profiles[scope][key as keyof WeightProfile]; } @@ -276,6 +295,7 @@ type="button" disabled={saving !== null} onclick={() => reset(p.scope)} + aria-label="Reset {p.label} to defaults" class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50" > Reset to defaults @@ -328,6 +348,63 @@ type="button" disabled={saving !== null} onclick={() => reset('taste')} + aria-label="Reset taste profile build to defaults" + class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50" + > + Reset to defaults + + + + + +
+
+

Discover requests

+

+ How the Discover suggestion deck ranks out-of-library artists. Tag coverage for + artists you don't own is partial by nature, so an untagged candidate keeps its + similarity score rather than being pushed down. +

+
+
+ {#each discoverFields as f (f.key)} +
+ + +
+ {/each} +
+
+ +