Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+580
View File
@@ -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;
}