"use client"; import { useEffect, useState } from "react"; import { describeJob, etaLabel, timeAgo } from "./_ui/status"; import { StatTiles } from "./_ui/stat-tiles"; import { SectionHeader } from "./_ui/section-header"; import { JobRow } from "./_ui/job-row"; import { ProgressBar } from "./_ui/progress-bar"; import { PressedList } from "./_ui/pressed-list"; import { KebabMenu } from "./_ui/kebab-menu"; import { toast } from "./_ui/toast"; type Tab = "active" | "queue" | "errors"; // Split the on-the-press rows into three honest buckets so active presses aren't buried between // queued items and errors. Active = being worked (searching/downloading/finishing), Queue = // waiting to be claimed, Errors = needs attention. function categoryOf(state: string, stage: string): Tab { const kind = describeJob(state, stage).kind; if (kind === "attention") return "errors"; if (state === "requested") return "queue"; return "active"; } const TAB_LABEL: Record = { active: "Active", queue: "In queue", errors: "Errors" }; const TAB_EMPTY: Record = { active: "Nothing pressing right now.", queue: "The queue is empty.", errors: "No errors — everything's flowing.", }; type Row = { id: string; artist: string; album: string; status: string; createdAt?: string; rgMbid?: string | null; job: { state: string; currentStage: string; attempts: number; error: string | null; claimedAt: string | null; updatedAt: string | null; candidateCount: number; chosen: { source: string; format: string; trackCount: number } | null; downloadProgress: number; downloadEtaSeconds: number | null; paused: boolean; } | null; }; function jobOf(r: Row) { return ( r.job ?? { state: "requested", currentStage: "intake", attempts: 0, error: null, claimedAt: null, updatedAt: null, candidateCount: 0, chosen: null, downloadProgress: 0, downloadEtaSeconds: null, paused: false, } ); } function pressedMark(r: Row): string { const when = r.job?.updatedAt ?? r.createdAt; // when it was pressed, not when it was requested if (!when) return "Lyra"; const d = new Date(when); return `Lyra · ${d.toLocaleDateString("en-GB", { day: "2-digit", month: "short" }).toUpperCase()}`; } export function Queue() { const [rows, setRows] = useState([]); const [wanted, setWanted] = useState(0); const [watching, setWatching] = useState(0); const [artist, setArtist] = useState(""); const [album, setAlbum] = useState(""); const [paused, setPaused] = useState(false); // null = follow the smart default (Active if anything's active, else Queue, else Errors); once // the user picks a tab, their choice sticks. const [tab, setTab] = useState(null); async function refresh() { const [reqRes, wantRes, artRes, floorRes] = await Promise.all([ fetch("/api/requests"), fetch("/api/wanted"), fetch("/api/artists"), fetch("/api/floor"), ]); setRows((await reqRes.json()).requests ?? []); setWanted(((await wantRes.json()).wanted ?? []).length); setWatching(((await artRes.json()).artists ?? []).length); setPaused((await floorRes.json()).paused ?? false); } useEffect(() => { refresh(); const id = setInterval(refresh, 2000); return () => clearInterval(id); }, []); async function retry(id: string) { const res = await fetch(`/api/requests/${id}/retry`, { method: "POST" }).catch(() => null); if (!res?.ok) { toast("Couldn't retry"); return; } toast("Retrying…"); refresh(); } async function floorAction(action: string, note: string) { const res = await fetch("/api/floor", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action }), }).catch(() => null); if (!res?.ok) { toast("Couldn't update the press"); return; } toast(note); refresh(); } async function clearPress() { const queued = rows.filter((r) => jobOf(r).state === "requested").length; if (queued === 0) return; if (!confirm(`Clear ${queued} queued item${queued === 1 ? "" : "s"} from the press?`)) return; await floorAction("clear", "Cleared the queue"); } async function stopPress() { if (!confirm("Stop the current download and pause the press? The worker restarts and the aborted album re-downloads when you resume.")) return; await floorAction("stop", "Stopping the press…"); } async function pauseItem(id: string, next: boolean) { const res = await fetch(`/api/requests/${id}/pause`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ paused: next }), }).catch(() => null); if (!res?.ok) { toast("Couldn't update the item"); return; } refresh(); } async function removeItem(id: string) { const res = await fetch(`/api/requests/${id}`, { method: "DELETE" }).catch(() => null); if (!res?.ok) { toast("Couldn't remove the item"); return; } toast("Removed from the press"); refresh(); } async function submit(e: React.FormEvent) { e.preventDefault(); if (!artist.trim() || !album.trim()) return; const label = album.trim(); const res = await fetch("/api/requests", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ artist, album }), }).catch(() => null); if (!res?.ok) { toast(`Couldn't queue ${label}`); return; } toast(`Queued ${label}`); setArtist(""); setAlbum(""); refresh(); } const active = rows.filter((r) => jobOf(r).state !== "imported"); const buckets: Record = { active: [], queue: [], errors: [] }; for (const r of active) { const j = jobOf(r); buckets[categoryOf(j.state, j.currentStage)].push(r); } // The API returns rows newest-first, but the worker claims the OLDEST queued job next // (claim_next: ORDER BY createdAt ASC). Order the queue tab to match, so the item that runs // next sits at the top instead of the bottom. const ts = (r: Row) => (r.createdAt ? new Date(r.createdAt).getTime() : 0); buckets.queue.sort((a, b) => ts(a) - ts(b)); // "Next up" = the first queued item the worker will actually claim (oldest, not individually // paused) — mark it so the ordering reads clearly. const nextUpId = buckets.queue.find((r) => !jobOf(r).paused)?.id ?? null; const queuedCount = buckets.queue.length; const downloading = buckets.active.length > 0; // Smart default: land on the tab that has something worth looking at. const defaultTab: Tab = buckets.active.length ? "active" : queuedCount ? "queue" : "errors"; const activeTab: Tab = tab ?? defaultTab; const shown = buckets[activeTab]; // "Recently Pressed": imported albums ordered by when they were PRESSED (job.updatedAt ≈ import // time), newest first — an album requested long ago but imported just now is recently pressed // even though its Request is old (rows arrive by request createdAt, which buried fresh imports). // Dedupe by artist+album (re-downloads leave several completed Requests; keep the most recent). const seenPressed = new Set(); const pressedAll = rows .filter((r) => jobOf(r).state === "imported") .sort((a, b) => new Date(jobOf(b).updatedAt ?? 0).getTime() - new Date(jobOf(a).updatedAt ?? 0).getTime()) .filter((r) => { const key = `${r.artist} ${r.album}`.toLowerCase(); if (seenPressed.has(key)) return false; seenPressed.add(key); return true; }); const pressed = pressedAll.slice(0, 10); // show only the 10 most recently pressed const now = Date.now(); function renderRow(r: Row) { const j = jobOf(r); const d = describeJob(j.state, j.currentStage); const attention = d.kind === "attention"; let meta: string | undefined; if (j.state === "downloading" || j.state === "tagging") { const parts: string[] = []; if (j.chosen) parts.push(`${j.chosen.source} · ${j.chosen.format}`); const elapsed = timeAgo(j.claimedAt ?? j.updatedAt, now); if (elapsed) parts.push(elapsed); meta = parts.join(" · ") || undefined; } else if (j.state === "matching" || j.state === "matched") { meta = j.candidateCount > 0 ? `${j.candidateCount} sources${j.chosen ? ` · best: ${j.chosen.source} ${j.chosen.format}` : ""}` : "Searching sources…"; } else if (j.state === "requested") { const elapsed = timeAgo(r.createdAt, now); meta = elapsed ? `In the queue · ${elapsed}` : "In the queue"; } else { meta = undefined; } const note = attention ? ( <> {j.error?.trim() || "No source met the quality cutoff, or the pipeline stalled."}{" "} ) : j.state === "requested" ? ( {j.paused ? ( <> Paused{" "} ) : ( )}{" "} ) : undefined; let bar: React.ReactNode; if (j.state === "matching" || j.state === "matched") { bar = ; } else if (j.state === "downloading") { const pct = Math.round((j.downloadProgress || 0) * 100); if (pct <= 0) { // Bytes haven't started flowing yet — Qobuz login/metadata/artwork, a Soulseek peer // queue, or a YouTube info-fetch. Show an indeterminate bar instead of a stuck-looking // 0% (it flips to the % bar on the first byte). bar = ; } else { const total = j.chosen?.trackCount ?? 0; const done = total ? Math.round((pct / 100) * total) : 0; const base = total ? `${pct}% · ${done}/${total} tracks` : `${pct}%`; const eta = etaLabel(j.downloadEtaSeconds); bar = ; } } else if (j.state === "tagging") { bar = ; } else { bar = undefined; } return ( ); } return (

On the Press

{paused ? paused : null} {paused ? ( ) : ( )} {downloading ? ( ) : null}
{active.length === 0 ? (

The press is quiet. Queue a release above, or send one over from Wanted or Discover.

) : ( <>
{(["active", "queue", "errors"] as Tab[]).map((t) => ( ))}
{shown.length === 0 ? (

{TAB_EMPTY[activeTab]}

) : (
{shown.map(renderRow)}
)} )} {pressed.length > 0 ? ( <> ({ id: r.id, artist: r.artist, album: r.album, matrix: pressedMark(r), rgMbid: r.rgMbid ?? null }))} /> ) : null}
Lyra — self-hosted pressing plant MusicBrainz · Qobuz · Soulseek · YouTube
); }