Files
muse/web/components/discover.tsx
T
Aryan SinghandClaude Opus 5 171e117f7c Recover blocked searches, fall through to the next peer, rebuild the UI
Measured against a live instance: 47 failed jobs against 33 imported. Three
independent causes, all of which ended the request.

The Soulseek server filters some artist names. "Michael Jackson Billie Jean"
and bare "Michael Jackson" both return zero responses; "Billie Jean" returns
1083 files. The Weeknd, Kanye West and Frank Ocean behave the same way. Since
pick_and_enqueue sent exactly one query of `artist title`, every request for a
filtered artist failed reporting "searched 0 files" -- 28 of 29 occurrences.
search_queries() now walks a ladder, loosest last, ending at the bare title,
and escalates only when a search underdelivers so the common case still costs
one search. Dropping the artist from the query does not mean ignoring it:
match_ratio() looks for it in the folder path, where an organised share puts it.

A rejected transfer ended the request. pick_and_enqueue kept a running maximum
and discarded every runner-up, so "Transfer rejected: File not shared." -- a
per-file condition where candidate two almost always works -- was terminal, as
were timeouts and slskd's 500 on an unreachable peer. The ranked list is now
persisted on the job row, ordered to put a different peer next, and
try_next_candidate() advances through it. A job fails only once the list is
exhausted, and then reports every peer it tried and what each one said.

norm() destroyed every non-ASCII character, so "Beyonce" never matched
Beyonce, nor Bjork Bjork, nor Dont Don't. It now folds accents and drops
apostrophes while leaving & as a separator.

Also: availability outranks format in score(), because a FLAC behind 5000
queued users used to beat an immediately available MP3; transfers that stall or
that slskd forgets are abandoned instead of sitting in `downloading` forever;
searches are deleted only once finished, since deleting one mid-flight makes
slskd throw an optimistic-concurrency error and lose the record; a failed
search POST is retried rather than failing the job on one blip.

The UI moves to a sidebar shell -- rail at tablet, bottom bar on phones -- with
neutral chrome and the sampled artwork colour confined to hero areas rather
than repainting the whole interface. Adds URL routing, a primitive set under
components/ui, and a request sheet showing the attempt ladder. Fixes
hover:bg-glass-hi, which was never a valid class, so track rows and the search
field had no hover or focus state at all; a reduced-motion path that could
strand elements at opacity 0; the login page's hardcoded palette; and the
missing 404 and error boundaries.

tests/test_acquisition.py covers this path against a faked slskd. It had none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:29:59 -05:00

211 lines
7.1 KiB
TypeScript

"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { CalendarDays, Compass, Sparkles, User } from "lucide-react";
import { api, bytes, type Discover as DiscoverData, type Recap } from "@/lib/api";
import { SkeletonGrid } from "@/components/ui/skeleton";
/**
* Recommendations and a personal recap, both derived from muse's OWN request
* history plus Deezer's related-artists graph.
*
* Deliberately not built on listening data: Navidrome owns play counts,
* favourites and history, and a second source of truth for those would
* disagree with the one the user actually listens through.
*/
export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }) {
const [d, setD] = React.useState<DiscoverData | null>(null);
const [r, setR] = React.useState<Recap | null>(null);
React.useEffect(() => {
api.discover().then(setD).catch(() => setD({ seeds: [], artists: [] }));
api.recap().then(setR).catch(() => undefined);
}, []);
if (!d) return <SkeletonGrid n={8} />;
const empty = d.artists.length === 0 && (!r || r.tracks === 0);
if (empty)
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="px-5 py-16 text-center"
>
<div
className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px]
border border-accent-line bg-accent-soft text-accent"
>
<Compass className="h-6 w-6" />
</div>
<h2 className="text-title">Nothing to go on yet</h2>
<p className="mx-auto mt-1.5 max-w-[46ch] text-callout text-dim">
Recommendations are built from what you&rsquo;ve actually added. Request a few
tracks and this fills in.
</p>
</motion.div>
);
return (
<div className="flex flex-col gap-5">
{r && r.tracks > 0 && <RecapPanel r={r} />}
{d.artists.length > 0 && (
<section>
<SectionHead
icon={<Sparkles className="h-3.5 w-3.5" />}
title="Because you added"
sub={d.seeds.slice(0, 3).join(" · ")}
/>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6">
{d.artists.map((a, i) => (
<motion.button
key={a.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(i * 0.03, 0.3) }}
onClick={() => onArtist(a.name)}
className="card lift focus-ring group cursor-pointer rounded-panel p-3 text-center"
>
{a.picture ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={a.picture}
alt=""
className="mx-auto mb-2.5 h-20 w-20 rounded-full border border-edge object-cover"
/>
) : (
<div className="mx-auto mb-2.5 grid h-20 w-20 place-items-center rounded-full bg-faint text-dim">
<User className="h-7 w-7" />
</div>
)}
<div className="text-callout font-semibold clamp1">{a.name}</div>
{a.fans ? (
<div className="text-caption text-dim nums">
{a.fans.toLocaleString()} fans
</div>
) : null}
</motion.button>
))}
</div>
</section>
)}
</div>
);
}
function RecapPanel({ r }: { r: Recap }) {
const maxDecade = Math.max(1, ...r.decades.map((d) => d.n));
return (
<section>
<SectionHead
icon={<CalendarDays className="h-3.5 w-3.5" />}
title="Your year in muse"
sub={`${r.tracks} tracks · ${bytes(r.bytes)}`}
/>
<div className="grid gap-2.5 md:grid-cols-3">
<Card title="Most requested">
{r.artists.slice(0, 5).map((a, i) => (
<Row key={a.name} i={i} label={a.name} n={a.n} />
))}
</Card>
<Card title="Genres">
{r.genres.slice(0, 5).map((g, i) => (
<Row key={g.name} i={i} label={g.name} n={g.n} />
))}
{r.genres.length === 0 && <Muted>No genre data yet.</Muted>}
</Card>
<Card title="Decades">
{r.decades.length === 0 && <Muted>No release years yet.</Muted>}
{r.decades.map((d, i) => (
<motion.div
key={d.decade}
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.04 }}
className="flex items-center gap-2.5"
>
<span className="w-12 shrink-0 text-callout text-dim nums">{d.decade}</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-pill bg-faint">
<motion.i
className="block h-full rounded-pill bg-accent"
initial={{ width: 0 }}
animate={{ width: `${(d.n / maxDecade) * 100}%` }}
transition={{ delay: i * 0.04, duration: 0.5, ease: "easeOut" }}
/>
</div>
<span className="w-6 shrink-0 text-right text-caption text-dim nums">
{d.n}
</span>
</motion.div>
))}
{r.busiest_day && (
<div className="mt-1 border-t border-edge pt-2 text-caption text-dim">
Busiest day {r.busiest_day.date} — {r.busiest_day.n} tracks
</div>
)}
</Card>
</div>
</section>
);
}
function SectionHead({
icon,
title,
sub,
}: {
icon: React.ReactNode;
title: string;
sub?: string;
}) {
return (
<div className="mb-2.5 flex items-baseline gap-2">
<span className="translate-y-[1px] text-accent">{icon}</span>
<h2 className="text-headline">{title}</h2>
{sub && <span className="text-caption text-dim clamp1">{sub}</span>}
</div>
);
}
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="card rounded-panel p-4"
>
<h3 className="mb-2.5 text-caption font-bold uppercase tracking-[0.9px] text-dim">
{title}
</h3>
<div className="flex flex-col gap-2">{children}</div>
</motion.div>
);
}
function Row({ i, label, n }: { i: number; label: string; n: number }) {
return (
<motion.div
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.04 }}
className="flex items-center gap-2"
>
<span
className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-faint
text-caption text-dim nums"
>
{i + 1}
</span>
<span className="min-w-0 flex-1 text-callout clamp1">{label}</span>
<span className="text-caption text-dim nums">{n}</span>
</motion.div>
);
}
function Muted({ children }: { children: React.ReactNode }) {
return <span className="text-callout text-dim">{children}</span>;
}