diff --git a/fileflows.json b/fileflows.json deleted file mode 100644 index 24e092d..0000000 --- a/fileflows.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "version": 6, - "script": { - "version": "1.0.0", - "changeLog": "Initial script" - }, - "requirements": { - "locations": [ - "ApplicationsPerformance", - "ApplicationsCapacity", - "Media", - "Movies", - "Music", - "Photos", - "Shows", - "Documents" - ], - "specifications": [ - "2CORE", - "512MB" - ], - "permissions": [ - "READ_WRITE_LOCATIONS" - ], - "ports": [ - 30200 - ] - }, - "ensure_directories_exists": [ - { - "path": "$LOCATION(ApplicationsPerformance)" - }, - { - "path": "$LOCATION(ApplicationsCapacity)" - }, - { - "path": "$LOCATION(Media)", - "network_share": true - }, - { - "path": "$LOCATION(Movies)", - "network_share": true - }, - { - "path": "$LOCATION(Music)", - "network_share": true - }, - { - "path": "$LOCATION(Photos)", - "network_share": true - }, - { - "path": "$LOCATION(Shows)", - "network_share": true - }, - { - "path": "$LOCATION(Documents)", - "network_share": true - }, - { - "path": "$LOCATION(ApplicationsPerformance)/fileflows/data", - "snapshot": { - "id": "data" - } - }, - { - "path": "$LOCATION(ApplicationsCapacity)/fileflows/logs" - }, - { - "path": "$LOCATION(ApplicationsCapacity)/fileflows/temp" - } - ], - "app_values": { - "fileflows": { - "additional_envs": [] - }, - "network": { - "server_port": { - "bind_mode": "published", - "port_number": 30200 - } - }, - "storage": { - "data": "$HOST_PATH($LOCATION(ApplicationsPerformance)/fileflows/data)", - "logs": "$HOST_PATH($LOCATION(ApplicationsCapacity)/fileflows/logs)", - "temp": "$HOST_PATH($LOCATION(ApplicationsCapacity)/fileflows/temp)", - "additional_storage": [ - "$MOUNTED_HOST_PATH($LOCATION(Media), /media)", - "$MOUNTED_HOST_PATH($LOCATION(Movies), /movies)", - "$MOUNTED_HOST_PATH($LOCATION(Music), /music)", - "$MOUNTED_HOST_PATH($LOCATION(Photos), /photos)", - "$MOUNTED_HOST_PATH($LOCATION(Shows), /shows)", - "$MOUNTED_HOST_PATH($LOCATION(Documents), /documents)" - ] - }, - "resources": { - "limits": { - "cpus": 4, - "memory": "$MEMORY(10%, 4096)" - }, - "gpus": "$GPU_CONFIG()" - } - }, - "hooks": [ - { - "id": "convert-to-mp4", - "title": "Convert to MP4", - "description": "Convert this video to an MP4 file using FileFlows. The converted copy appears next to the original; the original file is not changed.", - "kind": "custom", - "target": { - "type": "files", - "accepts": [ - ".mkv", - ".avi", - ".mov", - ".wmv", - ".flv", - ".webm", - ".mpg", - ".mpeg", - ".m4v", - ".ts" - ], - "maxFiles": 5, - "requiresTargetMount": true - }, - "conditions": [ - { - "role": "availability", - "type": "appRunning", - "app": "fileflows" - } - ], - "rerun": "converge", - "script": "fileflows/convert_to_mp4.ts", - "entrypoint": "run", - "timeout": 3600, - "retries": 0 - } - ], - "widgetsSchema": 3, - "widgets": [ - { - "id": "conversions", - "title": "Conversions", - "description": "What FileFlows is converting right now, and how.", - "refresh": 10, - "conditions": [ - { - "role": "availability", - "type": "appRunning", - "app": "fileflows" - } - ], - "slots": [ - { - "type": "stat", - "field": "active" - }, - { - "type": "list", - "field": "files", - "rows": 1 - } - ], - "script": "fileflows/widget_conversions.ts", - "entrypoint": "run", - "timeout": 10 - } - ] -} diff --git a/fileflows/convert_to_mp4.ts b/fileflows/convert_to_mp4.ts deleted file mode 100644 index 5049c67..0000000 --- a/fileflows/convert_to_mp4.ts +++ /dev/null @@ -1,402 +0,0 @@ -// The flow is non-destructive: the converted file is copied next to the -// original as .mp4 and the original is left untouched. -import type { HookContext } from "../_lib/hook_context"; - -const FLOW_NAME = "HexOS Convert to MP4"; - -// Library-file statuses (from /api/library-file/status) -const STATUS_UNPROCESSED = 0; -const STATUS_PROCESSED = 1; -const STATUS_PROCESSING = 2; -const STATUS_FAILED = 4; - -const POLL_INTERVAL_MS = 15000; - -interface LibraryFileRecord { - /** uid */ - u: string; - /** display name — the full path we enqueued */ - dn: string; - /** status */ - s: number; - /** original size in bytes */ - os?: number; - /** flow uid */ - fu?: string; -} - -// The blessed flow, as the FileFlows API accepts it: Video File -> FFmpeg -// Builder (H.264 encode, remux to MP4) -> copy the result next to the -// original. A zero Uid means "create"; part uids are only node ids within -// the flow graph. -function flowDefinition(): Record { - const nInput = "b6d7d4a1-0001-4e00-9000-000000000001"; - const nStart = "b6d7d4a1-0002-4e00-9000-000000000002"; - const nEncode = "b6d7d4a1-0003-4e00-9000-000000000003"; - const nRemux = "b6d7d4a1-0004-4e00-9000-000000000004"; - const nExec = "b6d7d4a1-0005-4e00-9000-000000000005"; - const nCopy = "b6d7d4a1-0006-4e00-9000-000000000006"; - const nFailEncode = "b6d7d4a1-0007-4e00-9000-000000000007"; - const nFailExec = "b6d7d4a1-0008-4e00-9000-000000000008"; - const nCustom = "b6d7d4a1-0009-4e00-9000-000000000009"; - const nNoAttach = "b6d7d4a1-000a-4e00-9000-00000000000a"; - return { - Uid: "00000000-0000-0000-0000-000000000000", - Name: FLOW_NAME, - Type: 0, - Enabled: true, - Description: - "Created by HexOS. Converts a video to H.264 MP4 and places the result next to the original file. The original is not modified.", - Icon: "fas fa-video:#7F35B2", - Properties: { Fields: [], Variables: {} }, - Parts: [ - { - Uid: nInput, - Name: "", - FlowElementUid: "FileFlows.VideoNodes.VideoFile", - xPos: 230, - yPos: 70, - Icon: "fas fa-video", - Label: "Video File", - Inputs: 0, - Outputs: 1, - Type: 0, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nStart }], - }, - { - Uid: nStart, - Name: "", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderStart", - xPos: 230, - yPos: 200, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Start", - Inputs: 1, - Outputs: 1, - Type: 4, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nEncode }], - }, - { - Uid: nEncode, - Name: "H.264", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderVideoEncodeSimple", - xPos: 230, - yPos: 330, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Video Encode", - Inputs: 1, - Outputs: 1, - Type: 6, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nCustom }], - // Hardware-first (auto): CPU-only runs ~0.15x realtime. The forced - // 8-bit output below is what lets hardware encoders take 10-bit - // sources (no encoder anywhere does 10-bit H.264); if hardware still - // fails, the executor's error path falls over to CPU. - Model: { Codec: "h264", Encoder: "", Quality: 6, Speed: 3 }, - }, - { - Uid: nCustom, - Name: "8-bit output", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderCustomParameters", - xPos: 230, - yPos: 395, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Custom Parameters", - Inputs: 1, - Outputs: 1, - Type: 6, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nNoAttach }], - // 10-bit sources otherwise negotiate a High-10 encode, which both - // breaks (x264 pipeline error) and yields a less-compatible file. - Model: { Parameters: "-pix_fmt:v:0 yuv420p", ForceEncode: false }, - }, - { - Uid: nNoAttach, - Name: "", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderRemoveAttachments", - xPos: 230, - yPos: 425, - Icon: "far fa-file-video", - Label: "Remove Attachments", - Inputs: 1, - Outputs: 1, - Type: 6, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nRemux }], - // MKV font attachments (OTF/TTF for styled subs) cannot exist in MP4 — - // the muxer fails writing the header if they're mapped through. - Model: {}, - }, - { - Uid: nRemux, - Name: "", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderRemuxToMP4", - xPos: 230, - yPos: 460, - Icon: "far fa-file-video", - Label: "Remux to MP4", - Inputs: 1, - Outputs: 1, - Type: 6, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nExec }], - Model: { UseHvc1: false }, - }, - { - Uid: nExec, - Name: "", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderExecutor", - xPos: 230, - yPos: 590, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Executor", - Inputs: 1, - Outputs: 2, - Type: 5, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nCopy }], - // Hardware encode can fail on sources the GPU can't handle (e.g. - // NVENC has no 10-bit H.264); the error path re-encodes on CPU — - // same pattern as FileFlows' own convert templates. - ErrorConnection: { Input: 1, Output: -1, InputNode: nFailEncode }, - Model: { HardwareDecoding: "auto", Strictness: "experimental" }, - }, - { - Uid: nFailEncode, - Name: "CPU Fail-over Encode", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderVideoEncodeSimple", - xPos: 450, - yPos: 590, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Video Encode", - Inputs: 1, - Outputs: 1, - Type: 6, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nFailExec }], - Model: { Codec: "h264", Encoder: "CPU", Quality: 6, Speed: 3 }, - }, - // (fail-over keeps CPU too — it exists as belt-and-braces for future - // hardware-primary variants of this flow) - { - Uid: nFailExec, - Name: "CPU Fail-over Executor", - FlowElementUid: "FileFlows.VideoNodes.FfmpegBuilderNodes.FfmpegBuilderExecutor", - xPos: 450, - yPos: 720, - Icon: "far fa-file-video", - Label: "FFMPEG Builder: Executor", - Inputs: 1, - Outputs: 2, - Type: 5, - OutputConnections: [{ Input: 1, Output: 1, InputNode: nCopy }], - Model: { HardwareDecoding: false, Strictness: "experimental" }, - }, - { - Uid: nCopy, - Name: "", - FlowElementUid: "FileFlows.BasicNodes.File.CopyFile", - xPos: 230, - yPos: 720, - Icon: "fas fa-copy", - Label: "Copy next to original", - Inputs: 1, - Outputs: 1, - Type: 2, - Model: { - InputFile: "", - DestinationPath: "{folder.Orig.FullName}", - DestinationFile: "{file.Orig.FileNameNoExtension}.mp4", - CopyFolder: false, - AdditionalFiles: [], - AdditionalFilesFromOriginal: false, - PreserverOriginalDates: false, - }, - }, - ], - }; -} - -async function listByStatus(base: string, status: number): Promise { - const response = await fetch(`${base}/api/library-file/list-all?status=${status}`); - if (!response.ok) return []; - return (await response.json()) as LibraryFileRecord[]; -} - -async function recordsFor(base: string, paths: Set): Promise { - const all: LibraryFileRecord[] = []; - for (const status of [STATUS_UNPROCESSED, STATUS_PROCESSING, STATUS_PROCESSED, STATUS_FAILED]) { - for (const record of await listByStatus(base, status)) { - if (paths.has(record.dn)) all.push(record); - } - } - return all; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// ---- real conversion progress ----------------------------------------------- -// FileFlows serves a file's processing log DURING the run; ffmpeg writes a -// stats line into it every few seconds. Frames done / frames total is the -// truthful travel signal (the time= field freezes when subtitle streams are -// being converted — frame= keeps moving). - -/** Total frames in the source, from the log header (Duration + stream fps). */ -function parseTotalFrames(logText: string): number | null { - const duration = logText.match(/Duration: (\d+):(\d+):(\d+)\.(\d+)/); - const fps = logText.match(/(\d+(?:\.\d+)?) fps/); - if (!duration || !fps) return null; - const seconds = - Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3]) + Number(duration[4]) / 100; - const frames = seconds * Number(fps[1]); - return frames > 0 ? frames : null; -} - -/** Latest encoded frame count from ffmpeg's periodic stats lines. */ -function parseCurrentFrame(logText: string): number | null { - const matches = logText.match(/frame=\s*(\d+)/g); - if (!matches || matches.length === 0) return null; - const last = matches[matches.length - 1].match(/(\d+)/); - return last ? Number(last[1]) : null; -} - -async function fetchLogText(base: string, uid: string): Promise { - try { - const response = await fetch(`${base}/api/library-file/${uid}/log`); - if (!response.ok) return null; - // the endpoint serves HTML-wrapped lines; strip tags, keep text - return (await response.text()).replace(/<[^>]+>/g, ""); - } catch { - return null; - } -} - -export async function run(ctx: HookContext) { - await ctx.registerCheckpoints([ - { id: "flow", message: "Checking the FileFlows convert flow" }, - { id: "enqueue", message: "Sending files to FileFlows" }, - { id: "convert", message: "Converting" }, - ]); - - if (!ctx.host || !ctx.port) throw new Error("Missing FileFlows host/port in action context"); - const base = `http://${ctx.host}:${ctx.port}`; - - const files = ctx.target?.files ?? []; - if (files.length === 0) throw new Error("No files were selected"); - // The engine only offers this action for mounted files; this guards direct calls. - const unmapped = files.filter((f) => !f.containerPath); - if (unmapped.length > 0) { - ctx.fail("Some files are not in a folder FileFlows can access", [ - { label: "Files", value: unmapped.map((f) => f.name).join(", ") }, - { label: "Fix", value: "Move them into a media folder (Movies, Shows, Media…) and try again" }, - ]); - } - - // 1. Ensure the blessed flow exists (find by name, create via API if missing) - const flowsResponse = await fetch(`${base}/api/flow`); - if (!flowsResponse.ok) throw new Error(`Could not reach FileFlows (${flowsResponse.status})`); - const flows = (await flowsResponse.json()) as Array<{ Uid: string; Name: string }>; - let flowUid = flows.find((f) => f.Name === FLOW_NAME)?.Uid; - if (!flowUid) { - const createResponse = await fetch(`${base}/api/flow`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(flowDefinition()), - }); - if (!createResponse.ok) throw new Error(`Could not create the convert flow (${createResponse.status})`); - flowUid = ((await createResponse.json()) as { Uid: string }).Uid; - } - await ctx.emitCheckpoint("flow"); - - // 2. Clear stale queue records for these paths so a re-run converts again - const containerPaths = files.map((f) => f.containerPath as string); - const pathSet = new Set(containerPaths); - const stale = await recordsFor(base, pathSet); - if (stale.length > 0) { - await fetch(`${base}/api/library-file`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ Uids: stale.map((r) => r.u) }), - }); - } - - // 3. Enqueue - const addResponse = await fetch(`${base}/api/library-file/manually-add`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ FlowUid: flowUid, Files: containerPaths }), - }); - if (!addResponse.ok) throw new Error(`FileFlows rejected the files (${addResponse.status})`); - await ctx.emitCheckpoint("enqueue"); - - // 4. Poll until every file reaches a terminal status. The progress bar is - // real conversion travel: frames encoded / total frames per file (from the - // live processing log), size-weighted across files, mapped into a 5-95 - // band — the edges cover queue pickup and the final remux/copy-back. - // Monotonic: a CPU fail-over restarts ffmpeg's frame count mid-file, so - // the bar plateaus during the retry instead of jumping backwards. - await ctx.setProgress(2); - const totalFiles = files.length; - const sourceFrames = new Map(); // record uid -> total frames - const fractions = new Map(); // container path -> best-known travel - let reported = 2; - - for (;;) { - await sleep(POLL_INTERVAL_MS); - const records = await recordsFor(base, pathSet); - const done = records.filter((r) => r.s === STATUS_PROCESSED); - const failed = records.filter((r) => r.s === STATUS_FAILED); - - for (const record of records) { - if (record.s === STATUS_PROCESSED || record.s === STATUS_FAILED) { - fractions.set(record.dn, 1); - continue; - } - if (record.s !== STATUS_PROCESSING) continue; - const logText = await fetchLogText(base, record.u); - if (!logText) continue; - if (!sourceFrames.has(record.u)) { - const parsed = parseTotalFrames(logText); - if (parsed) sourceFrames.set(record.u, parsed); - } - const frameTotal = sourceFrames.get(record.u); - const frame = parseCurrentFrame(logText); - if (frameTotal && frame !== null) { - const fraction = Math.min(frame / frameTotal, 0.99); - if (fraction > (fractions.get(record.dn) ?? 0)) fractions.set(record.dn, fraction); - } - } - - let weightTotal = 0; - let weightDone = 0; - for (const file of files) { - const containerPath = file.containerPath as string; - const record = records.find((r) => r.dn === containerPath); - const weight = record?.os || file.size || 1; - weightTotal += weight; - weightDone += weight * (fractions.get(containerPath) ?? 0); - } - const overall = weightTotal > 0 ? Math.round(5 + (weightDone / weightTotal) * 90) : 5; - if (overall > reported) { - reported = overall; - await ctx.setProgress(overall); - } - - if (done.length + failed.length >= totalFiles) { - if (failed.length > 0) { - ctx.fail(`FileFlows could not convert ${failed.length} of ${totalFiles} file(s)`, [ - { label: "Failed", value: failed.map((r) => r.dn).join(", ") }, - { label: "Details", value: "Open the FileFlows app and check the file's processing log" }, - ]); - } - break; - } - - await ctx.updateCheckpointMessage( - "convert", - totalFiles > 1 - ? `Converting — ${overall}% (${done.length + failed.length} of ${totalFiles} files done)` - : `Converting — ${overall}%`, - ); - } - - await ctx.emitCheckpoint("convert", `Converted ${totalFiles} file(s) to MP4`, 99); -} diff --git a/fileflows/widget_conversions.ts b/fileflows/widget_conversions.ts deleted file mode 100644 index 23168b0..0000000 --- a/fileflows/widget_conversions.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Type imports only — each script is compiled standalone, so runtime imports of sibling files wouldn't resolve. -import { readFileSync, readdirSync, statSync } from "node:fs"; - -import type { WidgetContext, WidgetQueryResult } from "../_lib/widget_context"; - -// FileFlows' /api/status gives the counts and one row per converting file, but -// its stepPercent is per-STEP (resets each flow step) and the encoder only -// exists in the per-file runner log. So the widget joins the status rows with -// the freshest runner logs and derives the convert hook's own frame-based -// 5–95 progress, so the percent matches the file browser and notifications. -interface StatusFile { - name?: string; - library?: string; - step?: string; - stepPercent?: number; -} - -interface StatusResponse { - queue?: number; - processing?: number; - processed?: number; - processingFiles?: StatusFile[]; -} - -const ENCODER_KINDS: Array<[RegExp, string]> = [ - [/nvenc/i, "NVENC"], - [/qsv/i, "QuickSync"], - [/vaapi/i, "VAAPI"], - [/amf/i, "AMF"], - [/videotoolbox/i, "VideoToolbox"], - [/^lib|^copy$/i, "software"], -]; - -function encoderKind(encoder: string): string { - for (const [pattern, kind] of ENCODER_KINDS) if (pattern.test(encoder)) return kind; - return "software"; -} - -/** Codec family from an encoder name: h264_nvenc → h264, libx265 → hevc. */ -function encoderCodec(encoder: string): string { - const lowered = encoder.toLowerCase(); - if (lowered.includes("265") || lowered.includes("hevc")) return "hevc"; - if (lowered.includes("264")) return "h264"; - if (lowered.includes("av1")) return "av1"; - if (lowered.includes("vp9")) return "vp9"; - return encoder; -} - -interface LogFacts { - sourceCodec?: string; - encoder?: string; - /** Overall progress 0..1 from frames encoded / total frames. */ - fraction?: number; - /** Seconds remaining, from the live encode fps and frames left. */ - etaSeconds?: number; - /** Log freshness — a re-convert writes a new log for the same source. */ - mtimeMs: number; -} - -/** Total frames in the source, from the log header (Duration + stream fps). */ -function parseTotalFrames(logText: string): number | null { - const duration = logText.match(/Duration: (\d+):(\d+):(\d+)\.(\d+)/); - const fps = logText.match(/(\d+(?:\.\d+)?) fps/); - if (!duration || !fps) return null; - const seconds = - Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3]) + Number(duration[4]) / 100; - const frames = seconds * Number(fps[1]); - return frames > 0 ? frames : null; -} - -/** Latest encoded frame count from ffmpeg's periodic stats lines. */ -function parseCurrentFrame(logText: string): number | null { - const matches = logText.match(/frame=\s*(\d+)/g); - if (!matches || matches.length === 0) return null; - const last = matches[matches.length - 1].match(/(\d+)/); - return last ? Number(last[1]) : null; -} - -/** Live encode rate (fps= in the stats line) — the moving one, not the source fps. */ -function parseEncodeFps(logText: string): number | null { - const matches = logText.match(/fps=\s*(\d+(?:\.\d+)?)/g); - if (!matches || matches.length === 0) return null; - const last = matches[matches.length - 1].match(/(\d+(?:\.\d+)?)/); - const value = last ? Number(last[1]) : 0; - return value > 0 ? value : null; -} - -/** - * Read encoder + progress facts for active conversions from the freshest runner - * logs. Keyed by the source file path the log names in its ffmpeg probe line. - */ -function harvestLogFacts(ctx: WidgetContext): Map { - const facts = new Map(); - const logsMount = ctx.mounts.find((m) => m.hostPath.endsWith("/fileflows/logs")); - if (!logsMount) return facts; - const dir = `${logsMount.localPath}/LibraryFiles`; - try { - const now = Date.now(); - for (const file of readdirSync(dir)) { - if (!file.endsWith(".log")) continue; - const path = `${dir}/${file}`; - try { - const mtimeMs = statSync(path).mtimeMs; - if (now - mtimeMs > 10 * 60_000) continue; - // Runner logs stay small during encode (a few hundred KB at most). - const text = readFileSync(path, "utf-8"); - // FileFlows quotes the -i path when it contains spaces — tolerate the - // quote and stop the capture at it, or real media paths never match. - const source = text.match(/Executing: \S*ffmpeg[^\n]* -i "?(\/[^"\n]+)"?/)?.[1]?.trim(); - if (!source) continue; - // Freshest log wins per source — a finished log inside the 10-minute - // window must not shadow a live re-convert with its stale ~100%. - const prev = facts.get(source); - if (prev && prev.mtimeMs >= mtimeMs) continue; - const total = parseTotalFrames(text); - const current = parseCurrentFrame(text); - const fraction = total && current !== null ? Math.min(Math.max(current / total, 0), 0.999) : undefined; - const fps = parseEncodeFps(text); - const etaSeconds = - total && current !== null && fps ? Math.max(0, Math.round((total - current) / fps)) : undefined; - facts.set(source, { - // The probed input stream is the source; the "Codec:" line is the - // encode node's TARGET config and must not be read here. - sourceCodec: text.match(/Video: (\w+)/)?.[1], - encoder: text.match(/Encoding Parameters: (\S+)/)?.[1], - fraction, - etaSeconds, - mtimeMs, - }); - } catch { - // One unreadable log never blanks the widget. - } - } - } catch { - return facts; - } - return facts; -} - -const basename = (path: string): string => path.split("/").pop() || path; - -/** Facts by the log's -i path, falling back to basename only when UNAMBIGUOUS — - * two shows both converting an "S01E01.mkv" must not swap progress rows. */ -function factsFor(facts: Map, name: string): LogFacts | undefined { - const direct = facts.get(name); - if (direct) return direct; - const base = basename(name); - const matches = [...facts.entries()].filter(([key]) => basename(key) === base); - return matches.length === 1 ? matches[0][1] : undefined; -} - -/** The convert hook's 5–95 band, so the same file shows the same percent. */ -function bandedPercent(fraction: number): number { - return Math.round(5 + fraction * 90); -} - -/** "~4m" / "~45s" / ">1h" — a compact glance ETA, never false precision. */ -function formatEta(seconds: number): string { - if (seconds >= 3600) return ">1h"; - if (seconds >= 60) return `~${Math.round(seconds / 60)}m`; - return `~${Math.max(5, Math.round(seconds / 5) * 5)}s`; -} - -export async function run(ctx: WidgetContext): Promise { - const base = `http://${ctx.host}:${ctx.port ?? 30200}`; - - const response = await fetch(`${base}/api/status`, { headers: { Accept: "application/json" } }); - if (!response.ok) throw new Error(`FileFlows status query failed (${response.status})`); - const status = (await response.json()) as StatusResponse; - - const files = status.processingFiles ?? []; - const facts = harvestLogFacts(ctx); - // Authoritative count for the stat AND the summary, so they never disagree. - const converting = status.processing ?? files.length; - const queued = status.queue ?? 0; - - return { - fields: { - active: { type: "stat", label: "Converting", value: String(converting) }, - queued: { type: "stat", label: "Queued", value: String(queued) }, - files: { - type: "list", - entries: files.slice(0, 5).map((file) => { - const fact = file.name ? factsFor(facts, file.name) : undefined; - const conversion = - fact?.encoder !== undefined - ? `${fact.sourceCodec ?? "?"} → ${encoderCodec(fact.encoder)} · ${encoderKind(fact.encoder)}` - : file.step; - // Frame-based overall percent; per-step stepPercent only when the - // log carries no frame lines. - const percent = - fact?.fraction !== undefined - ? bandedPercent(fact.fraction) - : file.stepPercent !== undefined - ? Math.round(file.stepPercent) - : undefined; - // ETA only while it's still meaningful — drop it in the final seconds. - const eta = fact?.etaSeconds !== undefined && fact.etaSeconds >= 10 ? formatEta(fact.etaSeconds) : undefined; - const meta = [percent !== undefined ? `${percent}%` : undefined, eta].filter(Boolean).join(" · "); - return { - title: basename(file.name ?? "Unknown file"), - subtitle: conversion || undefined, - // Text floor: the figure any renderer can show as-is. - meta: meta || undefined, - // Enrichment: capable renderers draw this as a bar. - progress: percent !== undefined ? Math.min(Math.max(percent, 0), 100) : undefined, - }; - }), - }, - summary: { - type: "text", - text: - converting === 0 - ? queued > 0 - ? `Nothing converting · ${queued} queued` - : "Nothing converting" - : `${converting} converting · ${queued} queued`, - }, - }, - }; -}