Add queue-based gradual discovery with shuffled call ordering and progress UI

Each yt-dlp call is now an independent task (one search query, one trending
fetch, one graph channel fetch). Tasks are shuffled together so we don't fire
10 searches in a row, then enqueued with 30-90s random gaps between them —
a full sweep of ~17 tasks completes in roughly 10-25 minutes instead of
hammering YouTube with 21 calls back-to-back.

Fast signals (community, category clusters) still run synchronously at
schedule time since they're pure SQL.

Progress is tracked per-user (total/done/running) and exposed on
GET /api/discovery/status. The Discovery page polls every 10s while
running and shows a progress bar + "Finding channels… X / Y" in the header.
The auto-discovery daemon skips scheduling if a manual sweep is already running.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 02:28:35 +02:00
parent e6faf8e08e
commit a535e9f22a
4 changed files with 367 additions and 44 deletions

View File

@@ -217,7 +217,8 @@ export default function DiscoveryPage() {
const { data: discStatus } = useQuery({
queryKey: ["discovery-status"],
queryFn: () => getDiscoveryStatus().then(r => r.data),
staleTime: 60_000,
staleTime: 10_000,
refetchInterval: (query) => query.state.data?.progress?.running ? 10_000 : 60_000,
});
const refreshMut = useMutation({
@@ -247,37 +248,52 @@ export default function DiscoveryPage() {
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<h1 className="font-display font-bold text-2xl text-zinc-100">Discover</h1>
{discStatus && (
<p className="text-xs text-zinc-500 mt-0.5">
{discStatus.pending_count > 0
? `${discStatus.pending_count} channel${discStatus.pending_count !== 1 ? "s" : ""} queued`
: "Queue empty"}
{discStatus.progress?.running
? `Finding channels… ${discStatus.progress.done} / ${discStatus.progress.total}`
: discStatus.pending_count > 0
? `${discStatus.pending_count} channel${discStatus.pending_count !== 1 ? "s" : ""} queued`
: "Queue empty"}
{discStatus.last_run
? ` · last refreshed ${new Date(discStatus.last_run + "Z").toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`
: " · never refreshed"}
</p>
)}
{discStatus?.progress?.running && (
<div className="mt-1.5 h-1 w-48 rounded-full bg-zinc-800 overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-500"
style={{ width: `${Math.round((discStatus.progress.done / discStatus.progress.total) * 100)}%` }}
/>
</div>
)}
</div>
<button
onClick={() => refreshMut.mutate()}
disabled={refreshMut.isPending || refreshMut.isSuccess}
disabled={refreshMut.isPending || discStatus?.progress?.running}
className="shrink-0 flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60"
>
{refreshMut.isPending && (
{discStatus?.progress?.running ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
)}
{refreshMut.isSuccess ? "Queued ✓" : refreshMut.isPending ? "Queueing…" : "Find more"}
) : refreshMut.isPending ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : null}
{discStatus?.progress?.running ? "Running…" : refreshMut.isSuccess ? "Queued ✓" : refreshMut.isPending ? "Queueing…" : "Find more"}
</button>
</div>
{refreshMut.isSuccess && (
{(refreshMut.isSuccess || discStatus?.progress?.running) && (
<div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-400">
Discovery is running in the background it searches YouTube using your tags and interests and takes a few minutes. New channels will appear when it finishes. It also runs automatically every day.
Discovery is running in the background searches and channel fetches are spaced out over ~20 minutes to avoid hitting limits. New channels appear as each batch completes. Runs automatically every day.
</div>
)}