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