Initial commit
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user