feat(discovery): acquire recommendations that keep their names

Acquisition ran yt-dlp without --embed-metadata, so every download
arrived untagged. The scanner then stored the video id as the title and
"Unknown Artist" as the artist, the vetted-candidate tag check rejected
the mismatch, and all 18 acquired tracks were hidden and retired.

- Pass --embed-metadata so downloads carry real tags.
- Let a scan take fallback title/artist from the candidate, for sources
  that still ship untagged files.
- Install Deno alongside yt-dlp: YouTube guards some formats with a JS
  challenge yt-dlp must execute, and no other runtime is enabled.
- Dedupe candidates by artist and title. The (source, external_id) key
  misses the same song reaching us under two Deezer release ids.

Also carries the in-flight discovery work this builds on: the
Recommendations page replacing Discover, the discovery source service,
and the acquisition spec tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-08-08 18:43:58 +04:00
parent d371bd97f3
commit bfe22745bc
28 changed files with 1241 additions and 221 deletions
+2 -2
View File
@@ -32,7 +32,7 @@ const NAV_COMMANDS: CommandItem[] = [
{ id: 'nav-artists', label: 'Artists', icon: Users, action: () => {}, keywords: ['artists', 'bands'] },
{ id: 'nav-genres', label: 'Genres', icon: Tag, action: () => {}, keywords: ['genres', 'tags', 'categories'] },
{ id: 'nav-vibe', label: 'Vibe', description: 'Endless recommendations', icon: Zap, action: () => {}, keywords: ['vibe', 'recommendations', 'radio'] },
{ id: 'nav-discover', label: 'Discover', description: 'Browse by genre', icon: Compass, action: () => {}, keywords: ['discover', 'explore'] },
{ id: 'nav-recommendations', label: 'Found', description: 'Discovered tracks and their fate', icon: Compass, action: () => {}, keywords: ['found', 'discovery', 'recommendations', 'probation', 'new releases'] },
{ id: 'nav-quarantine', label: 'Quarantine', icon: ShieldAlert, action: () => {}, keywords: ['quarantine', 'disliked', 'trash'] },
{ id: 'nav-jobs', label: 'Jobs', description: 'Background tasks', icon: Terminal, action: () => {}, keywords: ['jobs', 'tasks', 'queue'] },
{ id: 'nav-settings', label: 'Settings', icon: Settings, action: () => {}, keywords: ['settings', 'preferences', 'config'] },
@@ -63,7 +63,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
'nav-artists': '/artists',
'nav-genres': '/genres',
'nav-vibe': '/vibe',
'nav-discover': '/discover',
'nav-recommendations': '/recommendations',
'nav-quarantine': '/quarantine',
'nav-jobs': '/jobs',
'nav-settings': '/settings',
+1 -1
View File
@@ -35,7 +35,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'AI',
items: [
{ to: '/vibe', icon: Zap, label: 'Vibe' },
{ to: '/discover', icon: Compass, label: 'Discover' },
{ to: '/recommendations', icon: Compass, label: 'Found' },
],
},
{
+1 -1
View File
@@ -17,8 +17,8 @@ const PAGE_TITLES: Record<string, string> = {
'/albums': 'Albums',
'/artists': 'Artists',
'/genres': 'Genres',
'/recommendations': 'Found',
'/vibe': 'Vibe',
'/discover': 'Discover',
'/search': 'Search',
'/settings': 'Settings',
'/quarantine': 'Quarantine',
+41 -5
View File
@@ -22,9 +22,11 @@ export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; a
const energy = clamp(profile.energy, 0.5);
const novelty = clamp(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3);
const { energyLabel, discoveryLabel } = describeProfile(energy, novelty);
// Warm range only (amber → gold → orange). The old 205 base put a teal-blue
// glow in a warm brown room, which read as a different app's accent.
const hue = Math.round(28 + novelty * 30 - energy * 12);
// Hue is discovery alone: ember red when the Vibe stays familiar, gold when it
// reaches. Energy is deliberately absent — it already drives four motion
// channels below, and mixing it in here cancelled half the novelty swing.
// Warm range only; the old 205 base put a teal-blue glow in a warm brown room.
const hue = Math.round(15 + novelty * 40);
const style = {
'--vibe-hue': String(hue),
'--vibe-pulse': `${(4.8 - energy * 2.3).toFixed(2)}s`,
@@ -53,13 +55,47 @@ export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; a
);
if (ambient) {
// Displacing clean gradients by animated fractal noise is what separates a
// plasma from a blurred blob. baseFrequency is animated in SMIL rather than
// CSS because no CSS property reaches inside an SVG filter primitive.
return (
<div
className="vibe-aura-blob pointer-events-none absolute left-1/2 top-[28%] z-0 h-[190px] w-[230px] -translate-x-1/2 -translate-y-1/2 opacity-40 mix-blend-screen sm:h-[400px] sm:w-[480px] sm:opacity-70"
className="vibe-aura-blob pointer-events-none absolute z-0 h-[380px] w-[420px] -translate-x-1/2 -translate-y-1/2 opacity-85 mix-blend-screen sm:h-[560px] sm:w-[660px] sm:opacity-95"
style={style}
role="img"
aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`}
/>
>
<svg aria-hidden className="absolute h-0 w-0">
<filter id="vibe-plasma" x="-30%" y="-30%" width="160%" height="160%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.009 0.014"
numOctaves={3}
seed={7}
result="noise"
>
<animate
attributeName="baseFrequency"
dur={`${(18 - energy * 8).toFixed(1)}s`}
values="0.009 0.014; 0.021 0.007; 0.009 0.014"
repeatCount="indefinite"
/>
</feTurbulence>
<feDisplacementMap
in="SourceGraphic"
in2="noise"
scale={String(Math.round(46 + energy * 70))}
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</svg>
<div className="vibe-aura-stack">
<div className="vibe-aura-layer vibe-aura-rays-layer" />
<div className="vibe-aura-layer vibe-aura-swirl-layer" />
<div className="vibe-aura-layer vibe-aura-core-layer" />
</div>
</div>
);
}