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:
1 parent
0dca779f59
commit
e1b7b69261
47 files changed
+2939
-805
No files matched your search
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Search,
|
||||
Sparkles,
|
||||
ListMusic,
|
||||
ShieldCheck,
|
||||
ClipboardList,
|
||||
Settings2,
|
||||
Gauge,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { Tab } from "@/lib/nav";
|
||||
|
||||
export type NavItem = {
|
||||
tab: Tab;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: "Library" | "Activity" | "Manage";
|
||||
};
|
||||
|
||||
/**
|
||||
* Grouped, because a flat list of seven is a menu and a grouped list of seven
|
||||
* is a structure. The order is the order of use: you search, you watch what
|
||||
* you asked for, you administer occasionally.
|
||||
*/
|
||||
export const NAV: NavItem[] = [
|
||||
{ tab: "search", label: "Search", icon: Search, group: "Library" },
|
||||
{ tab: "discover", label: "Discover", icon: Sparkles, group: "Library" },
|
||||
{ tab: "jobs", label: "Requests", icon: ListMusic, group: "Activity" },
|
||||
{ tab: "review", label: "Review", icon: ShieldCheck, group: "Activity" },
|
||||
{ tab: "bulk", label: "Import", icon: ClipboardList, group: "Manage" },
|
||||
{ tab: "settings", label: "Settings", icon: Settings2, group: "Manage" },
|
||||
{ tab: "admin", label: "Admin", icon: Gauge, group: "Manage" },
|
||||
];
|
||||
|
||||
/** The five that earn a slot on a phone's bottom bar. */
|
||||
export const MOBILE: Tab[] = ["search", "discover", "jobs", "review", "settings"];
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Music2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { href, type Nav, type Tab } from "@/lib/nav";
|
||||
import { useMotionOK, spring } from "@/lib/motion";
|
||||
import { NAV } from "./nav-items";
|
||||
|
||||
/**
|
||||
* Sidebar navigation, the pattern Apple uses in Music, Mail, Finder and
|
||||
* Settings. It replaces a horizontally scrolling row of pills, which is a
|
||||
* mobile-web affordance that had been left on the desktop layout too.
|
||||
*
|
||||
* Collapses to a 68px icon rail between 768 and 1024, where a 240px sidebar
|
||||
* would eat a third of the window.
|
||||
*/
|
||||
export function Sidebar({
|
||||
nav,
|
||||
go,
|
||||
badges,
|
||||
rail,
|
||||
visible,
|
||||
}: {
|
||||
nav: Nav;
|
||||
go: (n: Nav) => void;
|
||||
badges: Partial<Record<Tab, number>>;
|
||||
rail: boolean;
|
||||
visible: (t: Tab) => boolean;
|
||||
}) {
|
||||
const ok = useMotionOK();
|
||||
const groups = ["Library", "Activity", "Manage"] as const;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className={cn(
|
||||
"material sticky top-0 hidden h-dvh shrink-0 flex-col gap-1 border-r border-edge",
|
||||
"md:flex",
|
||||
rail ? "w-[var(--rail-w)] items-center px-2 py-3" : "w-[var(--sidebar-w)] px-3 py-3",
|
||||
)}
|
||||
>
|
||||
<div className={cn("mb-4 flex items-center gap-2.5", rail ? "justify-center" : "px-2")}>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-card bg-accent text-[var(--accent-ink)]">
|
||||
<Music2 className="h-4 w-4" />
|
||||
</span>
|
||||
{!rail && <span className="text-headline tracking-tight">muse</span>}
|
||||
</div>
|
||||
|
||||
{groups.map((g) => {
|
||||
const items = NAV.filter((n) => n.group === g && visible(n.tab));
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={g} className="mb-2 w-full">
|
||||
{!rail && (
|
||||
<h2 className="mb-1 px-2 text-caption font-semibold uppercase tracking-[0.06em] text-mute">
|
||||
{g}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{items.map(({ tab, label, icon: Icon }) => {
|
||||
const on = nav.tab === tab;
|
||||
const badge = badges[tab] ?? 0;
|
||||
return (
|
||||
<li key={tab}>
|
||||
<a
|
||||
href={href({ tab })}
|
||||
aria-current={on ? "page" : undefined}
|
||||
title={rail ? label : undefined}
|
||||
onClick={(e) => {
|
||||
// Left-click navigates in place; cmd/ctrl-click still
|
||||
// opens a real new tab, because these are real links.
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
go({ tab });
|
||||
}}
|
||||
className={cn(
|
||||
"focus-ring relative flex items-center rounded-control text-callout",
|
||||
"font-medium transition-colors duration-150",
|
||||
rail ? "h-9 w-9 justify-center" : "gap-2.5 px-2 py-1.5",
|
||||
on ? "text-ink" : "text-dim hover:bg-[var(--faint)] hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{on && (
|
||||
<motion.span
|
||||
layoutId="nav-selection"
|
||||
transition={ok ? spring : { duration: 0 }}
|
||||
className="absolute inset-0 rounded-control bg-[var(--surface-3)]"
|
||||
/>
|
||||
)}
|
||||
<Icon className="relative h-4 w-4 shrink-0" />
|
||||
{!rail && <span className="relative flex-1 clamp1">{label}</span>}
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-pill text-caption font-bold nums",
|
||||
rail
|
||||
? "absolute -right-1 -top-1 h-4 min-w-4 px-1 text-center leading-4"
|
||||
: "relative px-1.5",
|
||||
tab === "review"
|
||||
? "bg-warn/20 text-warn"
|
||||
: "bg-accent-soft text-accent",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AlertTriangle, WifiOff } from "lucide-react";
|
||||
import { api, type Health } from "@/lib/api";
|
||||
import { useMotionOK } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* System health, for everyone.
|
||||
*
|
||||
* This check already existed but lived inside the admin panel, so a regular
|
||||
* user whose every request was failing saw nothing but a growing pile of red
|
||||
* pills. "Reachable but not logged in" is the important case: the container is
|
||||
* up and healthy, so no other signal reports a problem, yet every search
|
||||
* returns nothing.
|
||||
*/
|
||||
export function StatusBanner() {
|
||||
const [h, setH] = React.useState<Health | null>(null);
|
||||
const ok = useMotionOK();
|
||||
|
||||
React.useEffect(() => {
|
||||
let live = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const r = await api.health();
|
||||
if (live) setH(r);
|
||||
} catch {
|
||||
/* a 401 here just means the poll outlived the session */
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const t = setInterval(poll, 20000);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const problem = !h
|
||||
? null
|
||||
: h.slskd !== 200
|
||||
? {
|
||||
icon: WifiOff,
|
||||
text: "Can't reach slskd — nothing will download until it's back.",
|
||||
}
|
||||
: h.logged_in === false
|
||||
? {
|
||||
icon: AlertTriangle,
|
||||
text: "slskd is running but not signed in to Soulseek — searches will return nothing.",
|
||||
}
|
||||
: h.library_writable === false
|
||||
? { icon: AlertTriangle, text: "The music library isn't writable — imports will fail." }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{problem && (
|
||||
<motion.div
|
||||
role="status"
|
||||
initial={ok ? { height: 0, opacity: 0 } : false}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={ok ? { height: 0, opacity: 0 } : { opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-warn/30 bg-warn/10 px-4 py-2 text-callout text-warn">
|
||||
<problem.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="clamp1">{problem.text}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { href, type Nav, type Tab } from "@/lib/nav";
|
||||
import { NAV, MOBILE } from "./nav-items";
|
||||
|
||||
/**
|
||||
* The phone layout. A bottom bar is what a native app does, and it puts the
|
||||
* targets under the thumb instead of at the top of a tall screen. Respects the
|
||||
* home-indicator inset, which the previous layout did not.
|
||||
*/
|
||||
export function TabBar({
|
||||
nav,
|
||||
go,
|
||||
badges,
|
||||
visible,
|
||||
}: {
|
||||
nav: Nav;
|
||||
go: (n: Nav) => void;
|
||||
badges: Partial<Record<Tab, number>>;
|
||||
visible: (t: Tab) => boolean;
|
||||
}) {
|
||||
const items = NAV.filter((n) => MOBILE.includes(n.tab) && visible(n.tab));
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className="material-hi fixed inset-x-0 bottom-0 z-40 flex border-t border-edge
|
||||
pb-[env(safe-area-inset-bottom)] md:hidden"
|
||||
>
|
||||
{items.map(({ tab, label, icon: Icon }) => {
|
||||
const on = nav.tab === tab;
|
||||
const badge = badges[tab] ?? 0;
|
||||
return (
|
||||
<a
|
||||
key={tab}
|
||||
href={href({ tab })}
|
||||
aria-current={on ? "page" : undefined}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
go({ tab });
|
||||
}}
|
||||
className={cn(
|
||||
"focus-ring relative flex flex-1 flex-col items-center gap-0.5 py-2",
|
||||
"text-caption font-medium transition-colors",
|
||||
on ? "text-accent" : "text-mute",
|
||||
)}
|
||||
>
|
||||
<span className="relative">
|
||||
<Icon className="h-5 w-5" />
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-2 -top-1 min-w-[15px] rounded-pill px-1 text-[9px]",
|
||||
"font-bold leading-[15px] nums",
|
||||
tab === "review" ? "bg-warn text-black" : "bg-accent text-[var(--accent-ink)]",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Me } from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/**
|
||||
* Sticky chrome over scrolling content: search on the left, identity on the
|
||||
* right. Everything that used to be hidden below `sm:` — the quota chip, the
|
||||
* display name — now degrades into the overflow rather than vanishing, since
|
||||
* "how many requests do I have left" matters most on the device you are
|
||||
* actually queueing from.
|
||||
*/
|
||||
export function Toolbar({
|
||||
me,
|
||||
quotaLeft,
|
||||
children,
|
||||
}: {
|
||||
me: Me | null;
|
||||
quotaLeft: number | null;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="material sticky top-0 z-30 border-b border-edge">
|
||||
<div className="mx-auto flex w-full max-w-[var(--container-3xl)] items-center gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
|
||||
{me?.user && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{quotaLeft !== null && (
|
||||
<Badge
|
||||
tone={quotaLeft === 0 ? "err" : quotaLeft <= 5 ? "warn" : "neutral"}
|
||||
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
||||
className="nums"
|
||||
>
|
||||
{quotaLeft} left
|
||||
</Badge>
|
||||
)}
|
||||
{me.role && me.role !== "user" && (
|
||||
<Badge tone="accent" className="hidden uppercase tracking-[0.06em] sm:inline-flex">
|
||||
{me.role}
|
||||
</Badge>
|
||||
)}
|
||||
{me.avatar && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={me.avatar}
|
||||
alt=""
|
||||
className="h-7 w-7 rounded-full border border-edge object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="hidden max-w-[14ch] text-callout font-medium clamp1 lg:inline">
|
||||
{me.display || me.user}
|
||||
</span>
|
||||
<a
|
||||
href="/auth/logout"
|
||||
title="Log out"
|
||||
aria-label="Log out"
|
||||
className={cn(
|
||||
"focus-ring grid h-8 w-8 place-items-center rounded-control text-dim",
|
||||
"transition-colors hover:bg-[var(--faint)] hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user