Files
Lyra/web/src/app/queue.tsx
T
Jonathan 01657d4e1b fix(web): order Recently Pressed by press time + cap at 10
Recently Pressed was derived from the request list (ordered by Request.createdAt), so
an album requested long ago but imported just now sorted to the bottom — freshly
pressed albums never appeared near the top ("new albums don't get added"). Order by
press time (job.updatedAt ≈ import time) instead, and show only the 10 most recent.
The "Pressed" stat tile and the "N in library" note still reflect the true total; the
per-row date label now shows when it was pressed, not requested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:17:54 +02:00

406 lines
14 KiB
TypeScript

"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<Tab, string> = { active: "Active", queue: "In queue", errors: "Errors" };
const TAB_EMPTY: Record<Tab, string> = {
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<Row[]>([]);
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<Tab | null>(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<Tab, Row[]> = { 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<string>();
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."}{" "}
<button className="btn sm" onClick={() => retry(r.id)}>
Retry
</button>
</>
) : j.state === "requested" ? (
<span className="item-controls">
{j.paused ? (
<>
<span className="chip">Paused</span>{" "}
<button className="btn sm ghost" onClick={() => pauseItem(r.id, false)}>
Resume
</button>
</>
) : (
<button className="btn sm ghost" onClick={() => pauseItem(r.id, true)}>
Pause
</button>
)}{" "}
<button className="btn sm ghost" onClick={() => removeItem(r.id)}>
Remove
</button>
</span>
) : undefined;
let bar: React.ReactNode;
if (j.state === "matching" || j.state === "matched") {
bar = <ProgressBar kind="working" indeterminate caption="searching" />;
} 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 = <ProgressBar kind="working" indeterminate caption="preparing…" />;
} 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 = <ProgressBar kind="working" percent={pct} caption={eta ? `${base} · ${eta}` : base} />;
}
} else if (j.state === "tagging") {
bar = <ProgressBar kind="verify" indeterminate caption="finishing" />;
} else {
bar = undefined;
}
return (
<JobRow
key={r.id}
artist={r.artist}
album={r.album}
kind={d.kind}
label={d.label}
marker={r.id === nextUpId ? "Next up" : undefined}
meta={meta}
note={note}
bar={bar}
/>
);
}
return (
<div>
<StatTiles
items={[
{ k: "On the press", v: active.length },
{ k: "Pressed", v: pressedAll.length, accent: true },
{ k: "Wanted", v: wanted },
{ k: "Watching", v: watching, unit: "artists" },
]}
/>
<div className="dept has-actions">
<h2>On the Press</h2>
<span className="fill" />
{paused ? <span className="count">paused</span> : null}
<KebabMenu label="Press actions">
{paused ? (
<button className="menu-item" onClick={() => floorAction("resume", "Press resumed")}>
Resume the press
</button>
) : (
<button className="menu-item" onClick={() => floorAction("pause", "Press paused")}>
Pause the press
</button>
)}
<button className="menu-item" onClick={clearPress} disabled={queuedCount === 0}>
Clear queue
</button>
{downloading ? (
<button className="menu-item danger" onClick={stopPress}>
Stop now
</button>
) : null}
</KebabMenu>
</div>
<form className="request-form" onSubmit={submit}>
<label className="field">
<span>Artist</span>
<input aria-label="artist" placeholder="Artist" value={artist} onChange={(e) => setArtist(e.target.value)} />
</label>
<label className="field">
<span>Album</span>
<input aria-label="album" placeholder="Album" value={album} onChange={(e) => setAlbum(e.target.value)} />
</label>
<button type="submit" className="btn accent">
Request
</button>
</form>
{active.length === 0 ? (
<p className="empty">The press is quiet. Queue a release above, or send one over from Wanted or Discover.</p>
) : (
<>
<div className="tabs" role="tablist">
{(["active", "queue", "errors"] as Tab[]).map((t) => (
<button
key={t}
role="tab"
aria-selected={activeTab === t}
className={`tab${activeTab === t ? " active" : ""}${t === "errors" && buckets.errors.length ? " has-errors" : ""}`}
onClick={() => setTab(t)}
>
{TAB_LABEL[t]}
<span className="n">{buckets[t].length}</span>
</button>
))}
</div>
{shown.length === 0 ? (
<p className="empty">{TAB_EMPTY[activeTab]}</p>
) : (
<section className="floor">{shown.map(renderRow)}</section>
)}
</>
)}
{pressed.length > 0 ? (
<>
<SectionHeader title="Recently Pressed" note={`last ${pressed.length} · ${pressedAll.length} in library`} />
<PressedList
items={pressed.map((r) => ({ id: r.id, artist: r.artist, album: r.album, matrix: pressedMark(r), rgMbid: r.rgMbid ?? null }))}
/>
</>
) : null}
<div className="colophon">
<span>Lyra self-hosted pressing plant</span>
<span>MusicBrainz · Qobuz · Soulseek · YouTube</span>
</div>
</div>
);
}