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:
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+76
-16
@@ -244,27 +244,87 @@ html { scroll-behavior: smooth; }
|
||||
.vibe-aura-spark { width: 4px; height: 4px; animation: vibe-aura-spark 2.1s ease-in-out infinite; }
|
||||
.vibe-aura-spark-one { left: -4px; top: 5px; }
|
||||
.vibe-aura-spark-two { right: -3px; bottom: 7px; animation-delay: -1s; }
|
||||
/* Ambient variant: one soft organic blob of warm light behind the page. The
|
||||
scaled-up creature read as concentric rings at that size, which fought the
|
||||
content instead of sitting behind it. Gradient goes on the sized element —
|
||||
no inset-0 fill layer. */
|
||||
@keyframes vibe-aura-drift {
|
||||
from { transform: scale(var(--vibe-scale)) translate3d(-2%, -1%, 0) rotate(-4deg); }
|
||||
to { transform: scale(calc(var(--vibe-scale) * 1.12)) translate3d(3%, 2%, 0) rotate(5deg); }
|
||||
}
|
||||
/* Ambient variant: a plasma in the page background, in the lineage of a media
|
||||
player visualizer. Three layers — a hot core, a cooler counter-rotating
|
||||
companion, and a fan of rays — stacked and pushed through one SVG turbulence
|
||||
displacement pass (see VibeAura.tsx), which is what turns clean gradients
|
||||
into churning flame. Centering lives on the wrapper, never here: these
|
||||
keyframes animate `transform` and would overwrite it. */
|
||||
/* Anchor lives here, not in utility classes: the offsets differ per breakpoint
|
||||
and the -50% centering pair sits on the same element, so keeping both in one
|
||||
place is what stops the two from fighting. Off to the right on purpose — the
|
||||
reading column stays clear and the plasma bleeds past the track rows. */
|
||||
.vibe-aura-blob {
|
||||
border-radius: 46% 54% 62% 38% / 55% 43% 57% 45%;
|
||||
background:
|
||||
radial-gradient(closest-side at 40% 36%, hsl(var(--vibe-hue) 92% 60% / .55), transparent 72%),
|
||||
radial-gradient(closest-side at 66% 64%, hsl(calc(var(--vibe-hue) + 28) 86% 52% / .4), transparent 74%);
|
||||
filter: blur(44px);
|
||||
animation: vibe-aura-drift var(--vibe-orbit) ease-in-out infinite alternate;
|
||||
left: 62%;
|
||||
top: 52%;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.vibe-aura-blob { filter: blur(72px); }
|
||||
.vibe-aura-blob { left: 80%; top: 56%; }
|
||||
}
|
||||
.vibe-aura-stack {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
filter: url(#vibe-plasma) blur(14px);
|
||||
}
|
||||
.vibe-aura-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
@keyframes vibe-aura-churn {
|
||||
0% { transform: scale(var(--vibe-scale)) translate3d(-7%, -4%, 0) rotate(0deg); }
|
||||
33% { transform: scale(calc(var(--vibe-scale) * 1.22)) translate3d(6%, -8%, 0) rotate(120deg); }
|
||||
66% { transform: scale(calc(var(--vibe-scale) * 0.9)) translate3d(8%, 7%, 0) rotate(240deg); }
|
||||
100% { transform: scale(var(--vibe-scale)) translate3d(-7%, -4%, 0) rotate(360deg); }
|
||||
}
|
||||
@keyframes vibe-aura-spin {
|
||||
from { transform: rotate(0deg) scale(var(--vibe-scale)); }
|
||||
to { transform: rotate(-360deg) scale(var(--vibe-scale)); }
|
||||
}
|
||||
@keyframes vibe-aura-breathe {
|
||||
from { opacity: .55; transform: scale(.86); }
|
||||
to { opacity: 1; transform: scale(1.12); }
|
||||
}
|
||||
/* Hot core — near-white centre falling to the profile hue. */
|
||||
.vibe-aura-core-layer {
|
||||
background:
|
||||
radial-gradient(closest-side at 47% 44%, hsl(calc(var(--vibe-hue) + 24) 100% 82% / .95), hsl(var(--vibe-hue) 100% 58% / .75) 38%, transparent 70%),
|
||||
radial-gradient(closest-side at 58% 58%, hsl(calc(var(--vibe-hue) - 12) 100% 50% / .8), transparent 66%);
|
||||
animation: vibe-aura-churn var(--vibe-orbit) ease-in-out infinite;
|
||||
}
|
||||
/* Companion — turns the other way so the two shear against each other. */
|
||||
.vibe-aura-swirl-layer {
|
||||
background:
|
||||
radial-gradient(closest-side at 62% 40%, hsl(calc(var(--vibe-hue) - 20) 100% 54% / .7), transparent 68%),
|
||||
radial-gradient(closest-side at 36% 66%, hsl(calc(var(--vibe-hue) - 44) 96% 50% / .58), transparent 72%);
|
||||
animation: vibe-aura-churn calc(var(--vibe-orbit) * 1.6) ease-in-out infinite reverse;
|
||||
}
|
||||
/* Ray fan — the spikes the displacement pass bends into filaments. Masked to a
|
||||
ring so the centre stays a clean hot core. */
|
||||
.vibe-aura-rays-layer {
|
||||
background: repeating-conic-gradient(
|
||||
from 0deg,
|
||||
transparent 0deg 5deg,
|
||||
hsl(calc(var(--vibe-hue) + 8) 100% 66% / .5) 5deg 7.5deg
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(closest-side, transparent 18%, #000 46%, transparent 82%);
|
||||
mask-image: radial-gradient(closest-side, transparent 18%, #000 46%, transparent 82%);
|
||||
animation: vibe-aura-spin calc(var(--vibe-orbit) * 2.4) linear infinite;
|
||||
}
|
||||
/* Pulse at the profile's tempo. This lives on the stack, not on the outer
|
||||
.vibe-aura-blob: that element carries the -translate-x-1/2 -translate-y-1/2
|
||||
centering, and an animation setting `transform` overwrites it, which anchors
|
||||
the plasma by its top-left corner instead of its middle. */
|
||||
.vibe-aura-stack {
|
||||
animation: vibe-aura-breathe var(--vibe-pulse) ease-in-out infinite alternate;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.vibe-aura-stack { filter: url(#vibe-plasma) blur(20px); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark, .vibe-aura-blob { animation: none; }
|
||||
.vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark,
|
||||
.vibe-aura-stack, .vibe-aura-layer { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Component base classes ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Disc3, Sparkles, AlertCircle } from 'lucide-react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { genreService } from '../services/genreService';
|
||||
import { startVibeSession } from '../services/vibeSession';
|
||||
import { TrackRow } from '../components/TrackRow';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { SkeletonGrid, SkeletonRows } from '../components/LoadingState';
|
||||
import type { Genre, Track } from '../types';
|
||||
|
||||
export default function Discover() {
|
||||
const [selected, setSelected] = useState<Genre | null>(null);
|
||||
const [startingVibe, setStartingVibe] = useState(false);
|
||||
const [vibeError, setVibeError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const genres = useQuery<Genre[]>({
|
||||
queryKey: ['genres'],
|
||||
queryFn: () => genreService.listGenres(),
|
||||
});
|
||||
|
||||
const genreTracks = useQuery<Track[]>({
|
||||
queryKey: ['genre-tracks', selected?.id],
|
||||
queryFn: () => genreService.getGenreTracks(selected!.id),
|
||||
enabled: !!selected,
|
||||
});
|
||||
|
||||
const startGenreVibe = async () => {
|
||||
const tracks = genreTracks.data;
|
||||
if (!tracks || tracks.length === 0 || startingVibe) return;
|
||||
const seed = tracks[0];
|
||||
setStartingVibe(true);
|
||||
setVibeError(null);
|
||||
try {
|
||||
await startVibeSession(seed);
|
||||
await navigate({ to: '/vibe' });
|
||||
} catch {
|
||||
setVibeError('Could not start a Vibe from this genre. Please try again.');
|
||||
} finally {
|
||||
setStartingVibe(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Discover"
|
||||
subtitle="Pick a genre, then play it or seed a vibe from it."
|
||||
meta={genres.data?.length ? `${genres.data.length} genres` : undefined}
|
||||
/>
|
||||
|
||||
{genres.isLoading ? (
|
||||
<SkeletonGrid count={12} />
|
||||
) : genres.isError ? (
|
||||
<EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load genres" subtitle="Something went wrong. Try reloading the page." />
|
||||
) : (genres.data ?? []).length === 0 ? (
|
||||
<EmptyState compact icon={<Disc3 size={28} />} title="No genres yet" subtitle="Genres appear after you scan and enrich your library." />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{(genres.data ?? []).map((genre) => {
|
||||
const active = selected?.id === genre.id;
|
||||
return (
|
||||
<button
|
||||
key={genre.id}
|
||||
onClick={() => setSelected(genre)}
|
||||
className={`flex h-16 flex-col justify-center gap-0.5 rounded-lg border px-3 text-left transition-colors ${
|
||||
active
|
||||
? 'border-accent/60 bg-accent/10'
|
||||
: 'border-border bg-surface0/60 hover:bg-surface1'
|
||||
}`}
|
||||
>
|
||||
<div className={`truncate text-sm font-medium ${active ? 'text-accent' : 'text-text'}`} title={genre.name}>
|
||||
{genre.name}
|
||||
</div>
|
||||
{typeof genre.track_count === 'number' && (
|
||||
<div className="font-mono text-xs tabular-nums text-machine">
|
||||
{genre.track_count.toLocaleString()} tracks
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<h2 className="flex items-baseline gap-2 text-lg font-medium text-text">
|
||||
{selected.name}
|
||||
<span className="font-mono text-xs tabular-nums text-machine">
|
||||
{(genreTracks.data?.length ?? 0).toLocaleString()} tracks
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => void startGenreVibe()}
|
||||
disabled={startingVibe || !genreTracks.data || genreTracks.data.length === 0}
|
||||
className="flex items-center gap-2 rounded-lg border border-accent/60 bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent transition-colors hover:bg-accent/20 disabled:opacity-50"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{startingVibe ? 'Starting…' : 'Start a vibe'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{vibeError && (
|
||||
<p className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||
{vibeError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{genreTracks.isLoading ? (
|
||||
<SkeletonRows count={6} />
|
||||
) : genreTracks.isError ? (
|
||||
<EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load tracks" subtitle="Something went wrong. Try reloading the page." />
|
||||
) : (genreTracks.data ?? []).length === 0 ? (
|
||||
<EmptyState compact icon={<Disc3 size={28} />} title="No tracks in this genre" />
|
||||
) : (
|
||||
<div className="track-list">
|
||||
{(genreTracks.data ?? []).map((track, i) => (
|
||||
<TrackRow
|
||||
key={`${track.id}-${i}`}
|
||||
track={track}
|
||||
queue={genreTracks.data ?? []}
|
||||
index={i}
|
||||
showActions={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertCircle, Compass } from 'lucide-react';
|
||||
import { discoveryService } from '../services/discoveryService';
|
||||
import type { DiscoveryRow, DiscoverySourceSummary } from '../services/discoveryService';
|
||||
import { Badge } from '../components/ethos/Badge';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { SkeletonRows } from '../components/LoadingState';
|
||||
|
||||
// Mirrors the probation sweep in workers/src/index.ts. Shown rather than hidden:
|
||||
// a track on probation is N plays away from staying, and the operator should be
|
||||
// able to see exactly how far. If the sweep's thresholds change, change these.
|
||||
const KEEP_AFTER_PLAYS = 3;
|
||||
const DROP_AFTER_SKIPS = 3;
|
||||
|
||||
type Filter = 'all' | 'probation' | 'retained' | 'retired' | 'stalled';
|
||||
|
||||
const FILTERS: { id: Filter; label: string }[] = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'probation', label: 'On probation' },
|
||||
{ id: 'retained', label: 'Kept' },
|
||||
{ id: 'retired', label: 'Removed' },
|
||||
{ id: 'stalled', label: 'Never arrived' },
|
||||
];
|
||||
|
||||
/** A candidate that was evaluated but never became a track. */
|
||||
const isStalled = (row: DiscoveryRow) => !row.track_id && row.status !== 'candidate';
|
||||
|
||||
function matches(row: DiscoveryRow, filter: Filter): boolean {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'stalled') return isStalled(row);
|
||||
return row.probation_status === filter;
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
if (source === 'new_release') return 'New release';
|
||||
if (source === 'similar_recommendation') return 'Similar';
|
||||
if (source === 'graph_exploration') return 'Graph';
|
||||
return source;
|
||||
}
|
||||
|
||||
function statusBadge(row: DiscoveryRow) {
|
||||
if (row.probation_status === 'retained') return <Badge color="green">Kept</Badge>;
|
||||
if (row.probation_status === 'retired') return <Badge color="neutral">Removed</Badge>;
|
||||
if (row.probation_status === 'probation') return <Badge color="amber" dot>On probation</Badge>;
|
||||
if (isStalled(row)) return <Badge color="red">{row.status.replace(/_/g, ' ')}</Badge>;
|
||||
return <Badge color="neutral">Queued</Badge>;
|
||||
}
|
||||
|
||||
function shortDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Where this candidate came from, in one line, without a second request. */
|
||||
function provenance(row: DiscoveryRow): string {
|
||||
const notes = row.notes ?? {};
|
||||
if (notes.album) return `from ${notes.album}`;
|
||||
if (notes.seed_artist) return `because you play ${notes.seed_artist}`;
|
||||
return sourceLabel(row.source);
|
||||
}
|
||||
|
||||
function displayName(row: DiscoveryRow): { title: string; artist: string } {
|
||||
const credited = row.artist_credit?.[0]?.name ?? '';
|
||||
return {
|
||||
title: row.track_title ?? row.title ?? 'Untitled',
|
||||
artist: row.track_artist ?? credited,
|
||||
};
|
||||
}
|
||||
|
||||
function SummaryStrip({ summary }: { summary: DiscoverySourceSummary[] }) {
|
||||
if (summary.length === 0) return null;
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{summary.map((s) => (
|
||||
<div key={s.source} className="rounded-lg border border-border bg-surface0/40 p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-text">{sourceLabel(s.source)}</span>
|
||||
<span className="flex-none font-mono text-xs tabular-nums text-machine">
|
||||
{s.candidates} found
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-2 flex flex-wrap gap-x-4 gap-y-1 font-mono text-xs tabular-nums">
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-sans text-secondary">kept</dt>
|
||||
<dd className="text-green">{s.retained}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-sans text-secondary">trying</dt>
|
||||
<dd className="text-amber">{s.probation}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-sans text-secondary">removed</dt>
|
||||
<dd className="text-machine">{s.retired}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="font-sans text-secondary">never arrived</dt>
|
||||
<dd className={s.stalled > 0 ? 'text-red' : 'text-machine'}>{s.stalled}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Recommendations() {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['discovery-overview'],
|
||||
queryFn: () => discoveryService.overview(),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const rows = data?.rows ?? [];
|
||||
const visible = useMemo(() => rows.filter((row) => matches(row, filter)), [rows, filter]);
|
||||
|
||||
const totals = data?.summary.reduce(
|
||||
(acc, s) => ({
|
||||
candidates: acc.candidates + s.candidates,
|
||||
retained: acc.retained + s.retained,
|
||||
probation: acc.probation + s.probation,
|
||||
}),
|
||||
{ candidates: 0, retained: 0, probation: 0 }
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Recommendations"
|
||||
subtitle="What discovery brought in, and what your skips did with it."
|
||||
meta={
|
||||
totals
|
||||
? `${totals.candidates} found · ${totals.retained} kept · ${totals.probation} on probation`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<SkeletonRows count={5} />
|
||||
) : isError ? (
|
||||
<EmptyState
|
||||
compact
|
||||
icon={<AlertCircle size={28} />}
|
||||
title="Couldn't load recommendations"
|
||||
subtitle="Something went wrong. Try reloading the page."
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Compass size={28} />}
|
||||
title="Nothing discovered yet"
|
||||
subtitle="The new-release and similarity scans run daily. Acquisition also has to be enabled on the worker before a candidate can become a track."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SummaryStrip summary={data?.summary ?? []} />
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{FILTERS.map((f) => {
|
||||
const count = rows.filter((row) => matches(row, f.id)).length;
|
||||
const active = filter === f.id;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||
active
|
||||
? 'border-accent/60 bg-accent/10 text-accent'
|
||||
: 'border-border text-secondary hover:bg-surface1'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
<span className="font-mono tabular-nums text-[11px] text-machine">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<EmptyState compact icon={<Compass size={28} />} title="Nothing in this state" />
|
||||
) : (
|
||||
<ul className="track-list rounded-lg border border-border bg-surface0/40 px-1.5">
|
||||
{visible.map((row) => {
|
||||
const { title, artist } = displayName(row);
|
||||
const plays = row.completed_plays ?? 0;
|
||||
const skips = row.quick_skips ?? 0;
|
||||
// Stacked below 640px: at that width the name, the badge and the
|
||||
// mono readout cannot share a row without the title collapsing
|
||||
// to two characters.
|
||||
return (
|
||||
<li
|
||||
key={row.id}
|
||||
className="flex flex-col gap-1 py-2 sm:min-h-14 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-text">{title}</span>
|
||||
{statusBadge(row)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-secondary">
|
||||
{artist ? `${artist} · ` : ''}
|
||||
{provenance(row)}
|
||||
</div>
|
||||
{row.last_error && (
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] text-red" title={row.last_error}>
|
||||
{row.last_error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 font-mono text-xs tabular-nums text-machine sm:flex-none">
|
||||
{row.probation_status === 'probation' ? (
|
||||
<span title="Completed plays that keep it, quick skips that drop it">
|
||||
{plays}/{KEEP_AFTER_PLAYS} plays · {skips}/{DROP_AFTER_SKIPS} skips
|
||||
</span>
|
||||
) : row.track_id ? (
|
||||
<span title="Completed plays · quick skips">
|
||||
{plays} plays · {skips} skips
|
||||
</span>
|
||||
) : (
|
||||
<span title="Acquisition attempts">
|
||||
{row.acquisition_attempts} attempts
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted" title="Acquired, or first seen">
|
||||
{shortDate(row.acquired_at ?? row.first_seen_at)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import AlbumDetail from './pages/AlbumDetail';
|
||||
import Tracks from './pages/Tracks';
|
||||
import Genres from './pages/Genres';
|
||||
import Vibe from './pages/Vibe';
|
||||
import Discover from './pages/Discover';
|
||||
import Recommendations from './pages/Recommendations';
|
||||
import Search from './pages/Search';
|
||||
import Settings from './pages/Settings';
|
||||
import Quarantine from './pages/Quarantine';
|
||||
@@ -73,10 +73,10 @@ export const vibeRoute = createRoute({
|
||||
component: Vibe,
|
||||
});
|
||||
|
||||
export const discoverRoute = createRoute({
|
||||
export const recommendationsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/discover',
|
||||
component: Discover,
|
||||
path: '/recommendations',
|
||||
component: Recommendations,
|
||||
});
|
||||
|
||||
export const searchRoute = createRoute({
|
||||
@@ -115,7 +115,7 @@ const routeTree = rootRoute.addChildren([
|
||||
tracksRoute,
|
||||
genresRoute,
|
||||
vibeRoute,
|
||||
discoverRoute,
|
||||
recommendationsRoute,
|
||||
searchRoute,
|
||||
settingsRoute,
|
||||
quarantineRoute,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import api from './api';
|
||||
|
||||
/** One candidate and whatever became of it. `track_id` is null until acquired. */
|
||||
export interface DiscoveryRow {
|
||||
id: string;
|
||||
source: string;
|
||||
status: string;
|
||||
title: string | null;
|
||||
artist_credit: { name?: string; artist_id?: string }[] | null;
|
||||
notes: { acquisition?: { query?: string; url?: string }; album?: string; seed_artist?: string; seed_title?: string } | null;
|
||||
first_seen_at: string;
|
||||
acquired_at: string | null;
|
||||
last_error: string | null;
|
||||
acquisition_attempts: number;
|
||||
track_id: string | null;
|
||||
track_title: string | null;
|
||||
track_artist: string | null;
|
||||
probation_status: 'probation' | 'retained' | 'retired' | null;
|
||||
probation_entered_at: string | null;
|
||||
completed_plays: number | null;
|
||||
quick_skips: number | null;
|
||||
}
|
||||
|
||||
export interface DiscoverySourceSummary {
|
||||
source: string;
|
||||
candidates: number;
|
||||
probation: number;
|
||||
retained: number;
|
||||
retired: number;
|
||||
stalled: number;
|
||||
}
|
||||
|
||||
export const discoveryService = {
|
||||
// GET /api/discovery/overview -> every candidate plus per-source totals
|
||||
async overview(): Promise<{ rows: DiscoveryRow[]; summary: DiscoverySourceSummary[] }> {
|
||||
const res = await api.get<{ rows: DiscoveryRow[]; summary: DiscoverySourceSummary[] }>(
|
||||
'/discovery/overview'
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -2,16 +2,18 @@ import api from './api';
|
||||
import type { FeedbackAction, HistoryEntry } from '../types';
|
||||
|
||||
export const historyService = {
|
||||
// POST /api/history { trackId, completed?, batchId? } -> { historyId }
|
||||
// POST /api/history { trackId, completed?, batchId?, listenedMs? } -> { historyId }
|
||||
async recordPlay(
|
||||
trackId: string,
|
||||
completed?: boolean,
|
||||
batchId?: string
|
||||
batchId?: string,
|
||||
listenedMs?: number
|
||||
): Promise<{ historyId: string }> {
|
||||
const res = await api.post<{ historyId: string }>('/history', {
|
||||
trackId,
|
||||
completed,
|
||||
batchId,
|
||||
listenedMs,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user