Skip to content

Commit 59587fe

Browse files
authored
feat: show served multi-file output and ffmpeg errorDetail (#35)
* feat: show served multi-file output and ffmpeg errorDetail * refactor: tidy served-output rendering * feat: render new discriminated job output and error * refactor: render unified job output (data + file + files) * feat: download job outputs locally like a local tool
1 parent 4b55682 commit 59587fe

5 files changed

Lines changed: 694 additions & 40 deletions

File tree

src/__tests__/progress.test.ts

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import { describe, it, expect, afterEach } from "bun:test";
2+
import * as fs from "node:fs";
3+
import * as path from "node:path";
4+
import * as os from "node:os";
5+
import { buildResult, outputUrl, downloadUrlToFile, downloadFilesToDir, type JobFile } from "../lib/progress.js";
6+
7+
describe("buildResult — unified output", () => {
8+
it("reads a file output (headline file + meta)", () => {
9+
const r = buildResult("complete", undefined, {
10+
output: {
11+
data: null,
12+
file: {
13+
url: "https://cdn.rendobar.com/jobs/job_1/output.mp4",
14+
path: "output.mp4",
15+
type: "video",
16+
size: 4096,
17+
meta: { format: "mp4", width: 1280, height: 720 },
18+
},
19+
files: [
20+
{
21+
url: "https://cdn.rendobar.com/jobs/job_1/output.mp4",
22+
path: "output.mp4",
23+
type: "video",
24+
size: 4096,
25+
meta: { format: "mp4", width: 1280, height: 720 },
26+
},
27+
],
28+
expiresAt: 123,
29+
},
30+
});
31+
expect(r.output?.data).toBeNull();
32+
expect(r.output?.file?.url).toBe("https://cdn.rendobar.com/jobs/job_1/output.mp4");
33+
expect(r.output?.file?.type).toBe("video");
34+
expect(r.output?.file?.meta?.width).toBe(1280);
35+
expect(r.output?.files.length).toBe(1);
36+
expect(r.output?.expiresAt).toBe(123);
37+
expect(r.error).toBeUndefined();
38+
});
39+
40+
it("reads a stream output (playlist headline file)", () => {
41+
const r = buildResult("complete", undefined, {
42+
output: {
43+
data: null,
44+
file: {
45+
url: "https://api.rendobar.com/v/job_1/tok/master.m3u8",
46+
path: "master.m3u8",
47+
type: "playlist",
48+
size: 512,
49+
},
50+
files: [
51+
{ url: "https://api.rendobar.com/v/job_1/tok/seg0.ts", path: "seg0.ts", type: "video", size: 1000 },
52+
{ url: "https://api.rendobar.com/v/job_1/tok/seg1.ts", path: "seg1.ts", type: "video", size: 1000 },
53+
],
54+
expiresAt: 123,
55+
},
56+
});
57+
expect(r.output?.file?.type).toBe("playlist");
58+
expect(r.output?.file?.url).toBe("https://api.rendobar.com/v/job_1/tok/master.m3u8");
59+
expect(r.output?.files.length).toBe(2);
60+
});
61+
62+
it("reads a set output (no headline file, multiple files)", () => {
63+
const r = buildResult("complete", undefined, {
64+
output: {
65+
data: null,
66+
file: null,
67+
files: [
68+
{ url: "https://api.rendobar.com/v/job_2/tok/a.png", path: "a.png", type: "image", size: 100 },
69+
{ url: "https://api.rendobar.com/v/job_2/tok/b.png", path: "b.png", type: "image", size: 100 },
70+
],
71+
expiresAt: 123,
72+
},
73+
});
74+
expect(r.output?.file).toBeNull();
75+
expect(r.output?.files.length).toBe(2);
76+
expect(r.output?.files[0]?.path).toBe("a.png");
77+
});
78+
79+
it("reads a data-only output (data non-null, no files)", () => {
80+
const r = buildResult("complete", undefined, {
81+
output: {
82+
data: { duration: 12.5, streams: 2 },
83+
file: null,
84+
files: [],
85+
expiresAt: null,
86+
},
87+
});
88+
expect(r.output?.data).toEqual({ duration: 12.5, streams: 2 });
89+
expect(r.output?.file).toBeNull();
90+
expect(r.output?.files.length).toBe(0);
91+
expect(r.output?.expiresAt).toBeNull();
92+
});
93+
94+
it("ignores a malformed output object (no files array)", () => {
95+
const r = buildResult("complete", undefined, {
96+
output: { data: null, file: null },
97+
});
98+
expect(r.output).toBeUndefined();
99+
});
100+
101+
it("leaves output undefined when absent", () => {
102+
const r = buildResult("complete", undefined, {});
103+
expect(r.output).toBeUndefined();
104+
});
105+
106+
it("surfaces a structured error (code + message + detail) on failure", () => {
107+
const r = buildResult("failed", undefined, {
108+
error: {
109+
code: "PROVIDER_ERROR",
110+
message: "Job failed",
111+
detail: "frame= 100\n[error] Conversion failed!",
112+
retryable: false,
113+
},
114+
});
115+
expect(r.error?.code).toBe("PROVIDER_ERROR");
116+
expect(r.error?.message).toBe("Job failed");
117+
expect(r.error?.detail).toContain("Conversion failed!");
118+
expect(r.error?.retryable).toBe(false);
119+
});
120+
121+
it("defaults error.detail to null when absent", () => {
122+
const r = buildResult("failed", undefined, {
123+
error: { code: "TIMEOUT", message: "Job failed", retryable: true },
124+
});
125+
expect(r.error?.detail).toBeNull();
126+
expect(r.error?.retryable).toBe(true);
127+
});
128+
129+
it("ignores a malformed error object", () => {
130+
const r = buildResult("failed", undefined, { error: { message: 42 } });
131+
expect(r.error).toBeUndefined();
132+
});
133+
});
134+
135+
describe("outputUrl", () => {
136+
it("returns the headline file url for a single file", () => {
137+
expect(
138+
outputUrl({
139+
data: null,
140+
file: { url: "https://cdn.rendobar.com/jobs/job_1/output.mp4", path: "output.mp4", type: "video", size: 1 },
141+
files: [{ url: "https://cdn.rendobar.com/jobs/job_1/output.mp4", path: "output.mp4", type: "video", size: 1 }],
142+
expiresAt: null,
143+
}),
144+
).toBe("https://cdn.rendobar.com/jobs/job_1/output.mp4");
145+
});
146+
147+
it("returns the manifest url for a stream", () => {
148+
expect(
149+
outputUrl({
150+
data: null,
151+
file: { url: "https://api.rendobar.com/v/job_1/tok/master.m3u8", path: "master.m3u8", type: "playlist", size: 1 },
152+
files: [{ url: "https://api.rendobar.com/v/job_1/tok/seg0.ts", path: "seg0.ts", type: "video", size: 1 }],
153+
expiresAt: null,
154+
}),
155+
).toBe("https://api.rendobar.com/v/job_1/tok/master.m3u8");
156+
});
157+
158+
it("falls back to the first file url for a set (no headline file)", () => {
159+
expect(
160+
outputUrl({
161+
data: null,
162+
file: null,
163+
files: [
164+
{ url: "https://api.rendobar.com/v/job_2/tok/a.png", path: "a.png", type: "image", size: 1 },
165+
{ url: "https://api.rendobar.com/v/job_2/tok/b.png", path: "b.png", type: "image", size: 1 },
166+
],
167+
expiresAt: null,
168+
}),
169+
).toBe("https://api.rendobar.com/v/job_2/tok/a.png");
170+
});
171+
172+
it("returns undefined for a data-only output (no files)", () => {
173+
expect(
174+
outputUrl({ data: { ok: true }, file: null, files: [], expiresAt: null }),
175+
).toBeUndefined();
176+
});
177+
});
178+
179+
// ── Local download (behaves like a local tool) ─────────────────
180+
181+
describe("downloadUrlToFile — single file to a named local path", () => {
182+
const realFetch = globalThis.fetch;
183+
let tmpDir: string;
184+
185+
afterEach(() => {
186+
globalThis.fetch = realFetch;
187+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
188+
});
189+
190+
function mockBody(map: Record<string, string>): void {
191+
globalThis.fetch = ((input: unknown) => {
192+
const url = String(input);
193+
const body = map[url];
194+
if (body === undefined) return Promise.resolve(new Response("not found", { status: 404 }));
195+
return Promise.resolve(new Response(body, { status: 200 }));
196+
}) as typeof globalThis.fetch;
197+
}
198+
199+
it("fetches the signed url and writes to the exact local path", async () => {
200+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rb-dl-"));
201+
mockBody({ "https://signed.example/out.mp4": "VIDEO_BYTES" });
202+
203+
const target = path.join(tmpDir, "out.mp4");
204+
await downloadUrlToFile("https://signed.example/out.mp4", target);
205+
206+
expect(fs.existsSync(target)).toBe(true);
207+
expect(fs.readFileSync(target, "utf8")).toBe("VIDEO_BYTES");
208+
});
209+
210+
it("throws on a non-ok response", async () => {
211+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rb-dl-"));
212+
mockBody({});
213+
await expect(
214+
downloadUrlToFile("https://signed.example/missing.mp4", path.join(tmpDir, "x.mp4")),
215+
).rejects.toThrow();
216+
});
217+
});
218+
219+
describe("downloadFilesToDir — set/stream into a local folder", () => {
220+
const realFetch = globalThis.fetch;
221+
let tmpDir: string;
222+
223+
afterEach(() => {
224+
globalThis.fetch = realFetch;
225+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
226+
});
227+
228+
function mockBody(map: Record<string, string>): void {
229+
globalThis.fetch = ((input: unknown) => {
230+
const url = String(input);
231+
const body = map[url];
232+
if (body === undefined) return Promise.resolve(new Response("not found", { status: 404 }));
233+
return Promise.resolve(new Response(body, { status: 200 }));
234+
}) as typeof globalThis.fetch;
235+
}
236+
237+
it("downloads every file of a set, preserving each path", async () => {
238+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rb-set-"));
239+
const files: JobFile[] = [
240+
{ url: "https://signed.example/a.png", path: "a.png", type: "image", size: 1 },
241+
{ url: "https://signed.example/b.png", path: "b.png", type: "image", size: 1 },
242+
];
243+
mockBody({ "https://signed.example/a.png": "A", "https://signed.example/b.png": "B" });
244+
245+
const written = await downloadFilesToDir(files, tmpDir);
246+
247+
expect(written.length).toBe(2);
248+
expect(fs.readFileSync(path.join(tmpDir, "a.png"), "utf8")).toBe("A");
249+
expect(fs.readFileSync(path.join(tmpDir, "b.png"), "utf8")).toBe("B");
250+
});
251+
252+
it("downloads a stream manifest + segments so it plays locally", async () => {
253+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rb-hls-"));
254+
const files: JobFile[] = [
255+
{ url: "https://signed.example/master.m3u8", path: "master.m3u8", type: "playlist", size: 1 },
256+
{ url: "https://signed.example/seg0.ts", path: "seg0.ts", type: "video", size: 1 },
257+
{ url: "https://signed.example/seg1.ts", path: "seg1.ts", type: "video", size: 1 },
258+
];
259+
mockBody({
260+
"https://signed.example/master.m3u8": "#EXTM3U",
261+
"https://signed.example/seg0.ts": "S0",
262+
"https://signed.example/seg1.ts": "S1",
263+
});
264+
265+
const written = await downloadFilesToDir(files, tmpDir);
266+
267+
expect(written.length).toBe(3);
268+
expect(fs.readFileSync(path.join(tmpDir, "master.m3u8"), "utf8")).toBe("#EXTM3U");
269+
expect(fs.existsSync(path.join(tmpDir, "seg0.ts"))).toBe(true);
270+
expect(fs.existsSync(path.join(tmpDir, "seg1.ts"))).toBe(true);
271+
});
272+
273+
it("preserves nested relative paths and creates subdirs", async () => {
274+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rb-nested-"));
275+
const files: JobFile[] = [
276+
{ url: "https://signed.example/v/720p/seg0.ts", path: "720p/seg0.ts", type: "video", size: 1 },
277+
];
278+
mockBody({ "https://signed.example/v/720p/seg0.ts": "NESTED" });
279+
280+
const written = await downloadFilesToDir(files, tmpDir);
281+
282+
expect(written.length).toBe(1);
283+
expect(fs.readFileSync(path.join(tmpDir, "720p", "seg0.ts"), "utf8")).toBe("NESTED");
284+
});
285+
286+
it("falls back to the url basename when a file has no path", async () => {
287+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rb-nopath-"));
288+
const files: JobFile[] = [
289+
{ url: "https://signed.example/cdn/frame_001.png", path: "", type: "image", size: 1 },
290+
];
291+
mockBody({ "https://signed.example/cdn/frame_001.png": "PNG" });
292+
293+
const written = await downloadFilesToDir(files, tmpDir);
294+
295+
expect(written.length).toBe(1);
296+
expect(fs.readFileSync(path.join(tmpDir, "frame_001.png"), "utf8")).toBe("PNG");
297+
});
298+
});

src/commands/doctor.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { homedir, platform, arch } from "node:os";
88
import { spawnSync } from "node:child_process";
99
import { defineCommand } from "citty";
1010
import { VERSION } from "../generated/version.js";
11-
import { getConfigDir } from "../lib/auth.js";
11+
import { getConfigDir, getApiBaseUrl } from "../lib/auth.js";
1212
import { getBinPath } from "../lib/bin-path.js";
1313

1414
// Compile-time defines from `bun build --compile --define`. In dev mode they're undefined.
@@ -134,15 +134,17 @@ function checkUpdateCache(): Check {
134134
}
135135

136136
async function checkApiReachable(): Promise<Check> {
137+
const baseUrl = getApiBaseUrl();
138+
const host = (() => { try { return new URL(baseUrl).host; } catch { return baseUrl; } })();
137139
try {
138-
const res = await fetchWithTimeout("https://api.rendobar.com/health");
140+
const res = await fetchWithTimeout(`${baseUrl}/health`);
139141
if (res.ok) {
140-
return { name: "api.rendobar.com", status: "ok", detail: `HTTP ${res.status}` };
142+
return { name: host, status: "ok", detail: `HTTP ${res.status}` };
141143
}
142-
return { name: "api.rendobar.com", status: "warn", detail: `HTTP ${res.status}` };
144+
return { name: host, status: "warn", detail: `HTTP ${res.status}` };
143145
} catch (err) {
144146
return {
145-
name: "api.rendobar.com",
147+
name: host,
146148
status: "fail",
147149
detail: (err as Error).message,
148150
fix: "Check your internet connection or status.rendobar.com",

0 commit comments

Comments
 (0)