Compare commits

...

12 Commits

Author SHA1 Message Date
e49a04c576 v2026.04.15.1 — drop verify/checkmarks, merge jobs view into item details
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m58s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:07:06 +02:00
346cd681f9 details: surface job status, command, log, and run/cancel inline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 07:06:20 +02:00
17b1d5974a pipeline: remove plan_update SSE listener (feature gone) 2026-04-15 07:04:44 +02:00
12e4fbf14e pipeline: batch controls move to queued column header 2026-04-15 07:04:06 +02:00
d6e8d264c5 done column: drop checkmark glyph and verify-unverified button 2026-04-15 07:03:19 +02:00
f6488b6bbe delete /execute page, route, and Jobs nav link 2026-04-15 07:02:32 +02:00
2eacda9127 client types: drop verified, add job on DetailData
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-15 07:01:36 +02:00
688443e732 enrich GET /api/review/:id with the latest job row 2026-04-15 07:00:47 +02:00
0d6781973b rip out jellyfin handoff verification path and verify-unverified endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 06:58:55 +02:00
cbf0025a81 drop review_plans.verified column and all its references
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 06:55:43 +02:00
81b2990dca plan: drop verify/checkmarks, merge jobs view into item details
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:49:34 +02:00
9d7b76339b spec: drop verify/checkmarks, merge jobs view into item details
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:43:52 +02:00
18 changed files with 1098 additions and 724 deletions

View File

@@ -0,0 +1,813 @@
# Drop verify/checkmarks, merge jobs view into item details — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rip out the post-job verification path entirely (DB column, SSE event, handoff function), delete the standalone `/execute` page, and surface per-item job info (status, command, log, run/cancel) on the item details page. Batch queue controls move into the Pipeline column headers.
**Architecture:** Rescan becomes the single source of truth for "is this file still done?" — the verified flag and the Jellyfin refresh handoff are no longer needed. The Jobs page disappears; its per-item info is enriched onto `GET /api/review/:id` and rendered inline on the details page. Batch controls (`Run all`, `Clear queue`, `Clear`) sit in the existing `ColumnShell` `actions` slot.
**Tech Stack:** Bun + Hono (server), React 19 + TanStack Router (client), bun:sqlite.
---
## File Structure
**Backend:**
- `server/db/index.ts` — add `DROP COLUMN verified` migration
- `server/db/schema.ts` — remove `verified` from `review_plans` DDL
- `server/services/rescan.ts` — remove `verified` from INSERT/UPDATE logic
- `server/api/review.ts` — drop `rp.verified` from pipeline SELECT, drop `verified = 0` from unapprove, enrich `loadItemDetail` with latest job
- `server/api/execute.ts` — delete `handOffToJellyfin`, `emitPlanUpdate`, `POST /verify-unverified`, `GET /` (list endpoint) and the plan_update emissions from job lifecycle
- `server/types.ts` — drop `verified` from `ReviewPlan`, add job shape on detail response
- `server/services/__tests__/webhook.test.ts` — delete the `webhook_verified flag` describe block
**Frontend:**
- `src/routes/execute.tsx` — delete (file)
- `src/features/execute/ExecutePage.tsx` — delete (file)
- `src/shared/lib/types.ts` — drop `verified` from `PipelineJobItem` and `ReviewPlan`; add `job` field to `DetailData`
- `src/routes/__root.tsx` — remove `Jobs` nav link
- `src/features/pipeline/PipelinePage.tsx` — remove `plan_update` SSE listener, remove the `Start queue` header button
- `src/features/pipeline/DoneColumn.tsx` — remove verify button, `unverifiedCount`, `verified`/`✓✓` glyph
- `src/features/pipeline/QueueColumn.tsx` — add `Run all` + `Clear queue` actions
- `src/features/review/AudioDetailPage.tsx` — add JobSection
**Plan ordering rationale:** Backend DB migration first (Task 1) so the schema drift from the `verified` column doesn't break tests. Then server logic deletions (Task 2). Then server additions (Task 3). Frontend follows in dependency order: types → route deletion → column updates → details enrichment.
---
## Task 1: Drop `verified` column from DB + backend references
**Files:**
- Modify: `server/db/index.ts` (migration block)
- Modify: `server/db/schema.ts:77`
- Modify: `server/services/rescan.ts:233-270`
- Modify: `server/api/review.ts:330, 773`
- Modify: `server/types.ts` (ReviewPlan interface)
- Modify: `server/services/__tests__/webhook.test.ts:186-240`
- [ ] **Step 1: Add idempotent migration in `server/db/index.ts`**
Locate the existing block of `alter(...)` calls (around line 76 where `webhook_verified` was added and renamed). Append a new call at the end so it runs on the next startup for existing databases:
```ts
alter("ALTER TABLE review_plans DROP COLUMN verified");
```
The `alter()` helper wraps each statement in try/catch, so on a fresh DB (where `verified` never existed because we'll remove it from schema.ts) the DROP is a no-op, and on an existing DB it removes the column once.
- [ ] **Step 2: Remove `verified` from schema.ts**
Open `server/db/schema.ts`. Find the `review_plans` CREATE TABLE block (around line 77) and delete the line:
```ts
verified INTEGER NOT NULL DEFAULT 0,
```
- [ ] **Step 3: Remove `verified` from rescan.ts INSERT/UPDATE**
Open `server/services/rescan.ts` around lines 223272.
First, trim the block comment immediately above the `db.prepare(...)` call. Delete the paragraph that starts `` `verified` tracks whether we have independent confirmation... `` (lines 232238 in the current file). Keep the "Status transition rules" paragraph above it.
Then replace the INSERT/ON CONFLICT statement and its `.run(...)` args with the variant that has no `verified` column:
```ts
db
.prepare(`
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
VALUES (?, 'pending', ?, ?, ?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET
status = CASE
WHEN excluded.is_noop = 1 THEN 'done'
WHEN review_plans.status = 'done' AND ? = 'webhook' THEN 'pending'
WHEN review_plans.status = 'done' THEN 'done'
WHEN review_plans.status = 'error' THEN 'pending'
ELSE review_plans.status
END,
is_noop = excluded.is_noop,
confidence = excluded.confidence,
apple_compat = excluded.apple_compat,
job_type = excluded.job_type,
notes = excluded.notes
`)
.run(
itemId,
analysis.is_noop ? 1 : 0,
confidence,
analysis.apple_compat,
analysis.job_type,
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
source, // for the CASE WHEN ? = 'webhook' branch
);
```
Note: the parameter list drops the two `verified`-related bindings (was `analysis.is_noop ? 1 : 0` passed twice for the verified CASE, and the source passed twice). Verify by counting `?` placeholders in the SQL (7) matches `.run()` argument count (7).
- [ ] **Step 4: Remove `rp.verified` from the pipeline SELECT**
Open `server/api/review.ts` around line 330. In the `done` query, change:
```ts
SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat, rp.verified
```
to:
```ts
SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat
```
- [ ] **Step 5: Remove `verified = 0` from unapprove UPDATE**
Open `server/api/review.ts` around line 773. Change:
```ts
db.prepare("UPDATE review_plans SET status = 'pending', verified = 0, reviewed_at = NULL WHERE id = ?").run(plan.id);
```
to:
```ts
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE id = ?").run(plan.id);
```
- [ ] **Step 6: Remove `verified` from the `ReviewPlan` type**
Open `server/types.ts`. Find the `ReviewPlan` interface and delete the `verified: number;` line (around line 68).
- [ ] **Step 7: Delete the webhook_verified test block**
Open `server/services/__tests__/webhook.test.ts`. Find the block `describe("processWebhookEvent — webhook_verified flag", …)` starting at line 186 and delete through its closing `});` at line 240.
- [ ] **Step 8: Run the test suite**
Run: `bun test`
Expected: PASS with the full suite green (the remaining webhook tests, analyzer tests, etc.).
If any test fails with "no such column: verified", grep for remaining references:
```bash
rg "verified" server/
```
and remove each occurrence.
- [ ] **Step 9: Commit**
```bash
git add server/db/index.ts server/db/schema.ts server/services/rescan.ts server/api/review.ts server/types.ts server/services/__tests__/webhook.test.ts
git commit -m "drop review_plans.verified column and all its references"
```
---
## Task 2: Delete verification path in `server/api/execute.ts`
**Files:**
- Modify: `server/api/execute.ts`
- [ ] **Step 1: Delete `handOffToJellyfin` function**
Open `server/api/execute.ts`. Delete the entire `handOffToJellyfin` function and its JSDoc, spanning roughly lines 2898 (from the block comment starting `/**\n * Post-job verification…` through the closing brace of the function).
Also delete the now-unused imports at the top that only this function used:
```ts
import { getItem, refreshItem } from "../services/jellyfin";
import { loadRadarrLibrary, radarrUsable } from "../services/radarr";
import { loadSonarrLibrary, sonarrUsable } from "../services/sonarr";
import { upsertJellyfinItem } from "../services/rescan";
import type { RescanConfig } from "../services/rescan";
import { getAllConfig } from "../db";
```
(Only delete the ones not used elsewhere in the file. Run the TS check in Step 6 to catch any that are still needed.)
- [ ] **Step 2: Delete `emitPlanUpdate` function**
In the same file, find `emitPlanUpdate` (around line 183) and delete the function and its block comment (lines 176186).
- [ ] **Step 3: Remove calls to `handOffToJellyfin` from the job lifecycle**
There are two call sites at lines 492 and 609, each wrapped in `.catch(...)`. Find both instances that look like:
```ts
handOffToJellyfin(job.item_id).catch((err) =>
logError(`handOffToJellyfin for item ${job.item_id} failed:`, err),
);
```
Delete both blocks entirely.
- [ ] **Step 4: Delete the `/verify-unverified` endpoint**
In the same file, find and delete the whole block starting with the comment `// ─── Verify all unverified done plans ───` and the `app.post("/verify-unverified", …)` handler below it (approximately lines 357389).
- [ ] **Step 5: Delete the `GET /` list endpoint**
Find the handler mounted at `app.get("/", (c) => { ... })` that returns the filtered jobs list (the one used by the Execute page). Delete the whole block including its preceding comment.
To locate: it reads `filter` from `c.req.query("filter")`, runs a SELECT joining `jobs` with `media_items`, and returns `{ jobs, filter, totalCounts }`.
- [ ] **Step 6: Run TypeScript compile**
Run: `bun --bun tsc --noEmit --project tsconfig.server.json`
Expected: PASS with no unused import warnings.
If the compiler complains about unused imports, remove them.
- [ ] **Step 7: Run lint**
Run: `bun run lint`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add server/api/execute.ts
git commit -m "rip out jellyfin handoff verification path and verify-unverified endpoint"
```
---
## Task 3: Enrich `loadItemDetail` with the latest job
**Files:**
- Modify: `server/api/review.ts:111-126`
- Modify: `server/types.ts` (add exported `DetailJob` shape or similar if helpful)
- [ ] **Step 1: Add latest-job query to `loadItemDetail`**
Open `server/api/review.ts` around line 111. Replace the body with the job enrichment:
```ts
function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined;
if (!item) return { item: null, streams: [], plan: null, decisions: [], command: null, job: null };
const streams = db
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
.all(itemId) as MediaStream[];
const plan = db.prepare("SELECT * FROM review_plans WHERE item_id = ?").get(itemId) as ReviewPlan | undefined | null;
const decisions = plan
? (db.prepare("SELECT * FROM stream_decisions WHERE plan_id = ?").all(plan.id) as StreamDecision[])
: [];
const command = plan && !plan.is_noop ? buildCommand(item, streams, decisions) : null;
const job = db
.prepare(
`SELECT id, item_id, command, job_type, status, output, exit_code,
created_at, started_at, completed_at
FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1`,
)
.get(itemId) as Job | undefined;
return { item, streams, plan: plan ?? null, decisions, command, job: job ?? null };
}
```
Add the `Job` type import at the top of the file if not already imported:
```ts
import type { Job, MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types";
```
- [ ] **Step 2: Run the test suite**
Run: `bun test`
Expected: PASS.
- [ ] **Step 3: Smoke-test the endpoint manually**
Start the server: `bun run dev:server`
In another terminal:
```bash
curl -s http://localhost:3000/api/review/1 | jq '.job'
```
Expected: either `null` (no jobs ever) or a job object with the fields above.
Kill the dev server with Ctrl-C after confirming.
- [ ] **Step 4: Commit**
```bash
git add server/api/review.ts
git commit -m "enrich GET /api/review/:id with the latest job row"
```
---
## Task 4: Update client types (drop verified, add job on DetailData)
**Files:**
- Modify: `src/shared/lib/types.ts`
- Modify: `src/features/review/AudioDetailPage.tsx:13-19` (local `DetailData` interface)
- [ ] **Step 1: Remove `verified` from `ReviewPlan`**
In `src/shared/lib/types.ts`, find the `ReviewPlan` interface (lines 4456). This client-side type doesn't currently include `verified` — confirm by reading lines 4456. If it does, delete the line. If not, skip this sub-step.
- [ ] **Step 2: Remove `verified` from `PipelineJobItem`**
In the same file around line 161, delete:
```ts
// 1 when an independent post-hoc check confirms the on-disk file matches
// the plan (ffprobe after a job, or is_noop=1 on the very first scan).
// Renders as the second checkmark in the Done column.
verified?: number;
```
- [ ] **Step 3: Update `DetailData` in `AudioDetailPage.tsx`**
Open `src/features/review/AudioDetailPage.tsx` at line 13 and replace the interface with:
```ts
interface DetailData {
item: MediaItem;
streams: MediaStream[];
plan: ReviewPlan | null;
decisions: StreamDecision[];
command: string | null;
job: Job | null;
}
```
Add `Job` to the imports at line 9:
```ts
import type { Job, MediaItem, MediaStream, ReviewPlan, StreamDecision } from "~/shared/lib/types";
```
- [ ] **Step 4: Run lint**
Run: `bun run lint`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/shared/lib/types.ts src/features/review/AudioDetailPage.tsx
git commit -m "client types: drop verified, add job on DetailData"
```
---
## Task 5: Delete the Execute page, route, and nav link
**Files:**
- Delete: `src/features/execute/ExecutePage.tsx`
- Delete: `src/routes/execute.tsx`
- Modify: `src/routes/__root.tsx:72`
- Delete (if empty after file removal): `src/features/execute/`
- [ ] **Step 1: Delete the files**
```bash
rm src/features/execute/ExecutePage.tsx src/routes/execute.tsx
rmdir src/features/execute 2>/dev/null || true
```
- [ ] **Step 2: Remove the `Jobs` nav link**
Open `src/routes/__root.tsx` at line 72 and delete:
```tsx
<NavLink to="/execute">Jobs</NavLink>
```
- [ ] **Step 3: Regenerate the TanStack Router tree**
The router typegen runs in dev. Start the dev client briefly to regenerate `src/routeTree.gen.ts`:
Run: `bun run dev:client &`
Wait 3 seconds for Vite to finish the initial build and regenerate the tree, then kill it:
```bash
sleep 3 && kill %1
```
Alternatively, if TSR has a CLI: `bunx @tanstack/router-cli generate`. Either works.
- [ ] **Step 4: Run build to confirm no dangling imports**
Run: `bun run build`
Expected: PASS with no errors about missing `/execute` route or missing `ExecutePage` import.
- [ ] **Step 5: Commit**
```bash
git add -A src/
git commit -m "delete /execute page, route, and Jobs nav link"
```
---
## Task 6: Simplify DoneColumn (remove verify button + checkmark glyph)
**Files:**
- Modify: `src/features/pipeline/DoneColumn.tsx`
- [ ] **Step 1: Rewrite DoneColumn with the glyph and verify button removed**
Replace the entire file contents with:
```tsx
import { Link } from "@tanstack/react-router";
import { Badge } from "~/shared/components/ui/badge";
import { api } from "~/shared/lib/api";
import type { PipelineJobItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
interface DoneColumnProps {
items: PipelineJobItem[];
onMutate: () => void;
}
export function DoneColumn({ items, onMutate }: DoneColumnProps) {
const clear = async () => {
await api.post("/api/execute/clear-completed");
onMutate();
};
const reopen = async (itemId: number) => {
await api.post(`/api/review/${itemId}/reopen`);
onMutate();
};
const actions = items.length > 0 ? [{ label: "Clear", onClick: clear }] : undefined;
return (
<ColumnShell title="Done" count={items.length} actions={actions}>
{items.map((item) => (
<div key={item.id} className="group rounded border bg-white p-2">
<Link
to="/review/audio/$id"
params={{ id: String(item.item_id) }}
className="text-xs font-medium truncate block hover:text-blue-600 hover:underline"
>
{item.name}
</Link>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>
<div className="flex-1" />
<button
type="button"
onClick={() => reopen(item.item_id)}
title="Send this item back to the Review column to redecide and re-queue"
className="text-[0.68rem] px-1.5 py-0.5 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
>
← Back to review
</button>
</div>
</div>
))}
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No completed items</p>}
</ColumnShell>
);
}
```
- [ ] **Step 2: Run lint**
Run: `bun run lint`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/features/pipeline/DoneColumn.tsx
git commit -m "done column: drop checkmark glyph and verify-unverified button"
```
---
## Task 7: Add batch controls to QueueColumn header, remove Start queue from Pipeline header
**Files:**
- Modify: `src/features/pipeline/QueueColumn.tsx`
- Modify: `src/features/pipeline/PipelinePage.tsx`
- [ ] **Step 1: Update QueueColumn to expose `Run all` + `Clear queue`**
Replace the entire file with:
```tsx
import { api } from "~/shared/lib/api";
import type { PipelineJobItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
import { PipelineCard } from "./PipelineCard";
interface QueueColumnProps {
items: PipelineJobItem[];
jellyfinUrl: string;
onMutate: () => void;
}
export function QueueColumn({ items, jellyfinUrl, onMutate }: QueueColumnProps) {
const runAll = async () => {
await api.post("/api/execute/start");
onMutate();
};
const clear = async () => {
if (!confirm(`Cancel all ${items.length} pending jobs?`)) return;
await api.post("/api/execute/clear");
onMutate();
};
const unapprove = async (itemId: number) => {
await api.post(`/api/review/${itemId}/unapprove`);
onMutate();
};
const actions =
items.length > 0
? [
{ label: "Run all", onClick: runAll, primary: true },
{ label: "Clear", onClick: clear },
]
: undefined;
return (
<ColumnShell title="Queued" count={items.length} actions={actions}>
<div className="space-y-2">
{items.map((item) => (
<PipelineCard key={item.id} item={item} jellyfinUrl={jellyfinUrl} onUnapprove={() => unapprove(item.item_id)} />
))}
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">Queue empty</p>}
</div>
</ColumnShell>
);
}
```
- [ ] **Step 2: Remove `Start queue` button from PipelinePage header**
Open `src/features/pipeline/PipelinePage.tsx`. In the header JSX around line 8997, delete the `Start queue` `<Button>` and the `startQueue` callback (around lines 3437). The header should become:
```tsx
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
<h1 className="text-lg font-semibold">Pipeline</h1>
<span className="text-sm text-gray-500">{data.doneCount} files in desired state</span>
</div>
```
Also remove the `Button` import at the top of the file if it's no longer used:
```tsx
import { Button } from "~/shared/components/ui/button";
```
- [ ] **Step 3: Run lint**
Run: `bun run lint`
Expected: PASS (no unused-import errors).
- [ ] **Step 4: Commit**
```bash
git add src/features/pipeline/QueueColumn.tsx src/features/pipeline/PipelinePage.tsx
git commit -m "pipeline: batch controls move to queued column header"
```
---
## Task 8: Remove `plan_update` SSE listener
**Files:**
- Modify: `src/features/pipeline/PipelinePage.tsx`
- [ ] **Step 1: Delete the plan_update listener**
In `src/features/pipeline/PipelinePage.tsx` around lines 6772, delete:
```tsx
// plan_update lands ~15s after a job finishes — the post-job jellyfin
// verification writes verified=1 (or flips the plan back to pending).
// Without refreshing here the Done column would never promote ✓ to ✓✓.
es.addEventListener("plan_update", () => {
scheduleReload();
});
```
The other listeners (`job_update`, `job_progress`, `queue_status`) stay untouched.
- [ ] **Step 2: Run lint**
Run: `bun run lint`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/features/pipeline/PipelinePage.tsx
git commit -m "pipeline: remove plan_update SSE listener (feature gone)"
```
---
## Task 9: Add JobSection to AudioDetailPage
**Files:**
- Modify: `src/features/review/AudioDetailPage.tsx`
- [ ] **Step 1: Add a `JobSection` component in the same file**
Near the bottom of `src/features/review/AudioDetailPage.tsx`, after the `TitleInput` component and before the `AudioDetailPage` export, add:
```tsx
interface JobSectionProps {
itemId: number;
job: Job;
onMutate: () => void;
}
function JobSection({ itemId, job, onMutate }: JobSectionProps) {
const [showCmd, setShowCmd] = useState(false);
const [showLog, setShowLog] = useState(job.status === "error");
const [liveStatus, setLiveStatus] = useState(job.status);
const [liveOutput, setLiveOutput] = useState(job.output ?? "");
const [progress, setProgress] = useState<{ seconds: number; total: number } | null>(null);
// Keep local state in sync when parent fetches fresh data
useEffect(() => {
setLiveStatus(job.status);
setLiveOutput(job.output ?? "");
}, [job.status, job.output, job.id]);
// Subscribe to SSE for live updates on this specific job id
useEffect(() => {
const es = new EventSource("/api/execute/events");
es.addEventListener("job_update", (e) => {
const d = JSON.parse((e as MessageEvent).data) as { id: number; status: string; output?: string };
if (d.id !== job.id) return;
setLiveStatus(d.status as Job["status"]);
if (d.output !== undefined) setLiveOutput(d.output);
if (d.status === "done" || d.status === "error") onMutate();
});
es.addEventListener("job_progress", (e) => {
const d = JSON.parse((e as MessageEvent).data) as { id: number; seconds: number; total: number };
if (d.id !== job.id) return;
setProgress({ seconds: d.seconds, total: d.total });
});
return () => es.close();
}, [job.id, onMutate]);
const runJob = async () => {
await api.post(`/api/execute/job/${job.id}/run`);
onMutate();
};
const cancelJob = async () => {
await api.post(`/api/execute/job/${job.id}/cancel`);
onMutate();
};
const stopJob = async () => {
await api.post("/api/execute/stop");
onMutate();
};
const typeLabel = job.job_type === "transcode" ? "Audio Transcode" : "Audio Remux";
const exitBadge = job.exit_code != null && job.exit_code !== 0 ? job.exit_code : null;
return (
<div className="mt-6 pt-4 border-t border-gray-200">
<div className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em] mb-2">Job</div>
<div className="flex items-center gap-2 flex-wrap mb-3">
<Badge variant={liveStatus}>{liveStatus}</Badge>
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{typeLabel}</Badge>
{exitBadge != null && <Badge variant="error">exit {exitBadge}</Badge>}
{job.started_at && (
<span className="text-gray-500 text-[0.72rem]">started {job.started_at}</span>
)}
{job.completed_at && (
<span className="text-gray-500 text-[0.72rem]">completed {job.completed_at}</span>
)}
<div className="flex-1" />
<Button size="sm" variant="secondary" onClick={() => setShowCmd((v) => !v)}>
Cmd
</Button>
{liveOutput && (
<Button size="sm" variant="secondary" onClick={() => setShowLog((v) => !v)}>
Log
</Button>
)}
{liveStatus === "pending" && (
<>
<Button size="sm" onClick={runJob}>
▶ Run
</Button>
<Button size="sm" variant="secondary" onClick={cancelJob}>
✕ Cancel
</Button>
</>
)}
{liveStatus === "running" && (
<Button size="sm" variant="secondary" onClick={stopJob}>
✕ Stop
</Button>
)}
</div>
{liveStatus === "running" && progress && progress.total > 0 && (
<div className="h-1.5 bg-gray-200 rounded mb-3 overflow-hidden">
<div
className="h-full bg-blue-500 transition-[width] duration-500"
style={{ width: `${Math.min(100, (progress.seconds / progress.total) * 100).toFixed(1)}%` }}
/>
</div>
)}
{showCmd && (
<div className="font-mono text-[0.74rem] bg-gray-50 text-gray-700 px-3 py-2 rounded max-h-[120px] overflow-y-auto whitespace-pre-wrap break-all mb-2">
{job.command}
</div>
)}
{showLog && liveOutput && (
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3 py-2 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
{liveOutput}
</div>
)}
</div>
);
}
```
Note: `Badge`'s `variant` prop must accept each of `"pending" | "running" | "done" | "error" | "manual" | "noop"`. Verify by opening `src/shared/components/ui/badge.tsx` — these variants already exist per the Execute page's use. If any are missing, add them there.
- [ ] **Step 2: Render `JobSection` inside `AudioDetailPage`**
In the same file, in the `AudioDetailPage` component's JSX, place the JobSection between the FFmpeg command textarea and the Approve/Skip buttons. Locate the existing block around lines 338348 (the `{command && (...)}` section with the textarea) and add immediately below it:
```tsx
{data.job && <JobSection itemId={item.id} job={data.job} onMutate={load} />}
```
- [ ] **Step 3: Run lint**
Run: `bun run lint`
Expected: PASS.
- [ ] **Step 4: Run the dev server and verify manually**
Run: `bun run dev`
Open `http://localhost:5173`:
- Navigate to an item that has a pending job (approve one from Review, then go to its details page via the Queued card link) → confirm the Job section shows status `pending` and working `▶ Run` / `✕ Cancel` buttons.
- Click `▶ Run` → the status badge flips to `running` and the progress bar appears.
- When the job finishes → status flips to `done` and the Log button becomes available.
- Navigate to a done item → confirm Job section shows status `done`, `Cmd` and `Log` toggles work.
Kill the dev server with Ctrl-C.
- [ ] **Step 5: Commit**
```bash
git add src/features/review/AudioDetailPage.tsx
git commit -m "details: surface job status, command, log, and run/cancel inline"
```
---
## Task 10: Version bump, final build, CalVer commit
**Files:**
- Modify: `package.json` (version field)
- [ ] **Step 1: Bump the CalVer version**
Today is 2026-04-15. Read the current version in `package.json`; if it's already `2026.04.15.N`, increment `N`. Otherwise, set it to `2026.04.15.1`.
Edit `package.json`:
```json
"version": "2026.04.15.1"
```
(Use the next free `.N` suffix if `.1` was already used today.)
- [ ] **Step 2: Run the full build**
Run: `bun run build`
Expected: PASS — Vite produces `dist/` cleanly.
- [ ] **Step 3: Run tests once more**
Run: `bun test`
Expected: PASS.
- [ ] **Step 4: Run lint**
Run: `bun run lint`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add package.json
git commit -m "v2026.04.15.1 — drop verify/checkmarks, merge jobs view into item details"
```
---
## Guided Gates (user-verified after deploy)
- **GG-1:** Done column shows cards with only a `done`/`error` badge — no ✓ or ✓✓ glyph.
- **GG-2:** Clicking a Done item → details page shows Job section below the FFmpeg command box, with `Cmd` and `Log` toggles.
- **GG-3:** Clicking a Queued item → details page shows a pending job with working `▶ Run` and `✕ Cancel`; running it updates the badge live.
- **GG-4:** `/execute` returns 404 in the browser.
- **GG-5:** `Run all` + `Clear` buttons appear in the Queued column header; `Clear` stays in the Done column header; the previous `Start queue` button in the Pipeline page header is gone.
- **GG-6:** `PRAGMA table_info(review_plans);` in the SQLite DB no longer lists `verified`.

View File

@@ -0,0 +1,114 @@
# Drop verify/checkmarks, merge jobs view into item details
Date: 2026-04-15
## Summary
Remove the post-job Jellyfin verification path and its associated `verified` flag entirely. Delete the standalone `/execute` jobs page. Surface per-item job info (status, command, log, run/cancel actions) on the item details page instead. Batch queue controls (Run all / Clear) move into the Pipeline column headers.
Rescan becomes the sole source of truth for "is this file still done?" — if a file drifts off-noop, the next scan flips its plan back to pending and the card reappears in Review.
## Motivation
The verify feature tried to promote done cards from ✓ to ✓✓ after ffprobe/Jellyfin cross-checked the on-disk file. In practice the Jellyfin refresh path is fragile (times out silently), the ✓/✓✓ distinction adds UI noise without user value, and rescan already catches drift. The separate Jobs page duplicates info that belongs on the item details page and forces users to jump between views to answer "what happened to this file?".
## Backend changes
### Remove verification path
- Delete `handOffToJellyfin()` in `server/api/execute.ts` (≈lines 3898) and both callers at `:492` and `:609`. Post-job handling is now just the existing `jobs.status` update.
- Delete `emitPlanUpdate()` and the `plan_update` SSE event emission.
- Delete `POST /api/execute/verify-unverified` (≈lines 357389).
### Drop `verified` column
- Add idempotent migration in `server/db/index.ts` following the existing try/catch `alter()` pattern:
```ts
alter("ALTER TABLE review_plans DROP COLUMN verified");
```
Supported on Bun's bundled SQLite (≥3.35).
- Remove `verified` from `server/db/schema.ts:77` in the `review_plans` CREATE TABLE.
- In `server/services/rescan.ts`, remove `verified` from the INSERT column list and the `verified = CASE ...` branch in the ON CONFLICT DO UPDATE clause.
- In `server/api/review.ts`:
- Remove `rp.verified` from the pipeline SELECT (≈line 330).
- Remove `verified = 0` from the unapprove UPDATE (≈line 773).
### Remove jobs-list endpoint
- Delete `GET /api/execute` (the filtered list used only by the Execute page).
- Keep: `/start`, `/clear`, `/clear-completed`, `/job/:id/run`, `/job/:id/cancel`, `/stop`, `/events`, SSE events `job_update`, `job_progress`, `queue_status`.
### Enrich item details endpoint
- Extend `GET /api/review/:id` to include the latest job row for this item (if any):
```ts
job: {
id: number;
status: Job["status"];
job_type: "copy" | "transcode";
command: string | null;
output: string | null;
exit_code: number | null;
started_at: string | null;
completed_at: string | null;
} | null
```
- "Latest" = most recent by `jobs.created_at DESC LIMIT 1` for the item. A single additional prepared statement.
## Frontend changes
### Deletions
- `src/features/execute/ExecutePage.tsx`
- `src/routes/execute.tsx`
- Nav link to `/execute` in `src/routes/__root.tsx`
- `plan_update` SSE listener in `src/features/pipeline/PipelinePage.tsx:70-72`
- Verify button, `verifyUnverified()`, `unverifiedCount`, and the ``/`✓✓` glyph span in `src/features/pipeline/DoneColumn.tsx`
- The `verified` field on `PipelineJobItem` in `src/shared/lib/types.ts:161`
### DoneColumn simplification
Each card keeps its title link, `← Back to review` hover button, and status `<Badge>` (`done` or `error`). The mark glyph and its `title` attribute go away. Column actions stay: `Clear` when items exist.
### Pipeline column headers (batch controls)
`ColumnShell` already accepts an `actions: ColumnAction[]` array. Move existing batch controls off the `/execute` page into the headers:
- **Queued column** — `Run all` (primary, when at least one pending) + `Clear queue` (when items exist)
- **Done column** — `Clear` (existing)
- **Processing column** — no batch controls
Wire these to the existing endpoints: `/api/execute/start`, `/api/execute/clear`, `/api/execute/clear-completed`.
### AudioDetailPage job section
New section rendered only when `data.job` is non-null. Placement: between the FFmpeg command textarea and the Approve/Skip button row.
Contents:
- Header row: status `<Badge>`, job-type badge (`Audio Remux`/`Audio Transcode`), started/completed timestamps, exit code badge (only when non-zero)
- `Cmd` toggle button — reveals the job's recorded command (the `jobs.command` column)
- `Log` toggle button — reveals `jobs.output`; auto-expanded when `status === "error"`
- Action buttons based on `job.status`:
- `pending` → `▶ Run` (calls `POST /api/execute/job/:id/run`), `✕ Cancel` (calls `POST /api/execute/job/:id/cancel`)
- `running` → `✕ Stop` (calls `POST /api/execute/stop`)
- `done` / `error` → no actions
### Live updates on details page
The details page gets its own scoped `EventSource` subscription to `/api/execute/events`, filtering for events where `id === data.job?.id`:
- `job_update` → merge into local state, re-fetch details on terminal (`done`/`error`) to pick up the refreshed `jobs` row
- `job_progress` → update a progress bar for the active job
- Close on unmount
## Data flow after the change
1. User approves plan in Review → plan.status = approved
2. User clicks `Run all` in Queued column header → queued jobs start
3. Processing column shows the running job with live progress (unchanged)
4. Job finishes → `jobs.status = done`, `review_plans.status = done`. No Jellyfin refresh, no verified flip.
5. Card lands in Done column with a `done` badge. No ✓/✓✓ glyph.
6. Next scan (automatic or manual) re-analyzes the file. If still `is_noop = 1`, plan stays `done`; if not, plan returns to `pending` and the card reappears in Review.
## Testing
- Delete `server/services/__tests__/webhook.test.ts:186-240` — the "webhook_verified flag" describe block. The remaining webhook tests (status transitions, upserts) stay.
- No new tests required: this spec removes features, does not add behavior.
## Guided Gates
- **GG-1:** After deploy, confirm the Done column shows cards with only a `done`/`error` badge — no ✓ or ✓✓ glyph.
- **GG-2:** Click an item in Done → details page shows the job section below the FFmpeg command box, with `Cmd` and `Log` toggles.
- **GG-3:** Click an item in Queued → details page shows a pending job with working `▶ Run` and `✕ Cancel` buttons; running the job updates the badge live.
- **GG-4:** `/execute` in the browser returns a 404 (route is gone).
- **GG-5:** `Run all` and `Clear queue` buttons appear in the Queued column header; `Clear` stays in the Done column header.
- **GG-6:** `PRAGMA table_info(review_plans);` in the SQLite DB no longer lists a `verified` column.

View File

@@ -1,6 +1,6 @@
{
"name": "netfelix-audio-fix",
"version": "2026.04.14.24",
"version": "2026.04.15.1",
"scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite",

View File

@@ -1,12 +1,9 @@
import { accessSync, constants } from "node:fs";
import { Hono } from "hono";
import { stream } from "hono/streaming";
import { getAllConfig, getDb } from "../db/index";
import { getDb } from "../db/index";
import { log, error as logError, warn } from "../lib/log";
import { predictExtractedFiles } from "../services/ffmpeg";
import { getItem, refreshItem } from "../services/jellyfin";
import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "../services/radarr";
import { type RescanConfig, upsertJellyfinItem } from "../services/rescan";
import {
getScheduleConfig,
isInProcessWindow,
@@ -15,88 +12,9 @@ import {
sleepBetweenJobs,
waitForProcessWindow,
} from "../services/scheduler";
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "../services/sonarr";
import { verifyDesiredState } from "../services/verify";
import type { Job, MediaItem, MediaStream } from "../types";
/**
* Post-job hand-off to Jellyfin. Three phases:
* 1. refreshItem — ask Jellyfin to re-probe the file on disk and wait
* until its DateLastRefreshed advances (or 15s timeout).
* 2. getItem — pull back the freshly-probed metadata.
* 3. upsertJellyfinItem(source='webhook') — re-run the analyzer against
* Jellyfin's view. If it matches the plan (is_noop=1), sets verified=1
* — the ✓✓ in the Done column. If Jellyfin sees a different layout
* (is_noop=0) the plan flips back to 'pending' so the user notices.
*
* This closes the previously-dangling "we asked Jellyfin to refresh but
* never checked what it saw" gap. Earlier attempt used our own ffprobe of
* the output, but that was tautological — ffmpeg just wrote the file to
* match the plan, so the check always passed immediately. Jellyfin is the
* independent observer that matters.
*/
export async function handOffToJellyfin(itemId: number): Promise<void> {
const db = getDb();
const row = db.prepare("SELECT jellyfin_id FROM media_items WHERE id = ?").get(itemId) as
| { jellyfin_id: string }
| undefined;
if (!row) return;
const cfg = getAllConfig();
const jellyfinCfg = { url: cfg.jellyfin_url, apiKey: cfg.jellyfin_api_key, userId: cfg.jellyfin_user_id };
if (!jellyfinCfg.url || !jellyfinCfg.apiKey) return;
let refreshResult: { refreshed: boolean };
try {
refreshResult = await refreshItem(jellyfinCfg, row.jellyfin_id);
} catch (err) {
warn(`Jellyfin refresh for item ${itemId} failed: ${String(err)} — skipping verification`);
return;
}
if (!refreshResult.refreshed) {
// DateLastRefreshed never advanced within the timeout — Jellyfin may
// still be probing asynchronously. We can't trust the item data we'd
// fetch right now, so skip the verify step; the plan stays verified=0
// (single ✓) rather than risk flipping it based on stale metadata.
warn(`Jellyfin refresh for item ${itemId} timed out — leaving plan unverified`);
return;
}
try {
const fresh = await getItem(jellyfinCfg, row.jellyfin_id);
if (!fresh) {
warn(`Jellyfin returned no item for ${row.jellyfin_id} during verification`);
return;
}
const radarrCfg = { url: cfg.radarr_url, apiKey: cfg.radarr_api_key };
const sonarrCfg = { url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key };
const radarrEnabled = cfg.radarr_enabled === "1" && radarrUsable(radarrCfg);
const sonarrEnabled = cfg.sonarr_enabled === "1" && sonarrUsable(sonarrCfg);
const [radarrLibrary, sonarrLibrary] = await Promise.all([
radarrEnabled ? loadRadarrLibrary(radarrCfg) : Promise.resolve(null),
sonarrEnabled ? loadSonarrLibrary(sonarrCfg) : Promise.resolve(null),
]);
const audioLanguages = JSON.parse(cfg.audio_languages || "[]") as string[];
const rescanCfg: RescanConfig = {
audioLanguages,
radarr: radarrEnabled ? radarrCfg : null,
sonarr: sonarrEnabled ? sonarrCfg : null,
radarrLibrary,
sonarrLibrary,
};
const result = await upsertJellyfinItem(db, fresh, rescanCfg, { source: "webhook" });
log(`Post-job verify for item ${itemId}: is_noop=${result.isNoop}`);
// Nudge connected clients so the Done column re-polls and promotes
// the card from ✓ to ✓✓ (or flips it back to Review if jellyfin
// disagreed).
emitPlanUpdate(itemId);
} catch (err) {
warn(`Post-job verification for item ${itemId} failed: ${String(err)}`);
}
}
const app = new Hono();
// ─── Sequential local queue ──────────────────────────────────────────────────
@@ -173,18 +91,6 @@ function emitJobProgress(jobId: number, seconds: number, total: number): void {
for (const l of jobListeners) l(line);
}
/**
* Emit when a review_plan mutates asynchronously (after the job already
* finished). Right now the only producer is handOffToJellyfin — the
* verified=1 write that lands ~15s after a job completes. Without this
* the UI would keep showing ✓ indefinitely until the user navigates
* away and back, since we'd never fire job_update again for that item.
*/
function emitPlanUpdate(itemId: number): void {
const line = `event: plan_update\ndata: ${JSON.stringify({ itemId })}\n\n`;
for (const l of jobListeners) l(line);
}
/** Parse "Duration: HH:MM:SS.MS" from ffmpeg startup output. */
function parseFFmpegDuration(line: string): number | null {
const match = line.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/);
@@ -231,62 +137,6 @@ function loadJobRow(jobId: number) {
return { job: row as unknown as Job, item };
}
// ─── List ─────────────────────────────────────────────────────────────────────
app.get("/", (c) => {
const db = getDb();
const filter = (c.req.query("filter") ?? "pending") as "all" | "pending" | "running" | "done" | "error";
const validFilters = ["all", "pending", "running", "done", "error"];
const whereClause = validFilters.includes(filter) && filter !== "all" ? `WHERE j.status = ?` : "";
const params = whereClause ? [filter] : [];
const jobRows = db
.prepare(`
SELECT j.*, mi.name, mi.type, mi.series_name, mi.season_number, mi.episode_number, mi.file_path
FROM jobs j
LEFT JOIN media_items mi ON mi.id = j.item_id
${whereClause}
ORDER BY j.created_at DESC
LIMIT 200
`)
.all(...params) as (Job & {
name: string;
type: string;
series_name: string | null;
season_number: number | null;
episode_number: number | null;
file_path: string;
})[];
const jobs = jobRows.map((r) => ({
job: r as unknown as Job,
item: r.name
? ({
id: r.item_id,
name: r.name,
type: r.type,
series_name: r.series_name,
season_number: r.season_number,
episode_number: r.episode_number,
file_path: r.file_path,
} as unknown as MediaItem)
: null,
}));
const countRows = db.prepare("SELECT status, COUNT(*) as cnt FROM jobs GROUP BY status").all() as {
status: string;
cnt: number;
}[];
const totalCounts: Record<string, number> = { all: 0, pending: 0, running: 0, done: 0, error: 0 };
for (const row of countRows) {
totalCounts[row.status] = row.cnt;
totalCounts.all += row.cnt;
}
return c.json({ jobs, filter, totalCounts });
});
// ─── Param helpers ────────────────────────────────────────────────────────────
function parseId(raw: string | undefined): number | null {
@@ -354,40 +204,6 @@ app.post("/clear-completed", (c) => {
return c.json({ ok: true, cleared: result.changes });
});
// ─── Verify all unverified done plans ────────────────────────────────────────
// Backfill: kicks off the post-job jellyfin handoff for every plan that's
// status=done + verified=0. Sequential with a small inter-call delay to
// avoid hammering jellyfin's metadata refresher (each one waits up to 15s
// for DateLastRefreshed to advance). Returns immediately with the count;
// each individual handoff emits a plan_update SSE so the UI promotes ✓ → ✓✓
// (or flips back to Review on disagreement) as it lands.
app.post("/verify-unverified", (c) => {
const db = getDb();
const rows = db
.prepare(`
SELECT mi.id as item_id FROM review_plans rp
JOIN media_items mi ON mi.id = rp.item_id
WHERE rp.status = 'done' AND rp.verified = 0
ORDER BY rp.reviewed_at DESC NULLS LAST
`)
.all() as { item_id: number }[];
if (rows.length === 0) return c.json({ ok: true, count: 0 });
(async () => {
for (const row of rows) {
try {
await handOffToJellyfin(row.item_id);
} catch (err) {
warn(`verify-unverified: handoff for item ${row.item_id} threw: ${String(err)}`);
}
}
log(`verify-unverified: processed ${rows.length} unverified done plan(s)`);
})();
return c.json({ ok: true, count: rows.length });
});
// ─── Stop running job ─────────────────────────────────────────────────────────
app.post("/stop", (c) => {
@@ -481,17 +297,9 @@ async function runJob(job: Job): Promise<void> {
"UPDATE jobs SET status = 'done', exit_code = 0, output = ?, completed_at = datetime('now') WHERE id = ?",
)
.run(msg, job.id);
// Preflight matched → file is already correct per our own ffprobe.
// We still hand off to Jellyfin below so its independent re-probe
// drives the ✓✓ verified flag, rather than trusting our check of
// our own output.
db.prepare("UPDATE review_plans SET status = 'done' WHERE item_id = ?").run(job.item_id);
})();
emitJobUpdate(job.id, "done", msg);
// Hand off so Jellyfin re-probes and can corroborate the ✓✓.
handOffToJellyfin(job.item_id).catch((err) =>
warn(`Jellyfin hand-off for item ${job.item_id} failed: ${String(err)}`),
);
return;
}
log(`Job ${job.id} preflight: ${verify.reason} — running FFmpeg`);
@@ -599,16 +407,6 @@ async function runJob(job: Job): Promise<void> {
log(`Job ${job.id} completed successfully`);
emitJobUpdate(job.id, "done", fullOutput);
// Fire-and-forget hand-off. Jellyfin re-probes the file we just wrote,
// we wait for DateLastRefreshed to advance, then re-analyze its fresh
// view. Setting verified=1 only happens when Jellyfin's independent
// probe confirms is_noop=1. If its view disagrees the plan flips back
// to 'pending' so the user notices — better than silently rubber-
// stamping a bad output as ✓✓.
handOffToJellyfin(job.item_id).catch((err) =>
warn(`Jellyfin hand-off for item ${job.item_id} failed: ${String(err)}`),
);
} catch (err) {
logError(`Job ${job.id} failed:`, err);
const fullOutput = `${outputLines.join("\n")}\n${String(err)}`;

View File

@@ -4,7 +4,7 @@ import { isOneOf, parseId } from "../lib/validate";
import { analyzeItem, assignTargetOrder } from "../services/analyzer";
import { buildCommand } from "../services/ffmpeg";
import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin";
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types";
import type { Job, MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types";
const app = new Hono();
@@ -110,7 +110,7 @@ function rowToPlan(r: RawRow): ReviewPlan | null {
function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
const item = db.prepare("SELECT * FROM media_items WHERE id = ?").get(itemId) as MediaItem | undefined;
if (!item) return { item: null, streams: [], plan: null, decisions: [], command: null };
if (!item) return { item: null, streams: [], plan: null, decisions: [], command: null, job: null };
const streams = db
.prepare("SELECT * FROM media_streams WHERE item_id = ? ORDER BY stream_index")
@@ -122,7 +122,15 @@ function loadItemDetail(db: ReturnType<typeof getDb>, itemId: number) {
const command = plan && !plan.is_noop ? buildCommand(item, streams, decisions) : null;
return { item, streams, plan: plan ?? null, decisions, command };
const job = db
.prepare(
`SELECT id, item_id, command, job_type, status, output, exit_code,
created_at, started_at, completed_at
FROM jobs WHERE item_id = ? ORDER BY created_at DESC LIMIT 1`,
)
.get(itemId) as Job | undefined;
return { item, streams, plan: plan ?? null, decisions, command, job: job ?? null };
}
/**
@@ -327,7 +335,7 @@ app.get("/pipeline", (c) => {
const done = db
.prepare(`
SELECT j.*, mi.name, mi.series_name, mi.type,
rp.job_type, rp.apple_compat, rp.verified
rp.job_type, rp.apple_compat
FROM jobs j
JOIN media_items mi ON mi.id = j.item_id
JOIN review_plans rp ON rp.item_id = j.item_id
@@ -770,7 +778,7 @@ app.post("/:id/reopen", (c) => {
db.transaction(() => {
// Leave plan.notes alone so the user keeps any ffmpeg error summary
// from the prior run — useful context when redeciding decisions.
db.prepare("UPDATE review_plans SET status = 'pending', verified = 0, reviewed_at = NULL WHERE id = ?").run(plan.id);
db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE id = ?").run(plan.id);
db.prepare("DELETE FROM jobs WHERE item_id = ? AND status IN ('done', 'error')").run(id);
})();
return c.json({ ok: true });

View File

@@ -78,6 +78,7 @@ function migrate(db: Database): void {
// signal would come from our own ffprobe, not from a Jellyfin webhook.
// RENAME COLUMN preserves values; both alters are no-ops on fresh DBs.
alter("ALTER TABLE review_plans RENAME COLUMN webhook_verified TO verified");
alter("ALTER TABLE review_plans DROP COLUMN verified");
}
function seedDefaults(db: Database): void {

View File

@@ -70,11 +70,6 @@ CREATE TABLE IF NOT EXISTS review_plans (
subs_extracted INTEGER NOT NULL DEFAULT 0,
notes TEXT,
reviewed_at TEXT,
-- An independent post-hoc check has confirmed the on-disk file matches
-- the plan: either the analyzer saw is_noop=1 on first scan, or after
-- a job completed we ffprobed the output file and it agreed with the
-- kept/removed stream decisions. Surfaces as the ✓✓ in the Done column.
verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -182,61 +182,3 @@ describe("processWebhookEvent — done-status override", () => {
expect(planStatusFor(db, fresh.Id)).toBe("pending");
});
});
describe("processWebhookEvent — webhook_verified flag", () => {
beforeEach(() => _resetDedupe());
async function runWebhook(db: Database, item: JellyfinItem, cfg: RescanConfig = RESCAN_CFG) {
return processWebhookEvent(
{ NotificationType: "ItemAdded", ItemId: item.Id, ItemType: item.Type as "Movie" | "Episode" },
{ db, jellyfin: JF, rescanCfg: cfg, getItemFn: async () => item },
);
}
function verifiedFor(db: Database, jellyfinId: string): number {
return (
db
.prepare(
"SELECT rp.verified as v FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE mi.jellyfin_id = ?",
)
.get(jellyfinId) as { v: number }
).v;
}
test("is_noop=1 on first scan sets webhook_verified=1 (no Jellyfin round-trip needed)", async () => {
const db = makeDb();
const fresh = fakeItem();
await runWebhook(db, fresh);
expect(verifiedFor(db, fresh.Id)).toBe(1);
});
test("a post-execute webhook that still says is_noop=1 keeps webhook_verified=1", async () => {
const db = makeDb();
const fresh = fakeItem();
await runWebhook(db, fresh);
_resetDedupe();
await runWebhook(db, fresh);
expect(verifiedFor(db, fresh.Id)).toBe(1);
});
test("webhook that flips plan off-noop clears webhook_verified back to 0", async () => {
const db = makeDb();
const noopItem = fakeItem();
await runWebhook(db, noopItem);
expect(verifiedFor(db, noopItem.Id)).toBe(1);
// Second probe: Jellyfin reports a drifted file (extra french track
// that the 'deu' language config would now remove → is_noop=0).
const driftedCfg: RescanConfig = { ...RESCAN_CFG, audioLanguages: ["deu"] };
const drifted = fakeItem({
MediaStreams: [
{ Type: "Video", Index: 0, Codec: "h264" },
{ Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true },
{ Type: "Audio", Index: 2, Codec: "aac", Language: "fra" },
],
});
_resetDedupe();
await runWebhook(db, drifted, driftedCfg);
expect(verifiedFor(db, noopItem.Id)).toBe(0);
});
});

View File

@@ -229,17 +229,10 @@ export async function upsertJellyfinItem(
// commit that made done terminal)
// error → pending (retry loop)
// else keep current status
//
// `verified` tracks whether we have independent confirmation the file
// matches the plan. Set to 1 whenever is_noop=1 on a fresh analysis
// (an unchanged file is already in its desired end state). Post-
// execute, execute.ts re-runs verifyDesiredState and flips this on
// when ffprobe agrees. Cleared the moment a webhook says the file
// drifted off-noop.
db
.prepare(`
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes, verified)
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?)
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
VALUES (?, 'pending', ?, ?, ?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET
status = CASE
WHEN excluded.is_noop = 1 THEN 'done'
@@ -252,12 +245,7 @@ export async function upsertJellyfinItem(
confidence = excluded.confidence,
apple_compat = excluded.apple_compat,
job_type = excluded.job_type,
notes = excluded.notes,
verified = CASE
WHEN excluded.is_noop = 1 THEN 1
WHEN ? = 'webhook' THEN 0
ELSE review_plans.verified
END
notes = excluded.notes
`)
.run(
itemId,
@@ -266,9 +254,7 @@ export async function upsertJellyfinItem(
analysis.apple_compat,
analysis.job_type,
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
analysis.is_noop ? 1 : 0,
source,
source,
source, // for the CASE WHEN ? = 'webhook' branch
);
const planRow = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number };

View File

@@ -65,7 +65,6 @@ export interface ReviewPlan {
subs_extracted: number;
notes: string | null;
reviewed_at: string | null;
verified: number;
created_at: string;
}

View File

@@ -1,334 +0,0 @@
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "~/shared/components/ui/badge";
import { Button } from "~/shared/components/ui/button";
import { FilterTabs } from "~/shared/components/ui/filter-tabs";
import { api } from "~/shared/lib/api";
import type { Job, MediaItem } from "~/shared/lib/types";
interface JobEntry {
job: Job;
item: MediaItem | null;
}
interface ExecuteData {
jobs: JobEntry[];
filter: string;
totalCounts: Record<string, number>;
}
const FILTER_TABS = [
{ key: "all", label: "All" },
{ key: "pending", label: "Pending" },
{ key: "running", label: "Running" },
{ key: "done", label: "Done" },
{ key: "error", label: "Error" },
];
function itemName(job: Job, item: MediaItem | null): string {
if (!item) return `Item #${job.item_id}`;
if (item.type === "Episode" && item.series_name) {
return `${item.series_name} S${String(item.season_number ?? 0).padStart(2, "0")}E${String(item.episode_number ?? 0).padStart(2, "0")}`;
}
return item.name;
}
function jobTypeLabel(job: Job): string {
return job.job_type === "transcode" ? "Audio Transcode" : "Audio Remux";
}
// Module-level cache for instant tab switching
const cache = new Map<string, ExecuteData>();
export function ExecutePage() {
const { filter } = useSearch({ from: "/execute" });
const navigate = useNavigate();
const [data, setData] = useState<ExecuteData | null>(cache.get(filter) ?? null);
const [loading, setLoading] = useState(!cache.has(filter));
const [logs, setLogs] = useState<Map<number, string>>(new Map());
const [logVisible, setLogVisible] = useState<Set<number>>(new Set());
const [cmdVisible, setCmdVisible] = useState<Set<number>>(new Set());
const esRef = useRef<EventSource | null>(null);
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const load = useCallback(
(f?: string) => {
const key = f ?? filter;
const cached = cache.get(key);
if (cached && key === filter) {
setData(cached);
setLoading(false);
} else if (key === filter) {
setLoading(true);
}
api
.get<ExecuteData>(`/api/execute?filter=${key}`)
.then((d) => {
cache.set(key, d);
if (key === filter) {
setData(d);
setLoading(false);
}
})
.catch(() => {
if (key === filter) setLoading(false);
});
},
[filter],
);
useEffect(() => {
load();
}, [load]);
// SSE for live job updates
useEffect(() => {
const es = new EventSource("/api/execute/events");
esRef.current = es;
es.addEventListener("job_update", (e) => {
const d = JSON.parse(e.data) as { id: number; status: string; output?: string };
// Update job in current list if present
setData((prev) => {
if (!prev) return prev;
const jobIdx = prev.jobs.findIndex((j) => j.job.id === d.id);
if (jobIdx === -1) return prev;
const oldStatus = prev.jobs[jobIdx].job.status;
const newStatus = d.status as Job["status"];
// Live-update totalCounts
const newCounts = { ...prev.totalCounts };
if (oldStatus !== newStatus) {
if (newCounts[oldStatus] != null) newCounts[oldStatus]--;
if (newCounts[newStatus] != null) newCounts[newStatus]++;
else newCounts[newStatus] = 1;
}
return {
...prev,
totalCounts: newCounts,
jobs: prev.jobs.map((j) => (j.job.id === d.id ? { ...j, job: { ...j.job, status: newStatus } } : j)),
};
});
if (d.output !== undefined) {
setLogs((prev) => {
const m = new Map(prev);
m.set(d.id, d.output!);
return m;
});
}
// Debounced reload on terminal state for accurate list
if (d.status === "done" || d.status === "error") {
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = setTimeout(() => {
// Invalidate cache and reload current filter
cache.clear();
load();
}, 1000);
}
});
return () => {
es.close();
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
};
}, [load]);
const startAll = async () => {
await api.post("/api/execute/start");
cache.clear();
load();
};
const clearQueue = async () => {
await api.post("/api/execute/clear");
cache.clear();
load();
};
const clearCompleted = async () => {
await api.post("/api/execute/clear-completed");
cache.clear();
load();
};
const runJob = async (id: number) => {
await api.post(`/api/execute/job/${id}/run`);
cache.clear();
load();
};
const cancelJob = async (id: number) => {
await api.post(`/api/execute/job/${id}/cancel`);
cache.clear();
load();
};
const toggleLog = (id: number) =>
setLogVisible((prev) => {
const s = new Set(prev);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
const toggleCmd = (id: number) =>
setCmdVisible((prev) => {
const s = new Set(prev);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
const totalCounts = data?.totalCounts ?? { all: 0, pending: 0, running: 0, done: 0, error: 0 };
const pending = totalCounts.pending ?? 0;
const done = totalCounts.done ?? 0;
const errors = totalCounts.error ?? 0;
const jobs = data?.jobs ?? [];
const running = totalCounts.running ?? 0;
const allDone = totalCounts.all > 0 && pending === 0 && running === 0;
return (
<div>
<h1 className="text-xl font-bold mb-4">Execute Jobs</h1>
<div className="border border-gray-200 rounded-lg px-4 py-3 mb-6 flex items-center gap-3 flex-wrap">
{totalCounts.all === 0 && !loading && <span className="text-sm text-gray-500">No jobs yet.</span>}
{totalCounts.all === 0 && loading && <span className="text-sm text-gray-400">Loading...</span>}
{allDone && <span className="text-sm font-medium">All jobs completed</span>}
{running > 0 && (
<span className="text-sm font-medium">
{running} job{running !== 1 ? "s" : ""} running
</span>
)}
{pending > 0 && (
<>
<span className="text-sm font-medium">
{pending} job{pending !== 1 ? "s" : ""} pending
</span>
<Button size="sm" onClick={startAll}>
Run all pending
</Button>
<Button size="sm" variant="secondary" onClick={clearQueue}>
Clear queue
</Button>
</>
)}
{(done > 0 || errors > 0) && (
<Button size="sm" variant="secondary" onClick={clearCompleted}>
Clear done/errors
</Button>
)}
</div>
<FilterTabs
tabs={FILTER_TABS}
filter={filter}
totalCounts={totalCounts}
onFilterChange={(key) => navigate({ to: "/execute", search: { filter: key } as never })}
/>
{loading && !data && <div className="text-gray-400 py-8 text-center">Loading</div>}
{jobs.length > 0 && (
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{["#", "Item", "Type", "Status", "Actions"].map((h) => (
<th
key={h}
className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{jobs.map(({ job, item }: JobEntry) => {
const name = itemName(job, item);
const jobLog = logs.get(job.id) ?? job.output ?? "";
const showLog = logVisible.has(job.id) || job.status === "running" || job.status === "error";
const showCmd = cmdVisible.has(job.id);
return (
<Fragment key={job.id}>
<tr key={job.id} className="hover:bg-gray-50">
<td className="py-1.5 px-2 border-b border-gray-100 font-mono text-xs">{job.id}</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<div className="truncate max-w-[300px]" title={name}>
{item ? (
<Link
to="/review/audio/$id"
params={{ id: String(item.id) }}
className="text-inherit no-underline hover:underline"
>
{name}
</Link>
) : (
name
)}
</div>
</td>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{jobTypeLabel(job)}</Badge>
</td>
<td className="py-1.5 px-2 border-b border-gray-100">
<Badge variant={job.status}>{job.status}</Badge>
{job.exit_code != null && job.exit_code !== 0 && (
<Badge variant="error" className="ml-1">
exit {job.exit_code}
</Badge>
)}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 whitespace-nowrap">
<div className="flex gap-1 items-center">
{job.status === "pending" && (
<>
<Button size="sm" onClick={() => runJob(job.id)}>
Run
</Button>
<Button size="sm" variant="secondary" onClick={() => cancelJob(job.id)}>
</Button>
</>
)}
<Button size="sm" variant="secondary" onClick={() => toggleCmd(job.id)}>
Cmd
</Button>
{(job.status === "done" || job.status === "error") && jobLog && (
<Button size="sm" variant="secondary" onClick={() => toggleLog(job.id)}>
Log
</Button>
)}
</div>
</td>
</tr>
{showCmd && (
<tr key={`cmd-${job.id}`}>
<td colSpan={5} className="p-0 border-b border-gray-100">
<div className="font-mono text-[0.74rem] bg-gray-50 text-gray-700 px-3.5 py-2.5 rounded max-h-[120px] overflow-y-auto whitespace-pre-wrap break-all">
{job.command}
</div>
</td>
</tr>
)}
{jobLog && showLog && (
<tr key={`log-${job.id}`}>
<td colSpan={5} className="p-0 border-b border-gray-100">
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3.5 py-2.5 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
{jobLog}
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
{!loading && jobs.length === 0 && totalCounts.all > 0 && (
<p className="text-gray-500 text-center py-4">No jobs match this filter.</p>
)}
</div>
);
}

View File

@@ -20,68 +20,33 @@ export function DoneColumn({ items, onMutate }: DoneColumnProps) {
onMutate();
};
const verifyUnverified = async () => {
await api.post("/api/execute/verify-unverified");
// Server processes sequentially in the background; each plan_update
// SSE will trigger a pipeline reload as items get verified.
};
const unverifiedCount = items.filter((i) => i.status === "done" && i.verified !== 1).length;
const actions = [];
if (unverifiedCount > 0) {
actions.push({ label: `Verify ${unverifiedCount}`, onClick: verifyUnverified });
}
if (items.length > 0) {
actions.push({ label: "Clear", onClick: clear });
}
const actions = items.length > 0 ? [{ label: "Clear", onClick: clear }] : undefined;
return (
<ColumnShell title="Done" count={items.length} actions={actions.length > 0 ? actions : undefined}>
{items.map((item) => {
const verified = item.status === "done" && item.verified === 1;
const mark = verified ? "✓✓" : item.status === "done" ? "✓" : "✗";
const markTitle = verified
? "Done — ffprobe confirms the on-disk file matches the plan"
: item.status === "done"
? "Done — awaiting post-job verification"
: "Error";
return (
<div key={item.id} className="group rounded border bg-white p-2">
<div className="flex items-start gap-1.5">
<span
title={markTitle}
className={`font-mono text-xs shrink-0 ${
verified ? "text-green-700" : item.status === "done" ? "text-gray-400" : "text-red-600"
}`}
>
{mark}
</span>
<div className="min-w-0 flex-1">
<Link
to="/review/audio/$id"
params={{ id: String(item.item_id) }}
className="text-xs font-medium truncate block hover:text-blue-600 hover:underline"
>
{item.name}
</Link>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>
<div className="flex-1" />
<button
type="button"
onClick={() => reopen(item.item_id)}
title="Send this item back to the Review column to redecide and re-queue"
className="text-[0.68rem] px-1.5 py-0.5 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
>
Back to review
</button>
</div>
</div>
</div>
<ColumnShell title="Done" count={items.length} actions={actions}>
{items.map((item) => (
<div key={item.id} className="group rounded border bg-white p-2">
<Link
to="/review/audio/$id"
params={{ id: String(item.item_id) }}
className="text-xs font-medium truncate block hover:text-blue-600 hover:underline"
>
{item.name}
</Link>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>
<div className="flex-1" />
<button
type="button"
onClick={() => reopen(item.item_id)}
title="Send this item back to the Review column to redecide and re-queue"
className="text-[0.68rem] px-1.5 py-0.5 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
>
Back to review
</button>
</div>
);
})}
</div>
))}
{items.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No completed items</p>}
</ColumnShell>
);

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "~/shared/components/ui/button";
import { api } from "~/shared/lib/api";
import type { PipelineData } from "~/shared/lib/types";
import { DoneColumn } from "./DoneColumn";
@@ -31,11 +30,6 @@ export function PipelinePage() {
setLoading(false);
}, []);
const startQueue = useCallback(async () => {
await api.post("/api/execute/start");
load();
}, [load]);
useEffect(() => {
load();
}, [load]);
@@ -64,12 +58,6 @@ export function PipelinePage() {
}
scheduleReload();
});
// plan_update lands ~15s after a job finishes — the post-job jellyfin
// verification writes verified=1 (or flips the plan back to pending).
// Without refreshing here the Done column would never promote ✓ to ✓✓.
es.addEventListener("plan_update", () => {
scheduleReload();
});
es.addEventListener("job_progress", (e) => {
setProgress(JSON.parse((e as MessageEvent).data));
});
@@ -88,12 +76,7 @@ export function PipelinePage() {
<div className="flex flex-col -mx-3 sm:-mx-5 -mt-4 -mb-12 h-[calc(100vh-3rem)] overflow-hidden">
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
<h1 className="text-lg font-semibold">Pipeline</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-500">{data.doneCount} files in desired state</span>
<Button variant="primary" size="sm" onClick={startQueue}>
Start queue
</Button>
</div>
<span className="text-sm text-gray-500">{data.doneCount} files in desired state</span>
</div>
<div className="flex flex-1 gap-4 p-4 overflow-x-auto overflow-y-hidden min-h-0">
<ReviewColumn items={data.review} total={data.reviewTotal} jellyfinUrl={data.jellyfinUrl} onMutate={load} />

View File

@@ -10,23 +10,30 @@ interface QueueColumnProps {
}
export function QueueColumn({ items, jellyfinUrl, onMutate }: QueueColumnProps) {
const runAll = async () => {
await api.post("/api/execute/start");
onMutate();
};
const clear = async () => {
if (!confirm(`Cancel all ${items.length} pending jobs?`)) return;
await api.post("/api/execute/clear");
onMutate();
};
const unapprove = async (itemId: number) => {
await api.post(`/api/review/${itemId}/unapprove`);
onMutate();
};
const actions =
items.length > 0
? [
{ label: "Run all", onClick: runAll, primary: true },
{ label: "Clear", onClick: clear },
]
: undefined;
return (
<ColumnShell
title="Queued"
count={items.length}
actions={items.length > 0 ? [{ label: "Clear", onClick: clear }] : undefined}
>
<ColumnShell title="Queued" count={items.length} actions={actions}>
<div className="space-y-2">
{items.map((item) => (
<PipelineCard key={item.id} item={item} jellyfinUrl={jellyfinUrl} onUnapprove={() => unapprove(item.item_id)} />

View File

@@ -6,7 +6,7 @@ import { Button } from "~/shared/components/ui/button";
import { Select } from "~/shared/components/ui/select";
import { api } from "~/shared/lib/api";
import { LANG_NAMES, langName } from "~/shared/lib/lang";
import type { MediaItem, MediaStream, ReviewPlan, StreamDecision } from "~/shared/lib/types";
import type { Job, MediaItem, MediaStream, ReviewPlan, StreamDecision } from "~/shared/lib/types";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -16,6 +16,7 @@ interface DetailData {
plan: ReviewPlan | null;
decisions: StreamDecision[];
command: string | null;
job: Job | null;
}
// ─── Utilities ────────────────────────────────────────────────────────────────
@@ -205,6 +206,114 @@ function TitleInput({ value, onCommit }: { value: string; onCommit: (v: string)
);
}
// ─── Job section ─────────────────────────────────────────────────────────────
interface JobSectionProps {
job: Job;
onMutate: () => void;
}
function JobSection({ job, onMutate }: JobSectionProps) {
const [showCmd, setShowCmd] = useState(false);
const [showLog, setShowLog] = useState(job.status === "error");
const [liveStatus, setLiveStatus] = useState(job.status);
const [liveOutput, setLiveOutput] = useState(job.output ?? "");
const [progress, setProgress] = useState<{ seconds: number; total: number } | null>(null);
useEffect(() => {
setLiveStatus(job.status);
setLiveOutput(job.output ?? "");
}, [job.status, job.output, job.id]);
useEffect(() => {
const es = new EventSource("/api/execute/events");
es.addEventListener("job_update", (e) => {
const d = JSON.parse((e as MessageEvent).data) as { id: number; status: string; output?: string };
if (d.id !== job.id) return;
setLiveStatus(d.status as Job["status"]);
if (d.output !== undefined) setLiveOutput(d.output);
if (d.status === "done" || d.status === "error") onMutate();
});
es.addEventListener("job_progress", (e) => {
const d = JSON.parse((e as MessageEvent).data) as { id: number; seconds: number; total: number };
if (d.id !== job.id) return;
setProgress({ seconds: d.seconds, total: d.total });
});
return () => es.close();
}, [job.id, onMutate]);
const runJob = async () => {
await api.post(`/api/execute/job/${job.id}/run`);
onMutate();
};
const cancelJob = async () => {
await api.post(`/api/execute/job/${job.id}/cancel`);
onMutate();
};
const stopJob = async () => {
await api.post("/api/execute/stop");
onMutate();
};
const typeLabel = job.job_type === "transcode" ? "Audio Transcode" : "Audio Remux";
const exitBadge = job.exit_code != null && job.exit_code !== 0 ? job.exit_code : null;
return (
<div className="mt-6 pt-4 border-t border-gray-200">
<div className="text-gray-400 text-[0.75rem] uppercase tracking-[0.05em] mb-2">Job</div>
<div className="flex items-center gap-2 flex-wrap mb-3">
<Badge variant={liveStatus}>{liveStatus}</Badge>
<Badge variant={job.job_type === "transcode" ? "manual" : "noop"}>{typeLabel}</Badge>
{exitBadge != null && <Badge variant="error">exit {exitBadge}</Badge>}
{job.started_at && <span className="text-gray-500 text-[0.72rem]">started {job.started_at}</span>}
{job.completed_at && <span className="text-gray-500 text-[0.72rem]">completed {job.completed_at}</span>}
<div className="flex-1" />
<Button size="sm" variant="secondary" onClick={() => setShowCmd((v) => !v)}>
Cmd
</Button>
{liveOutput && (
<Button size="sm" variant="secondary" onClick={() => setShowLog((v) => !v)}>
Log
</Button>
)}
{liveStatus === "pending" && (
<>
<Button size="sm" onClick={runJob}>
Run
</Button>
<Button size="sm" variant="secondary" onClick={cancelJob}>
Cancel
</Button>
</>
)}
{liveStatus === "running" && (
<Button size="sm" variant="secondary" onClick={stopJob}>
Stop
</Button>
)}
</div>
{liveStatus === "running" && progress && progress.total > 0 && (
<div className="h-1.5 bg-gray-200 rounded mb-3 overflow-hidden">
<div
className="h-full bg-blue-500 transition-[width] duration-500"
style={{ width: `${Math.min(100, (progress.seconds / progress.total) * 100).toFixed(1)}%` }}
/>
</div>
)}
{showCmd && (
<div className="font-mono text-[0.74rem] bg-gray-50 text-gray-700 px-3 py-2 rounded max-h-[120px] overflow-y-auto whitespace-pre-wrap break-all mb-2">
{job.command}
</div>
)}
{showLog && liveOutput && (
<div className="font-mono text-[0.74rem] bg-[#1a1a1a] text-[#d4d4d4] px-3 py-2 rounded max-h-[260px] overflow-y-auto whitespace-pre-wrap break-all">
{liveOutput}
</div>
)}
</div>
);
}
// ─── Detail page ──────────────────────────────────────────────────────────────
export function AudioDetailPage() {
@@ -347,6 +456,9 @@ export function AudioDetailPage() {
</div>
)}
{/* Job */}
{data.job && <JobSection job={data.job} onMutate={load} />}
{/* Actions */}
{plan?.status === "pending" && !plan.is_noop && (
<div className="flex gap-2 mt-6">

View File

@@ -69,7 +69,6 @@ function RootLayout() {
</NavLink>
<NavLink to="/pipeline">Pipeline</NavLink>
<NavLink to="/review/subtitles">Subtitles</NavLink>
<NavLink to="/execute">Jobs</NavLink>
</div>
<div className="flex-1" />
<div className="flex items-center gap-0.5">

View File

@@ -1,10 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { ExecutePage } from "~/features/execute/ExecutePage";
export const Route = createFileRoute("/execute")({
validateSearch: z.object({
filter: z.enum(["all", "pending", "running", "done", "error"]).default("pending"),
}),
component: ExecutePage,
});

View File

@@ -155,10 +155,6 @@ export interface PipelineJobItem {
file_path?: string;
confidence?: "high" | "low";
is_noop?: number;
// 1 when an independent post-hoc check confirms the on-disk file matches
// the plan (ffprobe after a job, or is_noop=1 on the very first scan).
// Renders as the second checkmark in the Done column.
verified?: number;
transcode_reasons?: string[];
audio_streams?: PipelineAudioStream[];
}