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 { 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 { 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[] = []; // 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(); 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(); 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 { 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 { 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 = { 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; }