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
+103
-110
@@ -2,9 +2,14 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { api, type Job } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Inbox, ChevronRight } from "lucide-react";
|
||||
import { type Job } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { JobSheet } from "@/components/job-sheet";
|
||||
import { useMotionOK, spring } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type JobsSnapshot = {
|
||||
@@ -13,142 +18,130 @@ export type JobsSnapshot = {
|
||||
pending: number;
|
||||
};
|
||||
|
||||
const PILL: Record<Job["state"], string> = {
|
||||
imported: "text-ok border-ok/40 bg-ok/10",
|
||||
failed: "text-err border-err/40 bg-err/10",
|
||||
// Held for review, not in flight and not finished — it needs a person, so
|
||||
// it reads as the accent rather than as another in-progress amber.
|
||||
review: "text-accent border-accent-line bg-accent-soft",
|
||||
downloading: "text-warn border-warn/40 bg-warn/10",
|
||||
searching: "text-warn border-warn/40 bg-warn/10",
|
||||
queued: "text-warn border-warn/40 bg-warn/10",
|
||||
/**
|
||||
* `queued`, `searching` and `downloading` used to be the same amber pill and
|
||||
* differed only by the word inside, so the three stages of a request looked
|
||||
* identical at a glance. They are distinct stages with distinct waits, so they
|
||||
* now read as distinct.
|
||||
*/
|
||||
const STATE: Record<Job["state"], { tone: "neutral" | "accent" | "ok" | "warn" | "err"; label: string; live?: boolean }> = {
|
||||
queued: { tone: "neutral", label: "Queued" },
|
||||
searching: { tone: "accent", label: "Searching", live: true },
|
||||
downloading: { tone: "warn", label: "Downloading", live: true },
|
||||
review: { tone: "accent", label: "Needs review" },
|
||||
imported: { tone: "ok", label: "In library" },
|
||||
failed: { tone: "err", label: "Failed" },
|
||||
};
|
||||
|
||||
export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh: () => void }) {
|
||||
const toast = useToast();
|
||||
const ok = useMotionOK();
|
||||
const [open, setOpen] = React.useState<string | null>(null);
|
||||
if (!snap) return null;
|
||||
|
||||
const { jobs, counts, pending } = snap;
|
||||
const { jobs, counts } = snap;
|
||||
const active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
|
||||
const done = counts.imported ?? 0;
|
||||
const failed = counts.failed ?? 0;
|
||||
const held = counts.review ?? 0;
|
||||
const total = active + done;
|
||||
|
||||
const held = counts.review ?? 0;
|
||||
const stats: [string, number, string][] = [
|
||||
["In flight", active, "text-accent"],
|
||||
["Imported", done, "text-ok"],
|
||||
["Held", held, held > 0 ? "text-warn" : ""],
|
||||
["Failed", failed, "text-err"],
|
||||
["In library", done, "text-ok"],
|
||||
["Needs review", held, held > 0 ? "text-warn" : "text-dim"],
|
||||
["Failed", failed, failed > 0 ? "text-err" : "text-dim"],
|
||||
];
|
||||
|
||||
// Kept in sync with the poll so the sheet updates live while it is open.
|
||||
const current = jobs.find((j) => j.id === open) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
<div className="mb-4 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
{stats.map(([label, n, colour]) => (
|
||||
<motion.div key={label} layout className="glass rounded-glass p-3.5">
|
||||
<motion.b
|
||||
key={n}
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn("block text-title leading-none nums", colour)}
|
||||
>
|
||||
{n}
|
||||
</motion.b>
|
||||
<span className="mt-1 block text-caption uppercase tracking-[0.6px] text-dim">
|
||||
<Card key={label} pad="sm">
|
||||
<b className={cn("block text-title leading-none nums", colour)}>{n}</b>
|
||||
<span className="mt-1 block text-caption uppercase tracking-[0.06em] text-mute">
|
||||
{label}
|
||||
</span>
|
||||
</motion.div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{active > 0 && total > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 4 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-4 overflow-hidden rounded-full bg-faint"
|
||||
>
|
||||
<motion.i
|
||||
className="block h-1 rounded-full bg-accent"
|
||||
animate={{ width: `${(done / total) * 100}%` }}
|
||||
transition={{ ease: "easeOut", duration: 0.5 }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{active > 0 && total > 0 && (
|
||||
<Progress
|
||||
value={(done / total) * 100}
|
||||
className="mb-4"
|
||||
label="Requests completed"
|
||||
/>
|
||||
)}
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="py-12 text-center text-dim">Nothing requested yet.</div>
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="Nothing requested yet"
|
||||
body="Search for a song and press Get. Anything you ask for shows up here with its progress."
|
||||
/>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<div className="flex flex-col gap-row">
|
||||
<AnimatePresence initial={false}>
|
||||
{jobs.map((j) => (
|
||||
<motion.div
|
||||
key={j.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 34 }}
|
||||
className="glass flex items-center gap-3 rounded-glass py-2.5 pl-2.5 pr-3"
|
||||
>
|
||||
<div className="h-[50px] w-[50px] shrink-0 overflow-hidden rounded-[9px] bg-faint">
|
||||
{j.cover && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={j.cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-body font-semibold clamp1">{j.title}</div>
|
||||
<div className="mt-px text-callout text-dim clamp1">
|
||||
{[j.artist, j.album, j.detail].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{j.state === "failed" && (
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await api.retry(j.id);
|
||||
toast("Requeued");
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1",
|
||||
"text-caption font-semibold capitalize",
|
||||
PILL[j.state] ?? "text-dim border-edge",
|
||||
)}
|
||||
{jobs.map((j) => {
|
||||
const s = STATE[j.state] ?? STATE.queued;
|
||||
return (
|
||||
<motion.button
|
||||
key={j.id}
|
||||
layout={ok}
|
||||
initial={ok ? { opacity: 0, y: 10 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={ok ? { opacity: 0, scale: 0.97 } : { opacity: 0 }}
|
||||
transition={spring}
|
||||
onClick={() => setOpen(j.id)}
|
||||
aria-label={`${j.title} by ${j.artist} — ${s.label}. Show details`}
|
||||
className="card focus-ring flex items-center gap-3 rounded-card py-2.5 pl-2.5 pr-2
|
||||
text-left transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
{(j.state === "searching" || j.state === "downloading") && (
|
||||
<motion.span
|
||||
className="mr-1.5 inline-block h-[5px] w-[5px] rounded-full bg-current align-[1px]"
|
||||
animate={{ opacity: [1, 0.25, 1] }}
|
||||
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
{j.state}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
<div className="h-[48px] w-[48px] shrink-0 overflow-hidden rounded-control bg-[var(--surface-3)]">
|
||||
{j.cover && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={j.cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-body font-semibold clamp1">{j.title}</div>
|
||||
<div className="mt-px text-callout text-dim clamp1">
|
||||
{[j.artist, j.album].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{j.detail && j.state === "failed" && (
|
||||
<div className="mt-0.5 text-caption text-err clamp1">{j.detail}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge tone={s.tone} className="shrink-0">
|
||||
{s.live && (
|
||||
<motion.span
|
||||
className="h-[5px] w-[5px] rounded-full bg-current"
|
||||
animate={ok ? { opacity: [1, 0.25, 1] } : {}}
|
||||
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
{s.label}
|
||||
</Badge>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-mute" />
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JobSheet job={current} onClose={() => setOpen(null)} onChanged={refresh} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user