Skip to content

Commit 7cf3f94

Browse files
committed
Add resilient container repair and processing
1 parent e28c000 commit 7cf3f94

File tree

5 files changed

+471
-24
lines changed

5 files changed

+471
-24
lines changed

apps/media-server/bun.lock

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/media-server/src/lib/ffmpeg-video.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,12 +216,126 @@ export async function downloadVideoToTemp(
216216
}
217217
}
218218

219+
const REPAIR_TIMEOUT_MS = 5 * 60 * 1000;
220+
221+
export async function repairContainer(
222+
inputPath: string,
223+
abortSignal?: AbortSignal,
224+
): Promise<TempFileHandle> {
225+
const repairedFile = await createTempFile(".mkv");
226+
227+
const ffmpegArgs = [
228+
"ffmpeg",
229+
"-threads",
230+
"2",
231+
"-err_detect",
232+
"ignore_err",
233+
"-fflags",
234+
"+genpts+igndts",
235+
"-i",
236+
inputPath,
237+
"-c",
238+
"copy",
239+
"-y",
240+
repairedFile.path,
241+
];
242+
243+
console.log(`[repairContainer] Running: ${ffmpegArgs.join(" ")}`);
244+
245+
const proc = spawn({
246+
cmd: ffmpegArgs,
247+
stdout: "pipe",
248+
stderr: "pipe",
249+
});
250+
251+
let abortCleanup: (() => void) | undefined;
252+
if (abortSignal) {
253+
abortCleanup = () => {
254+
killProcess(proc);
255+
};
256+
abortSignal.addEventListener("abort", abortCleanup, { once: true });
257+
}
258+
259+
try {
260+
await withTimeout(
261+
(async () => {
262+
drainStream(proc.stdout as ReadableStream<Uint8Array>);
263+
264+
const stderrText = await readStreamWithLimit(
265+
proc.stderr as ReadableStream<Uint8Array>,
266+
MAX_STDERR_BYTES,
267+
);
268+
269+
const exitCode = await proc.exited;
270+
271+
if (exitCode !== 0) {
272+
console.error(`[repairContainer] FFmpeg stderr:\n${stderrText}`);
273+
throw new Error(`Container repair failed with exit code ${exitCode}`);
274+
}
275+
276+
const outputFile = file(repairedFile.path);
277+
if (outputFile.size === 0) {
278+
throw new Error("Container repair produced empty file");
279+
}
280+
281+
console.log(
282+
`[repairContainer] Repair successful: ${outputFile.size} bytes`,
283+
);
284+
})(),
285+
REPAIR_TIMEOUT_MS,
286+
() => killProcess(proc),
287+
);
288+
289+
return repairedFile;
290+
} catch (err) {
291+
await repairedFile.cleanup();
292+
throw err;
293+
} finally {
294+
if (abortCleanup) {
295+
abortSignal?.removeEventListener("abort", abortCleanup);
296+
}
297+
killProcess(proc);
298+
}
299+
}
300+
301+
export interface ResilientInputFlags {
302+
errDetectIgnoreErr?: boolean;
303+
genPts?: boolean;
304+
discardCorrupt?: boolean;
305+
maxMuxingQueueSize?: number;
306+
}
307+
308+
function buildExtraInputFlags(flags: ResilientInputFlags): string[] {
309+
const args: string[] = [];
310+
311+
if (flags.errDetectIgnoreErr) {
312+
args.push("-err_detect", "ignore_err");
313+
}
314+
315+
const fflags: string[] = [];
316+
if (flags.genPts) fflags.push("+genpts");
317+
if (flags.discardCorrupt) fflags.push("+discardcorrupt");
318+
if (fflags.length > 0) {
319+
args.push("-fflags", fflags.join(""));
320+
}
321+
322+
return args;
323+
}
324+
325+
function buildExtraOutputFlags(flags: ResilientInputFlags): string[] {
326+
if (flags.maxMuxingQueueSize) {
327+
return ["-max_muxing_queue_size", flags.maxMuxingQueueSize.toString()];
328+
}
329+
return [];
330+
}
331+
219332
export async function processVideo(
220333
inputPath: string,
221334
metadata: VideoMetadata,
222335
options: VideoProcessingOptions = {},
223336
onProgress?: ProgressCallback,
224337
abortSignal?: AbortSignal,
338+
resilientFlags?: ResilientInputFlags,
225339
): Promise<TempFileHandle> {
226340
const definedOptions = Object.fromEntries(
227341
Object.entries(options).filter(([, v]) => v !== undefined),
@@ -235,7 +349,21 @@ export async function processVideo(
235349
: needsVideoTranscode(metadata, opts);
236350
const audioTranscode = remuxOnly ? false : needsAudioTranscode(metadata);
237351

238-
const ffmpegArgs: string[] = ["ffmpeg", "-threads", "2", "-i", inputPath];
352+
const extraInputArgs = resilientFlags
353+
? buildExtraInputFlags(resilientFlags)
354+
: [];
355+
const extraOutputArgs = resilientFlags
356+
? buildExtraOutputFlags(resilientFlags)
357+
: [];
358+
359+
const ffmpegArgs: string[] = [
360+
"ffmpeg",
361+
"-threads",
362+
"2",
363+
...extraInputArgs,
364+
"-i",
365+
inputPath,
366+
];
239367

240368
if (videoTranscode) {
241369
ffmpegArgs.push(
@@ -265,6 +393,7 @@ export async function processVideo(
265393
ffmpegArgs.push(
266394
"-movflags",
267395
"+faststart",
396+
...extraOutputArgs,
268397
"-progress",
269398
"pipe:2",
270399
"-y",

apps/media-server/src/lib/ffprobe.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,92 @@ export async function probeVideo(videoUrl: string): Promise<VideoMetadata> {
190190
}
191191
}
192192

193+
export async function probeVideoFile(filePath: string): Promise<VideoMetadata> {
194+
if (!canAcceptNewProbeProcess()) {
195+
throw new Error("Server is busy, please try again later");
196+
}
197+
198+
activeProbeProcesses++;
199+
200+
const proc = spawn({
201+
cmd: [
202+
"ffprobe",
203+
"-v",
204+
"quiet",
205+
"-print_format",
206+
"json",
207+
"-show_format",
208+
"-show_streams",
209+
"-analyzeduration",
210+
"10000000",
211+
"-probesize",
212+
"10000000",
213+
filePath,
214+
],
215+
stdout: "pipe",
216+
stderr: "ignore",
217+
});
218+
219+
try {
220+
const result = await withTimeout(
221+
(async () => {
222+
const stdoutText = await readStreamToString(
223+
proc.stdout as ReadableStream<Uint8Array>,
224+
MAX_OUTPUT_BYTES,
225+
);
226+
227+
const exitCode = await proc.exited;
228+
229+
if (exitCode !== 0) {
230+
throw new Error(`ffprobe exited with code ${exitCode}`);
231+
}
232+
233+
const data: FFprobeOutput = JSON.parse(stdoutText);
234+
235+
console.log(
236+
`[probeVideoFile] ffprobe output for ${filePath}: format=${JSON.stringify(data.format)}, streams=${data.streams?.length ?? 0}`,
237+
);
238+
239+
const videoStream = data.streams?.find((s) => s.codec_type === "video");
240+
const audioStream = data.streams?.find((s) => s.codec_type === "audio");
241+
242+
if (!videoStream) {
243+
throw new Error("No video stream found");
244+
}
245+
246+
const duration = Number.parseFloat(data.format?.duration ?? "0");
247+
const fileSize = Number.parseInt(data.format?.size ?? "0", 10);
248+
const bitrate = Number.parseInt(data.format?.bit_rate ?? "0", 10);
249+
const fps =
250+
parseFrameRate(videoStream.r_frame_rate) ||
251+
parseFrameRate(videoStream.avg_frame_rate);
252+
253+
return {
254+
duration,
255+
width: videoStream.width ?? 0,
256+
height: videoStream.height ?? 0,
257+
fps: Math.round(fps * 100) / 100,
258+
videoCodec: videoStream.codec_name ?? "unknown",
259+
audioCodec: audioStream?.codec_name ?? null,
260+
audioChannels: audioStream?.channels ?? null,
261+
sampleRate: audioStream?.sample_rate
262+
? Number.parseInt(audioStream.sample_rate, 10)
263+
: null,
264+
bitrate,
265+
fileSize,
266+
};
267+
})(),
268+
PROBE_TIMEOUT_MS,
269+
() => killProcess(proc),
270+
);
271+
272+
return result;
273+
} finally {
274+
activeProbeProcesses--;
275+
killProcess(proc);
276+
}
277+
}
278+
193279
export async function checkVideoAccessible(videoUrl: string): Promise<boolean> {
194280
try {
195281
const response = await fetch(videoUrl, {

0 commit comments

Comments
 (0)