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>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-09-02 11:33:52 -05:00
1 parent 0dca779f59
commit e1b7b69261
47 files changed
+2939 -805

No files matched your search

+99 -277
View File
@@ -2,13 +2,13 @@
import * as React from "react";
import { motion } from "framer-motion";
import { ArrowLeft, Disc3, Music2 } from "lucide-react";
import { ArrowLeft, SearchX } from "lucide-react";
import { api, type Me, type Track } from "@/lib/api";
import { tint } from "@/lib/colour";
import { cn } from "@/lib/utils";
import { useNav, type Tab } from "@/lib/nav";
import { panel, useMotionOK } from "@/lib/motion";
import { Aurora } from "@/components/aurora";
import { SearchBar } from "@/components/search-bar";
import { TrackRow } from "@/components/track-row";
import { AlbumView } from "@/components/album-view";
@@ -18,32 +18,18 @@ import { Requests, type JobsSnapshot } from "@/components/requests";
import { Review } from "@/components/review";
import { DiscoverPanel } from "@/components/discover";
import { Settings } from "@/components/settings";
import { Skeletons } from "@/components/skeletons";
import { Welcome } from "@/components/welcome";
import { PlayerProvider } from "@/components/player";
import { ToastProvider } from "@/components/toast";
import { AdminPanel } from "@/components/admin/admin-panel";
type Tab =
| "search"
| "discover"
| "jobs"
| "review"
| "bulk"
| "settings"
| "admin";
type View =
| { k: "welcome" }
| { k: "results"; q: string }
| { k: "album"; id: number }
| { k: "artist"; id: number };
const EXAMPLES = [
"Radiohead Creep",
"Kendrick Lamar Money Trees",
"Fleetwood Mac Dreams",
"Tame Impala The Less I Know The Better",
"Frank Ocean Ivy",
];
import { Sidebar } from "@/components/shell/sidebar";
import { TabBar } from "@/components/shell/tabbar";
import { Toolbar } from "@/components/shell/toolbar";
import { StatusBanner } from "@/components/shell/status-banner";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { SkeletonRows } from "@/components/ui/skeleton";
export default function Page() {
return (
@@ -55,36 +41,32 @@ export default function Page() {
);
}
/** Tailwind's `md`. Decides sidebar-vs-rail, and rail-vs-bottom-bar. */
function useBreakpoint() {
const [w, setW] = React.useState(1280);
React.useEffect(() => {
const on = () => setW(window.innerWidth);
on();
window.addEventListener("resize", on);
return () => window.removeEventListener("resize", on);
}, []);
return { rail: w >= 768 && w < 1024 };
}
function Muse() {
const [tab, setTab] = React.useState<Tab>("search");
const [view, setView] = React.useState<View>({ k: "welcome" });
const [history, setHistory] = React.useState<View[]>([]);
const [nav, go] = useNav();
const { rail } = useBreakpoint();
const ok = useMotionOK();
const [results, setResults] = React.useState<Track[] | null>(null);
const [busy, setBusy] = React.useState(false);
const [me, setMe] = React.useState<Me | null>(null);
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
const go = React.useCallback((next: View) => {
setView((cur) => {
setHistory((h) => (cur.k === "welcome" ? h : [...h, cur]));
return next;
});
setTab("search");
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
const back = () => {
setHistory((h) => {
const prev = h[h.length - 1];
if (prev) setView(prev);
return h.slice(0, -1);
});
};
const doSearch = React.useCallback(
async (q: string) => {
if (!q.trim()) return;
go({ k: "results", q });
go({ tab: "search", q });
setResults(null);
setBusy(true);
try {
@@ -99,8 +81,24 @@ function Muse() {
[go],
);
const openAlbum = React.useCallback((id: number) => go({ k: "album", id }), [go]);
const openArtist = React.useCallback((id: number) => go({ k: "artist", id }), [go]);
// Re-runs the query when the URL carries one, so a shared link and a browser
// Back both restore an actual result set rather than an empty screen.
const lastQ = React.useRef<string | null>(null);
React.useEffect(() => {
if (nav.tab !== "search" || nav.view || !nav.q) return;
if (lastQ.current === nav.q) return;
lastQ.current = nav.q;
void doSearch(nav.q);
}, [nav.tab, nav.view, nav.q, doSearch]);
const openAlbum = React.useCallback(
(id: number) => go({ tab: "search", view: "album", id: String(id), q: nav.q }),
[go, nav.q],
);
const openArtist = React.useCallback(
(id: number) => go({ tab: "search", view: "artist", id: String(id), q: nav.q }),
[go, nav.q],
);
const refresh = React.useCallback(async () => {
try {
@@ -129,191 +127,64 @@ function Muse() {
(snap?.counts.searching ?? 0) +
(snap?.counts.downloading ?? 0);
const held = snap?.counts.review ?? 0;
const isAdmin = me?.role === "admin";
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
// is noise on a single-user install, which is most of them.
const quotaLeft =
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
const badges: Partial<Record<Tab, number>> = { jobs: active, review: held };
// Review appears exactly when something is waiting on you; an always-present
// tab that is empty nine days in ten is chrome.
const visible = (t: Tab) =>
t === "admin" ? !!isAdmin : t === "review" ? held > 0 || nav.tab === "review" : true;
const onBack = nav.view
? () => go({ tab: "search", q: nav.q })
: null;
return (
<>
<Aurora />
<div className="flex min-h-dvh">
<Sidebar nav={nav} go={go} badges={badges} rail={rail} visible={visible} />
<header
className="sticky top-0 z-40 border-b border-edge bg-[var(--header)] pt-3.5
backdrop-blur-[22px] backdrop-saturate-[1.7]"
>
<div className="mx-auto w-full max-w-[940px] px-4">
<div className="mb-3.5 flex items-center gap-3">
<motion.div
whileHover={{ rotate: 12, scale: 1.06 }}
transition={{ type: "spring", stiffness: 400, damping: 15 }}
className="grid h-[38px] w-[38px] shrink-0 place-items-center rounded-xl
border border-accent-line bg-accent-soft text-accent"
>
<Music2 className="h-5 w-5" />
</motion.div>
<div className="min-w-0 flex-1">
<h1 className="text-title leading-none">muse</h1>
<p className="mt-0.5 text-caption text-dim">
search · request · it appears in Navidrome
</p>
</div>
{me?.user && (
<div className="flex items-center gap-2 text-callout text-dim">
{quotaLeft !== null && (
<span
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
className={cn(
"hidden rounded-pill border px-2 py-0.5 text-caption nums sm:inline",
quotaLeft === 0
? "border-err/50 text-err"
: quotaLeft <= 5
? "border-warn/50 text-warn"
: "border-edge text-dim",
)}
>
{quotaLeft} left
</span>
)}
{me.avatar && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={me.avatar}
alt=""
className="h-6 w-6 rounded-full border border-edge"
/>
)}
<span className="hidden font-medium text-ink sm:inline">
{me.display || me.user}
</span>
{me.role && me.role !== "user" && (
<span
className="rounded-pill border border-accent-line bg-accent-soft px-1.5
py-0.5 text-caption uppercase tracking-[0.6px] text-accent"
>
{me.role}
</span>
)}
<a
href="/auth/logout"
className="ring rounded-md px-1 transition-colors hover:text-ink"
>
Log out
</a>
</div>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<Toolbar me={me} quotaLeft={quotaLeft}>
<SearchBar initial={nav.q} busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
</Toolbar>
<StatusBanner />
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
{/* A sliding pill rather than an underline: the moving surface is
the selection itself, so the eye tracks one object across the
row instead of watching two things fade. layoutId does the
interpolation, so it stays correct when tabs appear or vanish
with a role change. */}
{/* The mask is the scroll affordance: on a narrow screen the last
tab fades out at the edge instead of being clipped flat, which
is the only cue that there is more to the right. */}
<nav
className="flex gap-0.5 overflow-x-auto pb-3 pt-2.5 no-scrollbar
[mask-image:linear-gradient(to_right,#000_calc(100%-28px),transparent)]
sm:[mask-image:none]"
>
{(
[
["search", "Search", 0],
["discover", "Discover", 0],
["jobs", "Requests", active],
// Only exists when it is actionable. An always-present tab
// that is empty nine days in ten is chrome; this one appears
// exactly when something is waiting on you.
...(held > 0 || tab === "review"
? ([["review", "Review", held]] as [Tab, string, number][])
: []),
["bulk", "Import", 0],
["settings", "Settings", 0],
...(isAdmin ? ([["admin", "Admin", 0]] as [Tab, string, number][]) : []),
] as [Tab, string, number][]
).map(([k, label, badge]) => (
<button
key={k}
onClick={() => setTab(k)}
aria-current={tab === k ? "page" : undefined}
className={cn(
"ring relative shrink-0 cursor-pointer rounded-pill px-3 py-1.5",
"text-callout font-medium transition-colors duration-200",
tab === k ? "text-ink" : "text-dim hover:text-ink",
)}
>
{tab === k && (
<motion.span
layoutId="tab-pill"
className="absolute inset-0 rounded-pill border border-edge-hi bg-[var(--glass-hi)]"
transition={{ type: "spring", stiffness: 460, damping: 38 }}
/>
)}
<span className="relative flex items-center gap-1.5">
{label}
{badge > 0 && (
<span
className={cn(
"min-w-[17px] rounded-pill px-1 text-caption font-bold nums",
k === "review"
? "bg-warn/20 text-warn"
: "bg-accent-soft text-accent",
)}
>
{badge}
</span>
)}
</span>
</button>
))}
</nav>
</div>
</header>
<main className="mx-auto w-full max-w-[940px] px-4 pb-32 pt-5">
{/* Keyed on `tab`, so React remounts and Framer replays the entrance.
Deliberately NOT wrapped in AnimatePresence mode="wait": that holds
the incoming panel until the outgoing one's exit animation reports
finished, which makes navigation depend on an animation completing.
Anything that stalls the frame loop — a backgrounded tab, a browser
that throttles rAF — then leaves the UI stuck on the old panel with
no way forward. A cosmetic transition should never gate navigation. */}
<motion.div
key={tab}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22 }}
>
{tab === "search" && (
<main className="mx-auto w-full max-w-[var(--container-3xl)] flex-1 px-4 pb-28 pt-5 md:pb-16">
<motion.div key={nav.tab + (nav.view ?? "") + (nav.id ?? "")} {...panel(ok)}>
{nav.tab === "search" && (
<>
{history.length > 0 && (
<motion.button
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
onClick={back}
className="glass mb-3.5 flex cursor-pointer items-center gap-1.5 rounded-full
px-3 py-1.5 text-[13px] text-dim transition hover:border-edge-hi hover:text-ink"
>
{onBack && (
<Button variant="quiet" size="sm" onClick={onBack} className="mb-3 -ml-2">
<ArrowLeft className="h-3.5 w-3.5" /> Back
</motion.button>
</Button>
)}
{view.k === "welcome" && <Welcome onPick={doSearch} />}
{nav.view === "album" && nav.id && (
<AlbumView id={Number(nav.id)} onArtist={openArtist} onQueued={refresh} />
)}
{nav.view === "artist" && nav.id && (
<ArtistView id={Number(nav.id)} onAlbum={openAlbum} onQueued={refresh} />
)}
{view.k === "results" &&
{!nav.view && !nav.q && <Welcome onPick={doSearch} recent={snap?.jobs ?? []} />}
{!nav.view &&
nav.q &&
(results === null ? (
<Skeletons />
<SkeletonRows n={6} />
) : results.length === 0 ? (
<div className="py-12 text-center text-dim">
No matches. Try adding the artist name.
</div>
<EmptyState
icon={SearchX}
title="No matches"
body="Try adding the artist name, or fewer words."
/>
) : (
<motion.div layout className="flex flex-col gap-[7px]">
<div className="flex flex-col gap-row">
{results.map((t, i) => (
<TrackRow
key={t.deezer_id}
@@ -324,71 +195,22 @@ function Muse() {
onQueued={refresh}
/>
))}
</motion.div>
</div>
))}
{view.k === "album" && (
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
)}
{view.k === "artist" && (
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
)}
</>
)}
{tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
{tab === "review" && <Review onDecided={refresh} />}
{tab === "bulk" && <BulkImport onQueued={refresh} />}
{tab === "settings" && me && <Settings me={me} refresh={refresh} />}
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
</motion.div>
</main>
</>
);
}
function Welcome({ onPick }: { onPick: (q: string) => void }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 260, damping: 28 }}
className="px-5 py-14 text-center"
>
<motion.div
animate={{ y: [0, -7, 0] }}
transition={{ duration: 4.5, repeat: Infinity, ease: "easeInOut" }}
className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-[20px]
border border-accent-line bg-accent-soft text-accent"
>
<Disc3 className="h-7 w-7" />
</motion.div>
<h2 className="mb-2 text-display">Find anything</h2>
<p className="mx-auto max-w-[46ch] text-body text-dim">
Search a song and press{" "}
<kbd className="rounded-md border border-edge-hi bg-[var(--glass-hi)] px-1.5 py-0.5 text-caption text-ink">
Enter
</kbd>
. Hit play to hear a 30-second preview before you commit, then <b>Get</b> to pull it into
your library, tagged.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
{EXAMPLES.map((e, i) => (
<motion.button
key={e}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + i * 0.05 }}
whileHover={{ y: -2 }}
onClick={() => onPick(e)}
className="glass ring cursor-pointer rounded-pill px-3.5 py-1.5 text-callout text-dim
transition hover:border-accent-line hover:bg-accent-soft hover:text-ink"
>
{e}
</motion.button>
))}
{nav.tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
{nav.tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
{nav.tab === "review" && <Review onDecided={refresh} />}
{nav.tab === "bulk" && <BulkImport onQueued={refresh} />}
{nav.tab === "settings" && me && <Settings me={me} refresh={refresh} />}
{nav.tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
</motion.div>
</main>
</div>
</motion.div>
<TabBar nav={nav} go={go} badges={badges} visible={visible} />
</div>
);
}