import path from "node:path"; import { open } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import type { JobRecord } from "yt-dlp-transcript-common/jobs/registry"; import { readJobMeta } from "yt-dlp-transcript-common/jobs/jobMeta"; import { readChannelStat } from "yt-dlp-transcript-common/controller/channels"; import { listAllJobs } from "yt-dlp-transcript-common/jobs/listJobs"; import type { Paths } from "yt-dlp-transcript-common/lib/paths"; import { diskGate } from "yt-dlp-transcript-common/lib/diskSpace"; import { getAutoRunnerStatus } from "yt-dlp-transcript-common/controller/autoRunner"; import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; import { buildActiveJobsPayload as build, liveJobRows as rows, rowsFromJobsPage, stuckJobIds as stuck, type ActiveJobsInputs, type ActiveJobsPayload, type LiveJobRowsInputs, } from "yt-dlp-transcript-common/views/activeJobs"; import { liveInputs } from "../../lib/liveInputs"; // THE SHELL. The payload is `common/views/activeJobs.ts`, a pure function of // its arguments; everything here is the reading that view refuses to do — the // log tail, the channel stats, the meta sidecars, the disk gate and the runner // statuses. The exported names, their signatures and their values are what // they were, so all nine consumers are untouched. // How much of the log tail to read for the one line we want. A transcription // job's log runs to megabytes and this used to read all of it, per slot, per // poll, to display its final line. const TAIL_BYTES = 8 * 1024; // Cheap best-effort tail: read the END of the log file and return its last // non-empty line. Swallows every error (missing file, read failure) — this is // diagnostic sugar, never load-bearing. // // Reading a fixed window from the end means a log whose last 8 KB is entirely // blank yields nothing rather than scanning back further; that is the intended // trade for a status line, and the first (possibly truncated) line in the // window is discarded so a partial line is never displayed as a whole one. async function readLastLogLine( jobsDir: string, id: string, ): Promise { const file = path.join(jobsDir, `${id}.log`); let handle: FileHandle | undefined; try { handle = await open(file, "r"); const { size } = await handle.stat(); const start = Math.max(0, size - TAIL_BYTES); const length = size - start; if (length <= 0) return undefined; const buf = Buffer.alloc(length); await handle.read(buf, 0, length, start); const lines = buf.toString("utf8").split("\n"); // Mid-file window: drop the leading fragment, which may be half a line. const from = start > 0 ? 1 : 0; for (let n = lines.length - 1; n >= from; n--) { const line = lines[n].trim(); if (line) return line; } } catch { /* best-effort */ } finally { await handle?.close().catch(() => {}); } return undefined; } // The rows half of the inputs: no disk gate, no log tail, no runner statuses. // A page listing its own jobs must not pay for — or perform — any of those. function rowInputs(): LiveJobRowsInputs { const i = liveInputs(); return { ...i, channelStat: (slug) => readChannelStat(i.paths, slug) }; } // The full payload's inputs. The gate is sampled in "observe" mode — a UI poll, // several times a minute. It reports the state the runners are actually in // (latch included, so a pipeline held for the resume margin reads as stopped // rather than green) without being the thing that moves that latch. async function payloadInputs(): Promise { const i = rowInputs(); return { ...i, jobMeta: (id) => readJobMeta(i.paths, id), tailLog: (id) => readLastLogLine(i.paths.jobsDir, id), disk: await diskGate(i.paths, i.settings, { mode: "observe" }), autoRunnerStatus: getAutoRunnerStatus, }; } export async function liveJobRows( filter: (j: JobRecord) => boolean, ): Promise { return rows(rowInputs(), filter); } export async function listJobRows( paths: Paths, opts: { limit?: number; before?: number } = {}, ): Promise<{ rows: JobRowView[]; hasMore: boolean; total: number }> { return rowsFromJobsPage(await listAllJobs(paths, opts)); } export async function buildActiveJobsPayload(): Promise { return build(await payloadInputs()); } export async function stuckJobIds(): Promise { return stuck(await payloadInputs()); }