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>
);
}
+76 -16
View File
@@ -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 ────────────────────────────────────────────────── */
-137
View File
@@ -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>
);
}
+239
View File
@@ -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>
);
}
+5 -5
View File
@@ -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,
+41
View File
@@ -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;
},
};
+4 -2
View File
@@ -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;
},