import "server-only"; import { rawDb } from "./client"; import { isValidSort, type SortKey } from "../sort"; export interface AppSettings { fadeTransitions: boolean; fadeDurationMs: number; /** When emptying the recycle bin (or hard-deleting), also remove the file from disk. */ purgeFilesOnDelete: boolean; /** When true, delete sends to the recycle bin instead of removing immediately. */ useRecycleBin: boolean; /** Auto-purge trashed items after this many days. 0 = never auto-purge. */ trashRetentionDays: number; /** Auto-purge files in library/.superseded/ after this many days. 0 = never. */ supersededRetentionDays: number; defaultSort: SortKey; /** Hex color overriding --color-cyan / --color-cyan-glow. Empty string = use default. */ accentPrimary: string; /** Hex color overriding --color-violet / --color-violet-glow. Empty string = use default. */ accentSecondary: string; /** Number of cover columns in the masonry grid (2–4). */ gridColumns: number; /** Number of cover columns in the portrait/front-only view (4–10). */ gridColumnsPortrait: number; /** Cover grid page size — items fetched per page on `/` and per * infinite-scroll append. */ coverPageSize: number; /** Which layout the Settings drawer renders. "sidebar" = vertical nav * with focused single-section content. "three-column" = everything * visible in a 3-column wide layout. */ settingsLayout: "sidebar" | "three-column"; /** Absolute path to the main video library folder. Walked recursively, * expected to follow the same letter-bucket structure as library/ * (e.g. D:\JAV\A-E\A\AOZ-200Z.mp4). Empty string disables. */ videoLibraryPath: string; /** Additional video folders. These are also walked recursively, but * the user typically points them at flat folders that hold files * directly (e.g. E:\JAV\IBW-203.mp4). One absolute path per entry. */ videoExtraPaths: string[]; /** Folders scanned recursively for subtitle sidecars whose filename * matches the playing video's stem or code. Empty by default — the * player still finds same-folder sidecars without this. */ subtitleExtraPaths: string[]; /** Soft cap on data/subtitle-cache/ size in MB. When the cache grows * beyond this, an LRU sweep deletes oldest-mtime entries until the * total drops below 80% of the cap. 0 = unlimited. */ subtitleCacheLimitMb: number; /** Playback path selector — controls how video files are streamed. * - "off": always serve the original file directly via HTTP Range. * - "always": always transcode through ffmpeg+NVENC HLS pipeline. * - "auto-predicate": transcode only if a quick ffprobe says the file * is H.264 with B-frames (the trigger profile for Chromium's sink * reorder bug). Other codecs / no-B-frame H.264 → direct. * - "auto-runtime": play direct first, measure dropped frames over * a short pre-roll, decide per file, persist the decision. */ transcodeMode: "off" | "always" | "auto-predicate" | "auto-runtime"; /** Token-grammar suffix patterns used to classify video files into * sequential parts. Patterns are matched at the end of the filename * stem. `{N}` = digits (captured as part index), `{L}` = letter * (A=1, B=2…), all other characters literal. Files in the same code * group whose stem matches none of these patterns are treated as * variants of the matched files (alternate encodes). */ partSuffixPatterns: string[]; /** WhisperJAV subtitle-generator integration. Empty cliPath disables * the picker's Generate action. */ whisperjav: WhisperJavSettings; /** How the pagination bar's Prev/Next/Jump buttons behave. * - "url": always push a new URL, full page remount. Predictable. * - "scroll": scroll within the loaded buffer; prefetch forward * pages on demand; fall back to URL push only when the target is * behind the SSR anchor. */ paginationMode: "url" | "scroll"; } export type PaginationMode = AppSettings["paginationMode"]; export interface WhisperJavSettings { /** Resolved CLI path. Empty string = feature disabled. */ cliPath: string; quality: "fast" | "balanced" | "qwen"; sourceLanguage: "japanese" | "korean" | "chinese" | "english"; outputMode: "native" | "direct-to-english"; sensitivity: "conservative" | "balanced" | "aggressive"; outputLocation: "beside-video" | "data-folder"; /** When true, append --no-signature so generated subs don't include * WhisperJAV's trailing technical signature cue. */ noSignature: boolean; /** Days to keep failed / cancelled job temp directories. Successful * job dirs are deleted immediately. 0 = keep forever. */ retentionDays: number; } export type TranscodeMode = AppSettings["transcodeMode"]; export const APP_SETTINGS_DEFAULTS: AppSettings = { fadeTransitions: true, fadeDurationMs: 400, purgeFilesOnDelete: true, useRecycleBin: true, trashRetentionDays: 30, supersededRetentionDays: 30, defaultSort: "newest", accentPrimary: "", accentSecondary: "", gridColumns: 3, gridColumnsPortrait: 6, coverPageSize: 100, settingsLayout: "sidebar", videoLibraryPath: "", videoExtraPaths: [], subtitleExtraPaths: [], subtitleCacheLimitMb: 100, transcodeMode: "off", partSuffixPatterns: ["-cd{N}", ".part{N}", "_{N}", "_{L}"], whisperjav: { cliPath: "", quality: "balanced", sourceLanguage: "japanese", outputMode: "native", sensitivity: "balanced", outputLocation: "beside-video", noSignature: true, retentionDays: 30, }, paginationMode: "scroll", }; const HEX_RE = /^#([0-9a-fA-F]{6})$/; function decodeHex(raw: string): string | undefined { if (raw === "") return ""; return HEX_RE.test(raw) ? raw.toLowerCase() : undefined; } // Pin cache to globalThis so multiple bundle copies of this module // (Turbopack dev can produce more than one) share state — otherwise // the server action that saves a setting writes to one cache while // the queue worker keeps reading a stale value from another. declare global { // eslint-disable-next-line no-var var __pinkudexAppSettingsCache: Map | undefined; } const cache: Map = global.__pinkudexAppSettingsCache ?? (global.__pinkudexAppSettingsCache = new Map()); function getRaw(key: string): string | undefined { if (cache.has(key)) return cache.get(key); const row = rawDb.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(key) as | { value: string } | undefined; cache.set(key, row?.value); return row?.value; } function setRaw(key: string, value: string): void { rawDb.prepare(` INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value `).run(key, value); cache.set(key, value); } const SERIALIZERS: { [K in keyof AppSettings]: { encode: (v: AppSettings[K]) => string; decode: (raw: string) => AppSettings[K] | undefined } } = { fadeTransitions: { encode: (v) => (v ? "1" : "0"), decode: (r) => r === "1" ? true : r === "0" ? false : undefined }, fadeDurationMs: { encode: (v) => String(v), decode: (r) => { const n = Number(r); return Number.isFinite(n) ? n : undefined; } }, purgeFilesOnDelete: { encode: (v) => (v ? "1" : "0"), decode: (r) => r === "1" ? true : r === "0" ? false : undefined }, useRecycleBin: { encode: (v) => (v ? "1" : "0"), decode: (r) => r === "1" ? true : r === "0" ? false : undefined }, trashRetentionDays: { encode: (v) => String(v), decode: (r) => { const n = Number(r); return Number.isFinite(n) && n >= 0 ? n : undefined; } }, supersededRetentionDays: { encode: (v) => String(v), decode: (r) => { const n = Number(r); return Number.isFinite(n) && n >= 0 ? n : undefined; } }, defaultSort: { encode: (v) => v, decode: (r) => isValidSort(r) ? r : undefined }, accentPrimary: { encode: (v) => v, decode: decodeHex }, accentSecondary: { encode: (v) => v, decode: decodeHex }, gridColumns: { encode: (v) => String(v), decode: (r) => { const n = Number(r); return Number.isFinite(n) && n >= 2 && n <= 4 ? Math.round(n) : undefined; } }, gridColumnsPortrait: { encode: (v) => String(v), decode: (r) => { const n = Number(r); return Number.isFinite(n) && n >= 4 && n <= 10 ? Math.round(n) : undefined; } }, coverPageSize: { encode: (v) => String(v), decode: (r) => { const n = Number(r); return Number.isFinite(n) && n >= 25 && n <= 500 ? Math.round(n) : undefined; } }, settingsLayout: { encode: (v) => v, decode: (r) => r === "sidebar" || r === "three-column" ? r : undefined }, videoLibraryPath: { encode: (v) => v, decode: (r) => typeof r === "string" ? r : undefined }, videoExtraPaths: { // Stored as newline-separated absolute paths so the value remains a // plain string at the storage layer. Empty / blank lines are stripped. encode: (v) => (v ?? []).filter((s) => s && s.trim()).join("\n"), decode: (r) => typeof r === "string" ? r.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : undefined, }, subtitleExtraPaths: { encode: (v) => (v ?? []).filter((s) => s && s.trim()).join("\n"), decode: (r) => typeof r === "string" ? r.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : undefined, }, subtitleCacheLimitMb: { encode: (v) => String(v), decode: (r) => { const n = Number(r); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined; }, }, transcodeMode: { encode: (v) => v, decode: (r) => { if (r === "off" || r === "always" || r === "auto-predicate" || r === "auto-runtime") return r; // Migrate legacy boolean values from the old `transcodePlayback` setting. if (r === "1") return "always"; if (r === "0") return "off"; return undefined; }, }, partSuffixPatterns: { // Newline-separated to keep the storage value a plain string. Empty // / blank lines are stripped on decode. encode: (v) => (v ?? []).map((s) => s ?? "").map((s) => s.trim()).filter(Boolean).join("\n"), decode: (r) => typeof r === "string" ? r.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : undefined, }, paginationMode: { encode: (v) => v, decode: (r) => { if (r === "url" || r === "scroll") return r; // Migrate any persisted "split" rows to scroll (the default). if (r === "split") return "scroll"; return undefined; }, }, whisperjav: { encode: (v) => JSON.stringify(v ?? APP_SETTINGS_DEFAULTS.whisperjav), decode: (r) => { if (typeof r !== "string") return undefined; try { const parsed = JSON.parse(r) as Partial; // Merge with defaults so older rows missing a key still resolve. return { ...APP_SETTINGS_DEFAULTS.whisperjav, ...parsed }; } catch { return undefined; } }, }, }; export function getAppSetting(key: K): AppSettings[K] { const raw = getRaw(key); const decoded = raw == null ? undefined : SERIALIZERS[key].decode(raw); return (decoded ?? APP_SETTINGS_DEFAULTS[key]) as AppSettings[K]; } export function setAppSetting(key: K, value: AppSettings[K]): void { setRaw(key, SERIALIZERS[key].encode(value)); } export function getAllAppSettings(): AppSettings { return { fadeTransitions: getAppSetting("fadeTransitions"), fadeDurationMs: getAppSetting("fadeDurationMs"), purgeFilesOnDelete: getAppSetting("purgeFilesOnDelete"), useRecycleBin: getAppSetting("useRecycleBin"), trashRetentionDays: getAppSetting("trashRetentionDays"), supersededRetentionDays: getAppSetting("supersededRetentionDays"), defaultSort: getAppSetting("defaultSort"), accentPrimary: getAppSetting("accentPrimary"), accentSecondary: getAppSetting("accentSecondary"), gridColumns: getAppSetting("gridColumns"), gridColumnsPortrait: getAppSetting("gridColumnsPortrait"), coverPageSize: getAppSetting("coverPageSize"), settingsLayout: getAppSetting("settingsLayout"), videoLibraryPath: getAppSetting("videoLibraryPath"), videoExtraPaths: getAppSetting("videoExtraPaths"), subtitleExtraPaths: getAppSetting("subtitleExtraPaths"), subtitleCacheLimitMb: getAppSetting("subtitleCacheLimitMb"), transcodeMode: getAppSetting("transcodeMode"), partSuffixPatterns: getAppSetting("partSuffixPatterns"), whisperjav: getAppSetting("whisperjav"), paginationMode: getAppSetting("paginationMode"), }; } export function clearAppSettingsCache(): void { cache.clear(); }