Files
pinkudex/lib/db/appSettings.ts
T
2026-05-26 22:46:00 +02:00

279 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (24). */
gridColumns: number;
/** Number of cover columns in the portrait/front-only view (410). */
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<string, string | undefined> | undefined;
}
const cache: Map<string, string | undefined> =
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<WhisperJavSettings>;
// Merge with defaults so older rows missing a key still resolve.
return { ...APP_SETTINGS_DEFAULTS.whisperjav, ...parsed };
} catch {
return undefined;
}
},
},
};
export function getAppSetting<K extends keyof AppSettings>(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<K extends keyof AppSettings>(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();
}