581 lines
20 KiB
TypeScript
581 lines
20 KiB
TypeScript
import "server-only";
|
|
import path from "node:path";
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import { revalidatePath } from "next/cache";
|
|
import { rawDb } from "@/lib/db/client";
|
|
import { getAppSetting } from "@/lib/db/appSettings";
|
|
import { classifyGroup, compilePatterns } from "./partClassify";
|
|
import type { VideoFile } from "./index";
|
|
|
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
|
|
export type PlaybackMode = "direct" | "transcode";
|
|
|
|
export interface StoredVideoMetadata {
|
|
absPath: string;
|
|
relPath: string;
|
|
code: string;
|
|
sizeBytes: number;
|
|
mtimeMs: number;
|
|
probedAt: number | null;
|
|
probeError: string | null;
|
|
durationSec: number | null;
|
|
videoCodec: string | null;
|
|
videoBFrames: number | null;
|
|
width: number | null;
|
|
height: number | null;
|
|
videoBitrate: number | null;
|
|
playbackMode: PlaybackMode | null;
|
|
partKind: "part" | "variant" | "single" | null;
|
|
partIndex: number | null;
|
|
variantGroup: string | null;
|
|
}
|
|
|
|
interface VideoMetadataRow {
|
|
abs_path: string;
|
|
rel_path: string;
|
|
code: string;
|
|
size_bytes: number;
|
|
mtime_ms: number;
|
|
probed_at: number | null;
|
|
probe_error: string | null;
|
|
duration_sec: number | null;
|
|
video_codec: string | null;
|
|
video_b_frames: number | null;
|
|
width: number | null;
|
|
height: number | null;
|
|
video_bitrate: number | null;
|
|
playback_mode: string | null;
|
|
part_kind: string | null;
|
|
part_index: number | null;
|
|
variant_group: string | null;
|
|
}
|
|
|
|
interface FfprobeJson {
|
|
streams?: Array<{
|
|
codec_name?: string;
|
|
width?: number;
|
|
height?: number;
|
|
bit_rate?: string;
|
|
has_b_frames?: number;
|
|
}>;
|
|
format?: {
|
|
duration?: string;
|
|
bit_rate?: string;
|
|
};
|
|
}
|
|
|
|
function mapRow(row: VideoMetadataRow | undefined): StoredVideoMetadata | null {
|
|
if (!row) return null;
|
|
return {
|
|
absPath: row.abs_path,
|
|
relPath: row.rel_path,
|
|
code: row.code,
|
|
sizeBytes: row.size_bytes,
|
|
mtimeMs: row.mtime_ms,
|
|
probedAt: row.probed_at,
|
|
probeError: row.probe_error,
|
|
durationSec: row.duration_sec,
|
|
videoCodec: row.video_codec,
|
|
videoBFrames: row.video_b_frames,
|
|
width: row.width,
|
|
height: row.height,
|
|
videoBitrate: row.video_bitrate,
|
|
playbackMode: row.playback_mode === "direct" || row.playback_mode === "transcode" ? row.playback_mode : null,
|
|
partKind: row.part_kind === "part" || row.part_kind === "variant" || row.part_kind === "single" ? row.part_kind : null,
|
|
partIndex: row.part_index,
|
|
variantGroup: row.variant_group,
|
|
};
|
|
}
|
|
|
|
function parseFiniteNumber(value: unknown): number | null {
|
|
if (value == null || value === "N/A") return null;
|
|
const n = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
}
|
|
|
|
function parseNonNegativeNumber(value: unknown): number | null {
|
|
if (value == null || value === "N/A") return null;
|
|
const n = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
}
|
|
|
|
function isStatMatch(row: StoredVideoMetadata, sizeBytes: number, mtimeMs: number): boolean {
|
|
return row.sizeBytes === sizeBytes && Math.abs(row.mtimeMs - mtimeMs) < 1;
|
|
}
|
|
|
|
export function getStoredVideoMetadata(absPath: string): StoredVideoMetadata | null {
|
|
return mapRow(rawDb.prepare(`SELECT * FROM video_metadata WHERE abs_path = ?`).get(absPath) as VideoMetadataRow | undefined);
|
|
}
|
|
|
|
export function listStoredVideoMetadataForCode(code: string | null | undefined): StoredVideoMetadata[] {
|
|
if (!code) return [];
|
|
const rows = rawDb.prepare(`
|
|
SELECT * FROM video_metadata
|
|
WHERE upper(code) = upper(?)
|
|
ORDER BY rel_path ASC
|
|
`).all(code) as VideoMetadataRow[];
|
|
return rows.map((row) => mapRow(row)).filter((row): row is StoredVideoMetadata => row !== null);
|
|
}
|
|
|
|
export function serializeVideoMetadata(meta: StoredVideoMetadata | null) {
|
|
if (!meta) return null;
|
|
return {
|
|
absPath: meta.absPath,
|
|
relPath: meta.relPath,
|
|
code: meta.code,
|
|
sizeBytes: meta.sizeBytes,
|
|
mtimeMs: meta.mtimeMs,
|
|
probedAt: meta.probedAt,
|
|
probeError: meta.probeError,
|
|
durationSec: meta.durationSec,
|
|
videoCodec: meta.videoCodec,
|
|
videoBFrames: meta.videoBFrames,
|
|
width: meta.width,
|
|
height: meta.height,
|
|
videoBitrate: meta.videoBitrate,
|
|
playbackMode: meta.playbackMode,
|
|
partKind: meta.partKind,
|
|
partIndex: meta.partIndex,
|
|
variantGroup: meta.variantGroup,
|
|
};
|
|
}
|
|
|
|
export async function syncVideoMetadataIndex(files: VideoFile[]): Promise<void> {
|
|
const found = new Set(files.map((file) => file.abs));
|
|
const upsert = rawDb.prepare(`
|
|
INSERT INTO video_metadata (abs_path, rel_path, code, size_bytes, mtime_ms, dir_path)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(abs_path) DO UPDATE SET
|
|
rel_path = excluded.rel_path,
|
|
code = excluded.code,
|
|
dir_path = excluded.dir_path,
|
|
probed_at = CASE
|
|
WHEN video_metadata.size_bytes != excluded.size_bytes OR video_metadata.mtime_ms != excluded.mtime_ms THEN NULL
|
|
ELSE video_metadata.probed_at
|
|
END,
|
|
probe_error = CASE
|
|
WHEN video_metadata.size_bytes != excluded.size_bytes OR video_metadata.mtime_ms != excluded.mtime_ms THEN NULL
|
|
ELSE video_metadata.probe_error
|
|
END,
|
|
duration_sec = CASE
|
|
WHEN video_metadata.size_bytes != excluded.size_bytes OR video_metadata.mtime_ms != excluded.mtime_ms THEN NULL
|
|
ELSE video_metadata.duration_sec
|
|
END,
|
|
video_codec = CASE
|
|
WHEN video_metadata.size_bytes != excluded.size_bytes OR video_metadata.mtime_ms != excluded.mtime_ms THEN NULL
|
|
ELSE video_metadata.video_codec
|
|
END,
|
|
video_b_frames = CASE
|
|
WHEN video_metadata.size_bytes != excluded.size_bytes OR video_metadata.mtime_ms != excluded.mtime_ms THEN NULL
|
|
ELSE video_metadata.video_b_frames
|
|
END,
|
|
width = CASE
|
|
WHEN video_metadata.size_bytes != excluded.size_bytes OR video_metadata.mtime_ms != excluded.mtime_ms THEN NULL
|
|
ELSE video_metadata.width
|
|
END,
|
|
height = CASE
|
|
WHEN video_metadata.size_bytes != excluded.size_bytes OR video_metadata.mtime_ms != excluded.mtime_ms THEN NULL
|
|
ELSE video_metadata.height
|
|
END,
|
|
video_bitrate = CASE
|
|
WHEN video_metadata.size_bytes != excluded.size_bytes OR video_metadata.mtime_ms != excluded.mtime_ms THEN NULL
|
|
ELSE video_metadata.video_bitrate
|
|
END,
|
|
playback_mode = CASE
|
|
WHEN video_metadata.size_bytes != excluded.size_bytes OR video_metadata.mtime_ms != excluded.mtime_ms THEN NULL
|
|
ELSE video_metadata.playback_mode
|
|
END,
|
|
size_bytes = excluded.size_bytes,
|
|
mtime_ms = excluded.mtime_ms
|
|
`);
|
|
const deleteStale = rawDb.prepare(`DELETE FROM video_metadata WHERE abs_path = ?`);
|
|
const tx = rawDb.transaction(() => {
|
|
for (const file of files) {
|
|
const last = Math.max(file.abs.lastIndexOf("/"), file.abs.lastIndexOf("\\"));
|
|
const dir = last >= 0 ? file.abs.slice(0, last) : "";
|
|
upsert.run(file.abs, file.rel, file.code, file.size, file.mtime, dir);
|
|
}
|
|
const rows = rawDb.prepare(`SELECT abs_path FROM video_metadata`).all() as Array<{ abs_path: string }>;
|
|
for (const row of rows) {
|
|
if (!found.has(row.abs_path)) deleteStale.run(row.abs_path);
|
|
}
|
|
});
|
|
tx();
|
|
classifyAndPersist(files);
|
|
|
|
// Probe-data refresh runs in the background. Awaiting here used to
|
|
// block rescan responses for minutes on libraries with many drifted
|
|
// files (e.g. after a bulk rename). Each per-file probe completion
|
|
// calls revalidatePath internally so detail pages update as soon as
|
|
// their own video is fresh — no batch-level waiting.
|
|
void reprobeDirtyFiles(files);
|
|
}
|
|
|
|
const REPROBE_CONCURRENCY = 2;
|
|
|
|
async function reprobeDirtyFiles(files: VideoFile[]): Promise<void> {
|
|
let dirty: Array<{ abs_path: string }>;
|
|
try {
|
|
dirty = rawDb
|
|
.prepare(`SELECT abs_path FROM video_metadata WHERE probed_at IS NULL AND probe_error IS NULL`)
|
|
.all() as Array<{ abs_path: string }>;
|
|
} catch (e) {
|
|
console.error("[video] reprobe-dirty query failed:", e);
|
|
return;
|
|
}
|
|
if (dirty.length === 0) return;
|
|
|
|
const dirtySet = new Set(dirty.map((r) => r.abs_path));
|
|
const targets = files.filter((f) => dirtySet.has(f.abs));
|
|
if (targets.length === 0) return;
|
|
|
|
// Process in chunks of REPROBE_CONCURRENCY. ffprobe is mostly waiting
|
|
// on disk; small parallelism is enough.
|
|
let cursor = 0;
|
|
const workers: Promise<void>[] = [];
|
|
// Throttle revalidation calls: a burst of 1000 path invalidations
|
|
// would itself thrash. Coalesce so each batch of N codes triggers
|
|
// one revalidate per code, deduped within a short window.
|
|
const codesSeen = new Set<string>();
|
|
for (let i = 0; i < REPROBE_CONCURRENCY; i++) {
|
|
workers.push((async () => {
|
|
while (cursor < targets.length) {
|
|
const idx = cursor++;
|
|
const file = targets[idx];
|
|
if (!file) break;
|
|
try {
|
|
await probeVideoMetadata(file);
|
|
if (!codesSeen.has(file.code)) {
|
|
codesSeen.add(file.code);
|
|
try { revalidatePath("/id/[code]", "page"); } catch { /* ignore */ }
|
|
}
|
|
} catch (e) {
|
|
console.error(`[video] reprobe failed for ${file.abs}:`, e);
|
|
}
|
|
}
|
|
})());
|
|
}
|
|
await Promise.all(workers).catch(() => { /* swallowed */ });
|
|
}
|
|
|
|
/**
|
|
* Recompute part/variant classification for every file based on the
|
|
* current `partSuffixPatterns` setting. Independent of probe data; safe
|
|
* to run on every scan.
|
|
*/
|
|
function classifyAndPersist(files: VideoFile[]): void {
|
|
const sources = getAppSetting("partSuffixPatterns") ?? [];
|
|
const patterns = compilePatterns(sources);
|
|
const byCode = new Map<string, VideoFile[]>();
|
|
for (const f of files) {
|
|
const arr = byCode.get(f.code);
|
|
if (arr) arr.push(f);
|
|
else byCode.set(f.code, [f]);
|
|
}
|
|
const update = rawDb.prepare(`
|
|
UPDATE video_metadata SET part_kind = ?, part_index = ?, variant_group = ?
|
|
WHERE abs_path = ?
|
|
`);
|
|
const tx = rawDb.transaction(() => {
|
|
for (const group of byCode.values()) {
|
|
const inputs = group.map((f) => ({
|
|
key: f.abs,
|
|
stem: stemOf(f.filename),
|
|
}));
|
|
const results = classifyGroup(inputs, patterns);
|
|
for (const r of results) {
|
|
update.run(r.partKind, r.partIndex, r.variantGroup, r.key);
|
|
}
|
|
}
|
|
});
|
|
tx();
|
|
}
|
|
|
|
function stemOf(filename: string): string {
|
|
const ext = path.extname(filename);
|
|
return ext ? filename.slice(0, -ext.length) : filename;
|
|
}
|
|
|
|
export interface SubtitleStreamInfo {
|
|
index: number;
|
|
codec: string;
|
|
language: string | null;
|
|
title: string | null;
|
|
isImageBased: boolean;
|
|
isTextBased: boolean;
|
|
}
|
|
|
|
const TEXT_SUBTITLE_CODECS = new Set(["subrip", "ass", "ssa", "mov_text", "webvtt", "text"]);
|
|
const IMAGE_SUBTITLE_CODECS = new Set(["hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle", "dvbsub", "pgssub"]);
|
|
|
|
interface FfprobeStream {
|
|
index?: number;
|
|
codec_type?: string;
|
|
codec_name?: string;
|
|
tags?: { language?: string; title?: string };
|
|
}
|
|
|
|
/** Enumerate subtitle streams in a container. Computed on demand — not
|
|
* persisted, since users frequently remux subs in/out and a stale list
|
|
* is worse than re-probing. Returns [] on error or missing ffprobe. */
|
|
export async function runFfprobeSubtitles(absPath: string): Promise<SubtitleStreamInfo[]> {
|
|
return new Promise((resolve) => {
|
|
const proc = spawn("ffprobe", [
|
|
"-v", "error",
|
|
"-select_streams", "s",
|
|
"-show_entries", "stream=index,codec_name,codec_type:stream_tags=language,title",
|
|
"-of", "json",
|
|
absPath,
|
|
]);
|
|
let out = "";
|
|
let settled = false;
|
|
const settle = (val: SubtitleStreamInfo[]) => { if (!settled) { settled = true; clearTimeout(t); resolve(val); } };
|
|
const t = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} settle([]); }, PROBE_TIMEOUT_MS);
|
|
proc.stdout?.on("data", (d) => { out += d.toString(); });
|
|
proc.on("error", () => settle([]));
|
|
proc.on("close", (code) => {
|
|
if (code !== 0) { settle([]); return; }
|
|
try {
|
|
const json = JSON.parse(out) as { streams?: FfprobeStream[] };
|
|
const streams = (json.streams ?? []).filter((s) => s.codec_type === "subtitle");
|
|
const result: SubtitleStreamInfo[] = streams.map((s, i) => {
|
|
const codec = (s.codec_name ?? "unknown").toLowerCase();
|
|
return {
|
|
// Use the per-codec_type ordinal — that's what ffmpeg's
|
|
// 0:s:N mapping wants, NOT the absolute stream index.
|
|
index: i,
|
|
codec,
|
|
language: typeof s.tags?.language === "string" ? s.tags.language : null,
|
|
title: typeof s.tags?.title === "string" ? s.tags.title : null,
|
|
isImageBased: IMAGE_SUBTITLE_CODECS.has(codec),
|
|
isTextBased: TEXT_SUBTITLE_CODECS.has(codec),
|
|
};
|
|
});
|
|
settle(result);
|
|
} catch {
|
|
settle([]);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function runFfprobe(absPath: string, signal?: AbortSignal): Promise<{
|
|
durationSec: number | null;
|
|
videoCodec: string | null;
|
|
videoBFrames: number | null;
|
|
width: number | null;
|
|
height: number | null;
|
|
videoBitrate: number | null;
|
|
}> {
|
|
return new Promise((resolve, reject) => {
|
|
const proc = spawn("ffprobe", [
|
|
"-v", "error",
|
|
"-select_streams", "v:0",
|
|
"-show_entries", "stream=codec_name,width,height,bit_rate,has_b_frames:format=duration,bit_rate",
|
|
"-of", "json",
|
|
absPath,
|
|
]);
|
|
let out = "";
|
|
let err = "";
|
|
let settled = false;
|
|
|
|
const settle = (fn: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeoutId);
|
|
if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
|
|
fn();
|
|
};
|
|
const kill = (message: string) => {
|
|
try { proc.kill("SIGKILL"); } catch {}
|
|
settle(() => reject(new Error(message)));
|
|
};
|
|
|
|
const timeoutId = setTimeout(() => kill("ffprobe timed out"), PROBE_TIMEOUT_MS);
|
|
const abortHandler = signal ? () => kill("ffprobe aborted") : null;
|
|
if (signal && abortHandler) {
|
|
if (signal.aborted) { kill("ffprobe aborted"); return; }
|
|
signal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
|
|
proc.stdout?.on("data", (d) => { out += d.toString(); });
|
|
proc.stderr?.on("data", (d) => { err += d.toString(); });
|
|
proc.on("error", (e) => settle(() => reject(e)));
|
|
proc.on("close", (code) => {
|
|
settle(() => {
|
|
if (code !== 0) {
|
|
reject(new Error(err.trim() || `ffprobe exited ${code}`));
|
|
return;
|
|
}
|
|
try {
|
|
const json = JSON.parse(out) as FfprobeJson;
|
|
const stream = json.streams?.[0] ?? {};
|
|
const streamBitrate = parseFiniteNumber(stream.bit_rate);
|
|
const formatBitrate = parseFiniteNumber(json.format?.bit_rate);
|
|
resolve({
|
|
durationSec: parseFiniteNumber(json.format?.duration),
|
|
videoCodec: typeof stream.codec_name === "string" ? stream.codec_name : null,
|
|
videoBFrames: parseNonNegativeNumber(stream.has_b_frames),
|
|
width: parseFiniteNumber(stream.width),
|
|
height: parseFiniteNumber(stream.height),
|
|
videoBitrate: streamBitrate ?? formatBitrate,
|
|
});
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function probeVideoMetadata(file: VideoFile, signal?: AbortSignal): Promise<StoredVideoMetadata> {
|
|
const stat = await fs.stat(file.abs);
|
|
const existing = getStoredVideoMetadata(file.abs);
|
|
if (existing && isStatMatch(existing, stat.size, stat.mtimeMs)) {
|
|
if (existing.probeError || existing.probedAt != null) return existing;
|
|
}
|
|
|
|
const base = {
|
|
absPath: file.abs,
|
|
relPath: file.rel,
|
|
code: file.code,
|
|
sizeBytes: stat.size,
|
|
mtimeMs: stat.mtimeMs,
|
|
playbackMode: existing?.playbackMode ?? null,
|
|
};
|
|
|
|
try {
|
|
const probed = await runFfprobe(file.abs, signal);
|
|
rawDb.prepare(`
|
|
INSERT INTO video_metadata (
|
|
abs_path, rel_path, code, size_bytes, mtime_ms, probed_at, probe_error,
|
|
duration_sec, video_codec, video_b_frames, width, height, video_bitrate, playback_mode
|
|
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(abs_path) DO UPDATE SET
|
|
rel_path = excluded.rel_path,
|
|
code = excluded.code,
|
|
size_bytes = excluded.size_bytes,
|
|
mtime_ms = excluded.mtime_ms,
|
|
probed_at = excluded.probed_at,
|
|
probe_error = NULL,
|
|
duration_sec = excluded.duration_sec,
|
|
video_codec = excluded.video_codec,
|
|
video_b_frames = excluded.video_b_frames,
|
|
width = excluded.width,
|
|
height = excluded.height,
|
|
video_bitrate = excluded.video_bitrate,
|
|
playback_mode = excluded.playback_mode
|
|
`).run(
|
|
base.absPath, base.relPath, base.code, base.sizeBytes, base.mtimeMs, Date.now(),
|
|
probed.durationSec, probed.videoCodec, probed.videoBFrames, probed.width, probed.height, probed.videoBitrate, base.playbackMode,
|
|
);
|
|
} catch (e) {
|
|
rawDb.prepare(`
|
|
INSERT INTO video_metadata (
|
|
abs_path, rel_path, code, size_bytes, mtime_ms, probed_at, probe_error, playback_mode
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(abs_path) DO UPDATE SET
|
|
rel_path = excluded.rel_path,
|
|
code = excluded.code,
|
|
size_bytes = excluded.size_bytes,
|
|
mtime_ms = excluded.mtime_ms,
|
|
probed_at = excluded.probed_at,
|
|
probe_error = excluded.probe_error,
|
|
playback_mode = excluded.playback_mode
|
|
`).run(
|
|
base.absPath, base.relPath, base.code, base.sizeBytes, base.mtimeMs, Date.now(),
|
|
e instanceof Error ? e.message.slice(0, 500) : "ffprobe failed",
|
|
base.playbackMode,
|
|
);
|
|
}
|
|
|
|
return getStoredVideoMetadata(file.abs) ?? {
|
|
...base,
|
|
probedAt: null,
|
|
probeError: "metadata unavailable",
|
|
durationSec: null,
|
|
videoCodec: null,
|
|
videoBFrames: null,
|
|
width: null,
|
|
height: null,
|
|
videoBitrate: null,
|
|
partKind: null,
|
|
partIndex: null,
|
|
variantGroup: null,
|
|
};
|
|
}
|
|
|
|
export function setVideoPlaybackMode(file: VideoFile, mode: PlaybackMode | null): void {
|
|
rawDb.prepare(`
|
|
INSERT INTO video_metadata (abs_path, rel_path, code, size_bytes, mtime_ms, playback_mode)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(abs_path) DO UPDATE SET
|
|
rel_path = excluded.rel_path,
|
|
code = excluded.code,
|
|
size_bytes = excluded.size_bytes,
|
|
mtime_ms = excluded.mtime_ms,
|
|
playback_mode = excluded.playback_mode
|
|
`).run(file.abs, file.rel, file.code, file.size, file.mtime, mode);
|
|
}
|
|
|
|
export function formatDuration(sec: number | null | undefined): string | null {
|
|
if (sec == null || !Number.isFinite(sec) || sec <= 0) return null;
|
|
const total = Math.round(sec);
|
|
const h = Math.floor(total / 3600);
|
|
const m = Math.floor((total % 3600) / 60);
|
|
const s = total % 60;
|
|
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
return `${m}:${String(s).padStart(2, "0")}`;
|
|
}
|
|
|
|
export function formatBitrate(bps: number | null | undefined): string | null {
|
|
if (bps == null || !Number.isFinite(bps) || bps <= 0) return null;
|
|
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`;
|
|
if (bps >= 1_000) return `${Math.round(bps / 1_000)} Kbps`;
|
|
return `${Math.round(bps)} bps`;
|
|
}
|
|
|
|
export function formatBytes(bytes: number | null | undefined): string | null {
|
|
if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return null;
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let n = bytes;
|
|
let i = 0;
|
|
while (n >= 1024 && i < units.length - 1) {
|
|
n /= 1024;
|
|
i++;
|
|
}
|
|
return `${i === 0 ? Math.round(n) : n.toFixed(n >= 10 ? 1 : 2)} ${units[i]}`;
|
|
}
|
|
|
|
export function formatResolution(width: number | null | undefined, height: number | null | undefined): string | null {
|
|
if (!width || !height) return null;
|
|
return `${width}x${height}`;
|
|
}
|
|
|
|
export function formatCodec(codec: string | null | undefined): string | null {
|
|
if (!codec) return null;
|
|
const map: Record<string, string> = {
|
|
h264: "H.264",
|
|
hevc: "HEVC",
|
|
h265: "HEVC",
|
|
av1: "AV1",
|
|
vp9: "VP9",
|
|
mpeg4: "MPEG-4",
|
|
};
|
|
return map[codec.toLowerCase()] ?? codec.toUpperCase();
|
|
}
|
|
|
|
export function formatVideoSummary(meta: StoredVideoMetadata | null | undefined): string | null {
|
|
if (!meta || meta.probeError) return null;
|
|
const parts = [
|
|
formatResolution(meta.width, meta.height),
|
|
formatCodec(meta.videoCodec),
|
|
formatBitrate(meta.videoBitrate),
|
|
formatBytes(meta.sizeBytes),
|
|
formatDuration(meta.durationSec),
|
|
].filter((part): part is string => Boolean(part));
|
|
return parts.length > 0 ? parts.join(" · ") : null;
|
|
}
|