diff --git a/web/src/app/_ui/masthead.tsx b/web/src/app/_ui/masthead.tsx
index 6f5cea8..1456840 100644
--- a/web/src/app/_ui/masthead.tsx
+++ b/web/src/app/_ui/masthead.tsx
@@ -1,4 +1,5 @@
import { ThemeToggle } from "./theme-toggle";
+import { WorkerStatus } from "./worker-status";
export function Masthead() {
return (
@@ -10,10 +11,7 @@ export function Masthead() {
A record label for an audience of one
-
-
- Worker · listening
-
+
diff --git a/web/src/app/_ui/pressed-list.tsx b/web/src/app/_ui/pressed-list.tsx
index bea3e5e..148b6c4 100644
--- a/web/src/app/_ui/pressed-list.tsx
+++ b/web/src/app/_ui/pressed-list.tsx
@@ -1,11 +1,13 @@
-export type PressedItem = { id: string; artist: string; album: string; matrix: string };
+import { CoverArt } from "./cover-art";
+
+export type PressedItem = { id: string; artist: string; album: string; matrix: string; rgMbid: string | null };
export function PressedList({ items }: { items: PressedItem[] }) {
return (
{items.map((it) => (
-
- ✓
+
{it.album} · {it.artist}
diff --git a/web/src/app/_ui/worker-status.tsx b/web/src/app/_ui/worker-status.tsx
new file mode 100644
index 0000000..b30ad6a
--- /dev/null
+++ b/web/src/app/_ui/worker-status.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+/** Live worker liveness in the masthead — polls /api/worker (heartbeat recency). */
+export function WorkerStatus() {
+ const [online, setOnline] = useState(null);
+
+ useEffect(() => {
+ let alive = true;
+ async function check() {
+ try {
+ const d = await (await fetch("/api/worker")).json();
+ if (alive) setOnline(!!d.online);
+ } catch {
+ if (alive) setOnline(false);
+ }
+ }
+ check();
+ const id = setInterval(check, 10000);
+ return () => {
+ alive = false;
+ clearInterval(id);
+ };
+ }, []);
+
+ const label = online === null ? "…" : online ? "listening" : "offline";
+ return (
+
+
+ Worker · {label}
+
+ );
+}
diff --git a/web/src/app/api/requests/route.ts b/web/src/app/api/requests/route.ts
index 8a7fbdf..bb2d339 100644
--- a/web/src/app/api/requests/route.ts
+++ b/web/src/app/api/requests/route.ts
@@ -35,10 +35,11 @@ export async function POST(request: Request) {
}
export async function GET() {
- const requests = await prisma.request.findMany({
- orderBy: { createdAt: "desc" },
- include: { job: true },
- });
+ const [requests, releases] = await Promise.all([
+ prisma.request.findMany({ orderBy: { createdAt: "desc" }, include: { job: true } }),
+ prisma.monitoredRelease.findMany({ select: { artistName: true, album: true, rgMbid: true } }),
+ ]);
+ const rgByKey = new Map(releases.map((mr) => [`${mr.artistName} ${mr.album}`, mr.rgMbid]));
return Response.json({
requests: requests.map((r) => ({
@@ -47,6 +48,7 @@ export async function GET() {
album: r.album,
status: r.status,
createdAt: r.createdAt,
+ rgMbid: rgByKey.get(`${r.artist} ${r.album}`) ?? null,
job: r.job ? { state: r.job.state, currentStage: r.job.currentStage } : null,
})),
});
diff --git a/web/src/app/api/worker/route.test.ts b/web/src/app/api/worker/route.test.ts
new file mode 100644
index 0000000..28ac3cc
--- /dev/null
+++ b/web/src/app/api/worker/route.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from "vitest";
+import { prisma } from "@/lib/db";
+import { GET } from "./route";
+
+async function online() {
+ return (await (await GET()).json()).online;
+}
+
+describe("worker status API", () => {
+ it("reports offline when there is no heartbeat", async () => {
+ expect(await online()).toBe(false);
+ });
+
+ it("reports online for a fresh heartbeat", async () => {
+ await prisma.config.create({ data: { key: "worker.heartbeat", value: "1", secret: false } });
+ expect(await online()).toBe(true);
+ });
+
+ it("reports offline for a stale heartbeat", async () => {
+ await prisma.config.create({ data: { key: "worker.heartbeat", value: "1", secret: false } });
+ await prisma.$executeRaw`UPDATE "Config" SET "updatedAt" = now() - interval '5 minutes' WHERE key = 'worker.heartbeat'`;
+ expect(await online()).toBe(false);
+ });
+});
diff --git a/web/src/app/api/worker/route.ts b/web/src/app/api/worker/route.ts
new file mode 100644
index 0000000..0024b4c
--- /dev/null
+++ b/web/src/app/api/worker/route.ts
@@ -0,0 +1,10 @@
+import { prisma } from "@/lib/db";
+
+// The worker stamps worker.heartbeat every ~15s; it's "online" if that row was updated
+// recently. Uses DB time on both write and read so container clock skew doesn't matter.
+export async function GET() {
+ const rows = await prisma.$queryRaw<{ online: boolean }[]>`
+ SELECT (now() - "updatedAt") < interval '40 seconds' AS online
+ FROM "Config" WHERE key = 'worker.heartbeat'`;
+ return Response.json({ online: rows[0]?.online ?? false });
+}
diff --git a/web/src/app/design.css b/web/src/app/design.css
index fa4df01..5839367 100644
--- a/web/src/app/design.css
+++ b/web/src/app/design.css
@@ -342,3 +342,11 @@ nav.contents .sep { flex: 1; }
}
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .toast { animation: none; } }
+
+/* ── Worker status (live) ─────────────────────────────── */
+.worker-status.off { color: var(--graphite); }
+.worker-status.off .live-dot { background: var(--graphite); animation: none; }
+
+/* ── Recently-pressed thumbnail ───────────────────────── */
+.pressed li { align-items: center; }
+.pressed-thumb { width: 30px; height: 30px; flex-shrink: 0; }
diff --git a/web/src/app/library/library-client.tsx b/web/src/app/library/library-client.tsx
index 4d782d2..bd8c5b6 100644
--- a/web/src/app/library/library-client.tsx
+++ b/web/src/app/library/library-client.tsx
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { PageHead } from "../_ui/page-head";
+import { SectionHeader } from "../_ui/section-header";
import { CoverArt } from "../_ui/cover-art";
import { AlbumModal } from "../_ui/album-modal";
@@ -42,14 +43,17 @@ export function LibraryClient() {
Filter
setQ(e.target.value)} />
-
- {shown.length} of {albums.length} albums
-
{albums.length === 0 ? (
Nothing pressed yet. Scan your library in Settings, or queue a release from The Floor.
) : (
+
+ )}
+ {albums.length > 0 ? (
{shown.map((a) => (
))}
- )}
+ ) : null}
{open ? (
({ id: r.id, artist: r.artist, album: r.album, matrix: pressedMark(r) }))}
+ items={pressed.map((r) => ({ id: r.id, artist: r.artist, album: r.album, matrix: pressedMark(r), rgMbid: r.rgMbid ?? null }))}
/>
>
) : null}
diff --git a/worker/lyra_worker/main.py b/worker/lyra_worker/main.py
index a4df1d4..70a2da4 100644
--- a/worker/lyra_worker/main.py
+++ b/worker/lyra_worker/main.py
@@ -16,6 +16,7 @@ from lyra_worker.registry import (
from lyra_worker.scan import scan_chunk
IDLE_SLEEP = 2.0
+HEARTBEAT_SECONDS = 15.0 # how often to stamp worker.heartbeat (its updatedAt = liveness)
MONITOR_TICK_SECONDS = 60.0
DISCOVER_TICK_SECONDS = 300.0 # check the discovery interval every 5 min (interval itself is hours)
DEST_ROOT = "/music" # must match run_pipeline's default library root
@@ -147,6 +148,7 @@ def run_forever() -> None:
print("worker: waiting for jobs", flush=True)
last_tick = 0.0
last_discover_tick = 0.0
+ last_heartbeat = 0.0
try:
while True:
# A transient DB connection loss (e.g. AdminShutdown when Postgres restarts) raises
@@ -157,6 +159,9 @@ def run_forever() -> None:
config = get_config(conn)
mcfg = MonitorConfig.from_config(config)
now = time.monotonic()
+ if now - last_heartbeat >= HEARTBEAT_SECONDS:
+ _set_config(conn, "worker.heartbeat", "1") # value irrelevant; updatedAt is the signal
+ last_heartbeat = now
if mcfg.enabled and now - last_tick >= MONITOR_TICK_SECONDS:
try:
sweep(conn, browser, mcfg)