commit d565ab7685388ad8c9a958c94526cc33175b8f4d
parent dd562e8bcfdbe9dcb9a1dd47b33c4874c309c28c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 22 May 2026 15:26:29 -0400
relative progress bar
Diffstat:
9 files changed, 189 insertions(+), 90 deletions(-)
diff --git a/common/controller/channels.ts b/common/controller/channels.ts
@@ -112,6 +112,38 @@ export async function readChannelStat(
};
}
+async function countMissing(
+ paths: Paths,
+ slug: string,
+ ids: ReadonlyArray<string>,
+ predicate: (files: Awaited<ReturnType<typeof readVideoFiles>>) => boolean,
+): Promise<number> {
+ if (ids.length === 0) return 0;
+ const dataDir = path.join(paths.channelsDir, slug, "data");
+ let missing = 0;
+ for (const id of ids) {
+ const files = await readVideoFiles(path.join(dataDir, id));
+ if (predicate(files)) missing++;
+ }
+ return missing;
+}
+
+export function countNotYetDownloaded(
+ paths: Paths,
+ slug: string,
+ ids: ReadonlyArray<string>,
+): Promise<number> {
+ return countMissing(paths, slug, ids, (files) => !isVideoDownloaded(files));
+}
+
+export function countNotYetTranscribed(
+ paths: Paths,
+ slug: string,
+ ids: ReadonlyArray<string>,
+): Promise<number> {
+ return countMissing(paths, slug, ids, (files) => !isVideoTranscribed(files));
+}
+
export async function listChannels(paths: Paths): Promise<ChannelStat[]> {
let entries: Dirent[];
try {
diff --git a/common/jobs/registry.ts b/common/jobs/registry.ts
@@ -7,6 +7,14 @@ export type JobStatus =
| "failed"
| "cancelled";
+export type JobProgressMetric = "downloads" | "transcripts";
+
+export type JobProgress = {
+ metric: JobProgressMetric;
+ initial: number;
+ target: number;
+};
+
export type JobRecord = {
id: string;
kind: string;
@@ -21,6 +29,7 @@ export type JobRecord = {
logPath: string;
child?: ChildProcess;
abortController?: AbortController;
+ progress?: JobProgress;
};
export type QueueSnapshot = {
diff --git a/common/jobs/streamCommand.ts b/common/jobs/streamCommand.ts
@@ -2,7 +2,12 @@ import path from "node:path";
import { mkdir } from "node:fs/promises";
import { createWriteStream, type WriteStream } from "node:fs";
import { execa } from "execa";
-import { getRegistry, newJobId, type JobRecord } from "./registry";
+import {
+ getRegistry,
+ newJobId,
+ type JobProgress,
+ type JobRecord,
+} from "./registry";
import type { Paths } from "../lib/paths";
import { makeSafeController } from "../lib/safeStreamController";
@@ -29,6 +34,7 @@ export type RunManagedFunctionOpts = CommonOpts & {
fn: (
onLog: (line: string) => void,
signal: AbortSignal,
+ setProgress: (snap: JobProgress) => void,
) => Promise<void>;
};
@@ -198,8 +204,12 @@ export async function runManagedFunction(
safe.safeEnqueue(text);
};
+ const setProgress = (snap: JobProgress) => {
+ record.progress = snap;
+ };
+
opts
- .fn(onLog, abort.signal)
+ .fn(onLog, abort.signal, setProgress)
.then(() => {
if (record.status === "cancelled") {
registry.finalize(id, "cancelled");
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -18,6 +18,7 @@
- Sortable column headers on the `/channels` table. Clicking any column sorts client-side; clicking the active column flips direction. Default rendering is unsorted (input order preserved); sort state does not persist across navigation.
### Changed
+- `/jobs/active` progress bars are now per-job instead of per-channel. Each running batch job (download-from-playlist, download-missing, download-missing-subs, sync, retry-bucket, whisper-all, whisper-bucket-downloaded-no-transcript) gets its own bar scaled to the job's own work. `initial` and `target` are snapshotted just before the job's work function runs (not at enqueue time), so the bar starts empty and fills exactly one unit per video downloaded or transcribed by that job. `whisper-all`'s target locks at start (since `runWhisperBatch` only acts on the directories present when it starts). Jobs without a natural metric (store-playlist, build/clean/transcode/clear/remove/refresh-report, single-video runs) get no bar. The old channel-wide bar has been removed.
- "Recent changes" card on the dashboard now clamps to a fixed height with a gradient fade at the bottom; the entire card is a link to `/changelog`.
- `/channels` rows are now dimmed when a channel is excluded from Sync all, matching the existing treatment for build exclusion.
- `/jobs/active` now lists running jobs above queued jobs — both within each channel section and in the section order itself. Channels with running work appear before queued-only channels so the live jobs stay at the top of the page without scrolling. Ordering within each status group still follows the existing `queuedAt`-descending tiebreaker.
diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts
@@ -11,7 +11,11 @@ import {
detectPlatform,
platformQueueKey,
} from "yt-dlp-transcript-common/lib/platform";
-import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
+import {
+ countNotYetDownloaded,
+ readChannelConfig,
+ readChannelStat,
+} from "yt-dlp-transcript-common/controller/channels";
import { generateChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
import { runYtdlp } from "yt-dlp-transcript-common/ytdlp/runYtdlp";
import {
@@ -57,7 +61,32 @@ async function runPipelineAction(
queueKey === undefined ? defaultQueueKey(channelConfig) : queueKey.trim(),
paths,
channelSlug: slug,
- fn: async (onLog, signal) => {
+ fn: async (onLog, signal, setProgress) => {
+ if (mode !== "store-playlist") {
+ const stat = await readChannelStat(paths, slug);
+ if (stat) {
+ let target: number;
+ if (mode === "retry-bucket") {
+ const bucketIds = options?.bucketIds ?? [];
+ const remaining = await countNotYetDownloaded(
+ paths,
+ slug,
+ bucketIds,
+ );
+ target = stat.downloadCount + remaining;
+ } else {
+ target =
+ stat.playlistCount && stat.playlistCount > 0
+ ? stat.playlistCount
+ : stat.videoCount;
+ }
+ setProgress({
+ metric: "downloads",
+ initial: stat.downloadCount,
+ target,
+ });
+ }
+ }
await runYtdlp({
channelSlug: slug,
mode,
diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts
@@ -7,6 +7,10 @@ import {
type AudioFormat,
} from "yt-dlp-transcript-common/lib/channelConfig";
import { TRANSCRIPTION_QUEUE } from "yt-dlp-transcript-common/lib/platform";
+import {
+ countNotYetTranscribed,
+ readChannelStat,
+} from "yt-dlp-transcript-common/controller/channels";
import { runWhisperBatch } from "yt-dlp-transcript-common/controller/whisperBatch";
import { runTranscodeFailures } from "yt-dlp-transcript-common/controller/transcodeFailures";
import { runTranscodeUntranscoded } from "yt-dlp-transcript-common/controller/transcodeUntranscoded";
@@ -57,7 +61,15 @@ export async function transcribeMissingAction(
queueKey: queueKey === undefined ? TRANSCRIPTION_QUEUE : queueKey.trim(),
paths,
channelSlug: slug,
- fn: async (onLog, signal) => {
+ fn: async (onLog, signal, setProgress) => {
+ const stat = await readChannelStat(paths, slug);
+ if (stat) {
+ setProgress({
+ metric: "transcripts",
+ initial: stat.transcriptCount,
+ target: stat.downloadCount,
+ });
+ }
const result = await runWhisperBatch({
channelSlug: slug,
paths,
@@ -98,7 +110,16 @@ export async function transcribeBucketAction(
queueKey: queueKey === undefined ? TRANSCRIPTION_QUEUE : queueKey.trim(),
paths,
channelSlug: slug,
- fn: async (onLog, signal) => {
+ fn: async (onLog, signal, setProgress) => {
+ const stat = await readChannelStat(paths, slug);
+ if (stat) {
+ const remaining = await countNotYetTranscribed(paths, slug, cleaned);
+ setProgress({
+ metric: "transcripts",
+ initial: stat.transcriptCount,
+ target: stat.transcriptCount + remaining,
+ });
+ }
const result = await runWhisperBatch({
channelSlug: slug,
paths,
diff --git a/editor/app/jobs/active/components/ChannelProgressBar.tsx b/editor/app/jobs/active/components/ChannelProgressBar.tsx
@@ -1,59 +0,0 @@
-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
@@ -1,6 +1,9 @@
import type { Metadata } from "next";
import Link from "next/link";
-import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import {
+ getRegistry,
+ type JobProgress,
+} from "yt-dlp-transcript-common/jobs/registry";
import {
readChannelStat,
type ChannelStat,
@@ -11,33 +14,44 @@ import {
RunningJobsList,
type RunningJobsListItem,
} from "../components/RunningJobsList";
-import { ChannelProgressBar } from "./components/ChannelProgressBar";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Active jobs" };
+function computeJobProgressView(
+ snap: JobProgress | undefined,
+ stat: ChannelStat | undefined,
+): RunningJobsListItem["progress"] {
+ if (!snap || !stat) return undefined;
+ const current =
+ snap.metric === "downloads" ? stat.downloadCount : stat.transcriptCount;
+ const range = Math.max(0, snap.target - snap.initial);
+ const advance = Math.max(0, current - snap.initial);
+ const pct =
+ range === 0 ? 100 : Math.max(0, Math.min(100, (advance / range) * 100));
+ return {
+ metric: snap.metric,
+ initial: snap.initial,
+ current,
+ target: snap.target,
+ pct,
+ };
+}
+
export default async function ActiveJobsPage() {
const registry = getRegistry();
- const activeJobs: RunningJobsListItem[] = registry
+ const activeRecords = registry
.list()
.filter((j) => j.status === "running" || j.status === "queued")
.sort((a, b) => {
if (a.status === b.status) return 0;
return a.status === "running" ? -1 : 1;
- })
- .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
+ activeRecords
.map((j) => j.channelSlug)
.filter((s): s is string => Boolean(s)),
),
@@ -53,6 +67,19 @@ export default async function ActiveJobsPage() {
if (stat) channelStats.set(channelSlugs[i], stat);
}
+ const activeJobs: RunningJobsListItem[] = activeRecords.map((j) => ({
+ id: j.id,
+ kind: j.kind,
+ status: j.status as "running" | "queued",
+ queueKey: j.queueKey,
+ channelSlug: j.channelSlug,
+ videoId: j.videoId,
+ progress:
+ j.status === "running" && j.channelSlug
+ ? computeJobProgressView(j.progress, channelStats.get(j.channelSlug))
+ : undefined,
+ }));
+
const jobsBySlug = new Map<string, RunningJobsListItem[]>();
const unassigned: RunningJobsListItem[] = [];
for (const job of activeJobs) {
@@ -96,18 +123,6 @@ export default async function ActiveJobsPage() {
</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>
);
diff --git a/editor/app/jobs/components/RunningJobsList.tsx b/editor/app/jobs/components/RunningJobsList.tsx
@@ -11,6 +11,13 @@ export type RunningJobsListItem = {
queueKey: string;
channelSlug?: string;
videoId?: string;
+ progress?: {
+ metric: "downloads" | "transcripts";
+ initial: number;
+ current: number;
+ target: number;
+ pct: number;
+ };
};
type Props = {
@@ -103,7 +110,41 @@ function JobRow({
{showLog ? "Hide log" : "Show log"}
</button>
</div>
+ {job.progress && <JobProgressBar progress={job.progress} />}
{showLog && <JobLogTail jobId={job.id} initiallyRunning />}
</li>
);
}
+
+function JobProgressBar({
+ progress,
+}: {
+ progress: NonNullable<RunningJobsListItem["progress"]>;
+}) {
+ const label =
+ progress.metric === "downloads"
+ ? `Downloads: ${progress.current} / ${progress.target}`
+ : `Transcripts: ${progress.current} / ${progress.target}`;
+ const fillClass =
+ progress.metric === "downloads"
+ ? "bg-emerald-300 dark:bg-emerald-700"
+ : "bg-emerald-500";
+ return (
+ <div className="flex flex-col gap-1">
+ <div
+ role="progressbar"
+ aria-valuemin={progress.initial}
+ aria-valuemax={progress.target}
+ aria-valuenow={progress.current}
+ aria-label={label}
+ className="relative h-2 w-full overflow-hidden rounded bg-zinc-200 dark:bg-zinc-800"
+ >
+ <div
+ className={`absolute inset-y-0 left-0 ${fillClass}`}
+ style={{ width: `${progress.pct}%` }}
+ />
+ </div>
+ <div className="text-xs text-zinc-500">{label}</div>
+ </div>
+ );
+}