01657d4e1b
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>
406 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|