commit 2695aa4949d217d0eb20d21e471b37a31d3ddf95
parent 59259161cf146874e9cfc45e4f04cda7629d52d7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 27 Apr 2026 23:35:12 -0400
feat(editor): show originating channel on jobs and auto-refresh the jobs list
Jobs created from a channel page now record the channel slug. The jobs list
gains a Channel column linking back to /channels/<slug>, the detail page
shows the same link near the status header, and the list auto-refreshes
every 2.5s while at least one job is still running so download progress
updates without a manual reload. Archived (post-restart) jobs render an
em-dash since their channel context is lost when the registry resets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat:
9 files changed, 119 insertions(+), 2 deletions(-)
diff --git a/common/jobs/listJobs.ts b/common/jobs/listJobs.ts
@@ -6,6 +6,7 @@ import { getRegistry, type JobRecord, type JobStatus } from "./registry";
export type JobListEntry = {
id: string;
kind?: string;
+ channelSlug?: string;
status: JobStatus | "archived";
startedAt: number;
endedAt?: number;
@@ -51,6 +52,7 @@ export async function listAllJobs(paths: Paths): Promise<JobListEntry[]> {
out.push({
id,
kind: live_.kind,
+ channelSlug: live_.channelSlug,
status: live_.status,
startedAt: live_.startedAt,
endedAt: live_.endedAt,
@@ -79,6 +81,7 @@ export async function listAllJobs(paths: Paths): Promise<JobListEntry[]> {
out.push({
id: r.id,
kind: r.kind,
+ channelSlug: r.channelSlug,
status: r.status,
startedAt: r.startedAt,
endedAt: r.endedAt,
diff --git a/common/jobs/registry.ts b/common/jobs/registry.ts
@@ -6,6 +6,7 @@ export type JobRecord = {
id: string;
kind: string;
mutexKey?: string;
+ channelSlug?: string;
status: JobStatus;
startedAt: number;
endedAt?: number;
diff --git a/common/jobs/streamCommand.ts b/common/jobs/streamCommand.ts
@@ -14,6 +14,7 @@ type CommonOpts = {
mutexKey: string;
paths: Paths;
busyMessage?: string;
+ channelSlug?: string;
};
export type RunManagedCommandOpts = CommonOpts & {
@@ -38,6 +39,7 @@ function makeJob(
kind: string,
mutexKey: string,
paths: Paths,
+ channelSlug?: string,
): { id: string; logPath: string; record: JobRecord } {
const id = newJobId();
const logPath = path.join(paths.jobsDir, `${id}.log`);
@@ -45,6 +47,7 @@ function makeJob(
id,
kind,
mutexKey,
+ channelSlug,
status: "running",
startedAt: Date.now(),
logPath,
@@ -64,7 +67,12 @@ export async function runManagedCommand(
};
}
await ensureJobsDir(opts.paths);
- const { id, logPath, record } = makeJob(opts.kind, opts.mutexKey, opts.paths);
+ const { id, logPath, record } = makeJob(
+ opts.kind,
+ opts.mutexKey,
+ opts.paths,
+ opts.channelSlug,
+ );
const child = execa(opts.command, opts.args, {
cwd: opts.cwd,
@@ -147,7 +155,12 @@ export async function runManagedFunction(
};
}
await ensureJobsDir(opts.paths);
- const { id, logPath, record } = makeJob(opts.kind, opts.mutexKey, opts.paths);
+ const { id, logPath, record } = makeJob(
+ opts.kind,
+ opts.mutexKey,
+ opts.paths,
+ opts.channelSlug,
+ );
const abort = new AbortController();
record.abortController = abort;
diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts
@@ -26,6 +26,7 @@ async function runPipelineAction(
kind,
mutexKey: `channel:${slug}`,
paths,
+ channelSlug: slug,
busyMessage: `Channel "${slug}" already has an operation running`,
fn: async (onLog, signal) => {
await runYtdlp({
diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts
@@ -17,6 +17,7 @@ export async function transcribeMissingAction(
kind: "whisper-all",
mutexKey: `channel:${slug}`,
paths,
+ channelSlug: slug,
busyMessage: `Channel "${slug}" already has an operation running`,
fn: async (onLog, signal) => {
const result = await runWhisperBatch({
@@ -42,6 +43,7 @@ export async function retryFailuresAction(
kind: "whisper-retry",
mutexKey: `channel:${slug}`,
paths,
+ channelSlug: slug,
busyMessage: `Channel "${slug}" already has an operation running`,
fn: async (onLog, signal) => {
const result = await runWhisperBatch({
diff --git a/editor/app/jobs/[id]/page.tsx b/editor/app/jobs/[id]/page.tsx
@@ -29,6 +29,17 @@ export default async function JobDetailPage({
<span className="font-mono">{id}</span>
</div>
<h1 className="text-2xl font-semibold">{job.kind ?? "job"}</h1>
+ {job.channelSlug && (
+ <p className="text-sm" data-testid="job-channel">
+ Channel:{" "}
+ <Link
+ href={`/channels/${job.channelSlug}`}
+ className="underline font-mono hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {job.channelSlug}
+ </Link>
+ </p>
+ )}
<dl className="text-sm grid grid-cols-2 sm:grid-cols-4 gap-3">
<Cell label="Status" value={job.status} />
<Cell label="Started" value={new Date(job.startedAt).toLocaleString()} />
diff --git a/editor/app/jobs/_components/JobsAutoRefresh.tsx b/editor/app/jobs/_components/JobsAutoRefresh.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+type Props = {
+ hasNonTerminal: boolean;
+};
+
+export function JobsAutoRefresh({ hasNonTerminal }: Props) {
+ const router = useRouter();
+ useEffect(() => {
+ if (!hasNonTerminal) return;
+ const id = setInterval(() => router.refresh(), 2_500);
+ return () => clearInterval(id);
+ }, [hasNonTerminal, router]);
+ return null;
+}
diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx
@@ -3,6 +3,7 @@ import { listAllJobs } from "yt-dlp-transcript-common/jobs/listJobs";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { ClearArchivedButton } from "./_components/ClearArchivedButton";
import { CancelJobButton } from "./_components/CancelJobButton";
+import { JobsAutoRefresh } from "./_components/JobsAutoRefresh";
export const dynamic = "force-dynamic";
@@ -31,8 +32,10 @@ function statusColor(status: string): string {
export default async function JobsPage() {
const jobs = await listAllJobs(getPaths());
+ const hasNonTerminal = jobs.some((j) => j.status === "running");
return (
<div className="flex flex-col gap-4">
+ <JobsAutoRefresh hasNonTerminal={hasNonTerminal} />
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Jobs</h1>
<ClearArchivedButton />
@@ -53,6 +56,7 @@ export default async function JobsPage() {
<tr>
<th className="text-left font-medium px-3 py-2">ID</th>
<th className="text-left font-medium px-3 py-2">Kind</th>
+ <th className="text-left font-medium px-3 py-2">Channel</th>
<th className="text-left font-medium px-3 py-2">Status</th>
<th className="text-left font-medium px-3 py-2">Started</th>
<th className="text-left font-medium px-3 py-2">Duration</th>
@@ -80,6 +84,21 @@ export default async function JobsPage() {
</Link>
</td>
<td className="px-3 py-2">{j.kind ?? "—"}</td>
+ <td
+ className="px-3 py-2 font-mono text-xs"
+ data-testid={`job-row-channel-${j.id}`}
+ >
+ {j.channelSlug ? (
+ <Link
+ href={`/channels/${j.channelSlug}`}
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {j.channelSlug}
+ </Link>
+ ) : (
+ "—"
+ )}
+ </td>
<td className="px-3 py-2">
<span
className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusColor(j.status)}`}
diff --git a/editor/cypress/e2e/jobs-channel.cy.ts b/editor/cypress/e2e/jobs-channel.cy.ts
@@ -0,0 +1,49 @@
+describe("Job→channel link and jobs list auto-refresh", () => {
+ it("shows the originating channel as a link on the jobs list and detail", () => {
+ cy.resetData("test-pipeline");
+ cy.visit("/channels/test-pipeline");
+ cy.findByTestId("pipeline-store").find("button").click();
+ cy.findByTestId("pipeline-store-log", { timeout: 20_000 }).should(
+ "contain.text",
+ "Wrote 5 URLs",
+ );
+
+ cy.visit("/jobs");
+ cy.get('[data-testid^="job-row-"]')
+ .first()
+ .invoke("attr", "data-testid")
+ .then((testId) => {
+ const id = (testId as string).replace(/^job-row-/, "");
+ cy.findByTestId(`job-row-channel-${id}`)
+ .find("a")
+ .should("have.attr", "href", "/channels/test-pipeline")
+ .and("contain.text", "test-pipeline");
+ cy.visit(`/jobs/${id}`);
+ cy.findByTestId("job-channel")
+ .find("a")
+ .should("have.attr", "href", "/channels/test-pipeline");
+ });
+ });
+
+ it("auto-refreshes the jobs list when a job is running", () => {
+ cy.resetData("slow-pipeline-channel");
+ cy.visit("/channels/slow-channel");
+ cy.findByTestId("pipeline-sync").find("button").click();
+ cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should(
+ "contain.text",
+ "cypress-slow",
+ );
+
+ // Switch to the jobs list and don't manually reload. The status should
+ // start as "running" and flip to "cancelled" within ~5s after we cancel
+ // the underlying job.
+ cy.visit("/jobs");
+ cy.get('[data-testid^="job-row-"]').first().contains(/^running$/i);
+
+ cy.get('[data-testid^="cancel-"]').first().click();
+ // Don't visit again — the auto-refresh should pick up the status change.
+ cy.get('[data-testid^="job-row-"]')
+ .first()
+ .contains(/^cancelled$/i, { timeout: 10_000 });
+ });
+});