commit 4d4aa8b2543272762a152d460be7bbfd9d701fdd
parent 073cd66f98b4b05c36fc09e679ef1e88815e9ddf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 19 May 2026 17:20:39 -0400
Active jobs UI
Diffstat:
4 files changed, 202 insertions(+), 0 deletions(-)
diff --git a/common/controller/channels.ts b/common/controller/channels.ts
@@ -94,6 +94,24 @@ export async function channelExists(
return exists(path.join(paths.channelsDir, slug, "config.json"));
}
+export async function readChannelStat(
+ paths: Paths,
+ slug: string,
+): Promise<ChannelStat | null> {
+ const config = await readChannelConfig(paths, slug);
+ if (!config) return null;
+ const channelDir = path.join(paths.channelsDir, slug);
+ const counts = await countDataFiles(path.join(channelDir, "data"));
+ return {
+ slug,
+ config,
+ playlistCount: await countPlaylist(path.join(channelDir, "playlist")),
+ videoCount: counts.videos,
+ transcriptCount: counts.transcripts,
+ downloadCount: counts.downloads,
+ };
+}
+
export async function listChannels(paths: Paths): Promise<ChannelStat[]> {
let entries: Dirent[];
try {
diff --git a/editor/app/jobs/active/components/ChannelProgressBar.tsx b/editor/app/jobs/active/components/ChannelProgressBar.tsx
@@ -0,0 +1,59 @@
+type Props = {
+ playlistCount: number | null;
+ videoCount: number;
+ downloadCount: number;
+ transcriptCount: number;
+};
+
+export function ChannelProgressBar({
+ playlistCount,
+ videoCount,
+ downloadCount,
+ transcriptCount,
+}: Props) {
+ const denom =
+ playlistCount && playlistCount > 0
+ ? playlistCount
+ : Math.max(videoCount, downloadCount, transcriptCount, 1);
+ const denomLabel = playlistCount === null ? "?" : String(playlistCount);
+ const clamp = (n: number) => Math.max(0, Math.min(100, n));
+ const transcribedPct = clamp((transcriptCount / denom) * 100);
+ const downloadedOnlyPct = clamp(
+ (Math.max(0, downloadCount - transcriptCount) / denom) * 100,
+ );
+ return (
+ <div className="flex flex-col gap-1">
+ <div
+ role="progressbar"
+ aria-valuemin={0}
+ aria-valuemax={denom}
+ aria-valuenow={downloadCount}
+ aria-label={`Downloads ${downloadCount} of ${denomLabel}, transcripts ${transcriptCount} of ${denomLabel}`}
+ className="relative h-2 w-full overflow-hidden rounded bg-zinc-200 dark:bg-zinc-800"
+ >
+ <div
+ className="absolute inset-y-0 left-0 bg-emerald-500"
+ style={{ width: `${transcribedPct}%` }}
+ />
+ <div
+ className="absolute inset-y-0 bg-emerald-200 dark:bg-emerald-800"
+ style={{
+ left: `${transcribedPct}%`,
+ width: `${downloadedOnlyPct}%`,
+ }}
+ />
+ </div>
+ <div className="text-xs text-zinc-500 flex flex-wrap gap-x-3">
+ <span>
+ Downloads: {downloadCount} / {denomLabel}
+ </span>
+ <span>
+ Transcripts: {transcriptCount} / {denomLabel}
+ </span>
+ {playlistCount === null && (
+ <span className="italic">playlist size unknown</span>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/editor/app/jobs/active/page.tsx b/editor/app/jobs/active/page.tsx
@@ -0,0 +1,124 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import {
+ readChannelStat,
+ type ChannelStat,
+} from "yt-dlp-transcript-common/controller/channels";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { JobsAutoRefresh } from "../components/JobsAutoRefresh";
+import {
+ RunningJobsList,
+ type RunningJobsListItem,
+} from "../components/RunningJobsList";
+import { ChannelProgressBar } from "./components/ChannelProgressBar";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = { title: "Active jobs" };
+
+export default async function ActiveJobsPage() {
+ const registry = getRegistry();
+ const activeJobs: RunningJobsListItem[] = registry
+ .list()
+ .filter((j) => j.status === "running" || j.status === "queued")
+ .map((j) => ({
+ id: j.id,
+ kind: j.kind,
+ status: j.status as "running" | "queued",
+ queueKey: j.queueKey,
+ channelSlug: j.channelSlug,
+ videoId: j.videoId,
+ }));
+
+ const channelSlugs = Array.from(
+ new Set(
+ activeJobs
+ .map((j) => j.channelSlug)
+ .filter((s): s is string => Boolean(s)),
+ ),
+ );
+
+ const paths = getPaths();
+ const statResults = await Promise.all(
+ channelSlugs.map((slug) => readChannelStat(paths, slug)),
+ );
+ const channelStats = new Map<string, ChannelStat>();
+ for (let i = 0; i < channelSlugs.length; i++) {
+ const stat = statResults[i];
+ if (stat) channelStats.set(channelSlugs[i], stat);
+ }
+
+ const jobsBySlug = new Map<string, RunningJobsListItem[]>();
+ const unassigned: RunningJobsListItem[] = [];
+ for (const job of activeJobs) {
+ if (job.channelSlug) {
+ const list = jobsBySlug.get(job.channelSlug) ?? [];
+ list.push(job);
+ jobsBySlug.set(job.channelSlug, list);
+ } else {
+ unassigned.push(job);
+ }
+ }
+
+ return (
+ <div className="flex flex-col gap-4">
+ <JobsAutoRefresh hasNonTerminal={activeJobs.length > 0} />
+ <div className="flex items-center justify-between">
+ <h1 className="text-2xl font-semibold">Active jobs</h1>
+ </div>
+ {activeJobs.length === 0 ? (
+ <p className="text-sm text-zinc-500 border border-dashed border-zinc-300 dark:border-zinc-700 rounded p-4">
+ No active jobs.
+ </p>
+ ) : (
+ <div className="flex flex-col gap-4">
+ {channelSlugs.map((slug) => {
+ const jobs = jobsBySlug.get(slug) ?? [];
+ const stat = channelStats.get(slug);
+ const displayName = stat?.config.name ?? slug;
+ return (
+ <section
+ key={slug}
+ aria-label={`Active jobs for ${displayName}`}
+ className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded-md p-3 bg-white dark:bg-zinc-900"
+ >
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
+ <Link
+ href={`/channels/${slug}`}
+ className="text-base font-medium underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {displayName}
+ </Link>
+ <span className="font-mono text-xs text-zinc-500">{slug}</span>
+ </div>
+ {stat ? (
+ <ChannelProgressBar
+ playlistCount={stat.playlistCount}
+ videoCount={stat.videoCount}
+ downloadCount={stat.downloadCount}
+ transcriptCount={stat.transcriptCount}
+ />
+ ) : (
+ <p className="text-xs text-zinc-500 italic">
+ Channel stats unavailable.
+ </p>
+ )}
+ <RunningJobsList jobs={jobs} hideChannelSlug />
+ </section>
+ );
+ })}
+ {unassigned.length > 0 && (
+ <section
+ aria-label="Active jobs without a channel"
+ className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded-md p-3 bg-white dark:bg-zinc-900"
+ >
+ <h2 className="text-base font-medium">Other</h2>
+ <RunningJobsList jobs={unassigned} />
+ </section>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -28,6 +28,7 @@ const NAV_LINKS: NavLink[] = [
{ href: "/", label: "Dashboard" },
{ href: "/channels", label: "Channels" },
{ href: "/jobs", label: "Jobs", badgeKey: "jobs" },
+ { href: "/jobs/active", label: "Active", badgeKey: "jobs" },
{ href: "/build", label: "Build" },
{ href: "/settings", label: "Settings" },
];