Files
netfelix-audio-fix/server/services/probe.ts
T

111 lines
3.4 KiB
TypeScript

export interface ProbeStream {
streamIndex: number;
type: "Video" | "Audio" | "Subtitle" | "Data" | "EmbeddedImage";
codec: string | null;
profile: string | null;
language: string | null;
title: string | null;
isDefault: number;
isForced: number;
isHearingImpaired: number;
channels: number | null;
channelLayout: string | null;
bitRate: number | null;
sampleRate: number | null;
bitDepth: number | null;
}
export interface ProbeResult {
fileSize: number | null;
durationSeconds: number | null;
container: string | null;
streams: ProbeStream[];
}
function mapCodecType(codecType: string): ProbeStream["type"] {
switch (codecType) {
case "video":
return "Video";
case "audio":
return "Audio";
case "subtitle":
return "Subtitle";
case "data":
return "Data";
case "attachment":
return "EmbeddedImage";
default:
return "Data";
}
}
function parseIntOrNull(value: string | number | null | undefined): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === "number" ? value : parseInt(value, 10);
return Number.isFinite(n) ? n : null;
}
function parseFloatOrNull(value: string | number | null | undefined): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === "number" ? value : parseFloat(value);
return Number.isFinite(n) ? n : null;
}
/** Parse ffprobe JSON output. Exported for unit testing. */
export function parseProbeOutput(json: string): ProbeResult {
const raw = JSON.parse(json);
const fmt = raw.format ?? {};
const fileSize = parseIntOrNull(fmt.size ?? null);
const durationSeconds = parseFloatOrNull(fmt.duration ?? null);
const container = fmt.format_name
? (fmt.format_name as string).split(",")[0] || null
: null;
const streams: ProbeStream[] = (raw.streams ?? []).map(
// biome-ignore lint/suspicious/noExplicitAny: raw ffprobe JSON
(s: any): ProbeStream => {
const tags = s.tags ?? {};
const disp = s.disposition ?? {};
return {
streamIndex: s.index as number,
type: mapCodecType(s.codec_type ?? ""),
codec: (s.codec_name as string | undefined) ?? null,
profile: (s.profile as string | undefined) ?? null,
language: (tags.language ?? tags.LANGUAGE ?? null) as string | null,
title: (tags.title ?? tags.TITLE ?? null) as string | null,
isDefault: (disp.default as number | undefined) ?? 0,
isForced: (disp.forced as number | undefined) ?? 0,
isHearingImpaired: (disp.hearing_impaired as number | undefined) ?? 0,
channels: parseIntOrNull(s.channels ?? null),
channelLayout: (s.channel_layout as string | undefined) ?? null,
bitRate: parseIntOrNull(s.bit_rate ?? null),
sampleRate: parseIntOrNull(s.sample_rate ?? null),
bitDepth: parseIntOrNull(s.bits_per_raw_sample ?? null),
};
},
);
return { fileSize, durationSeconds, container, streams };
}
/** Run ffprobe on a local file. */
export async function probeFile(filePath: string): Promise<ProbeResult> {
const proc = Bun.spawn(
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
{ stdout: "pipe", stderr: "pipe" },
);
const [stdout, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
proc.exited,
]);
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw new Error(`ffprobe exited with code ${exitCode}: ${stderr.trim()}`);
}
return parseProbeOutput(stdout);
}