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();
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import "server-only";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import * as schema from "./schema";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { safeJoin } from "../safePath";
|
||||
|
||||
const DB_PATH = path.join(process.cwd(), "data", "library.db");
|
||||
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
fs.mkdirSync(path.join(process.cwd(), "data", "thumbs"), { recursive: true });
|
||||
fs.mkdirSync(path.join(process.cwd(), "data", "portraits"), { recursive: true });
|
||||
fs.mkdirSync(path.join(process.cwd(), "library"), { recursive: true });
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __sqlite: Database.Database | undefined;
|
||||
}
|
||||
|
||||
const sqlite = global.__sqlite ?? new Database(DB_PATH);
|
||||
if (!global.__sqlite) {
|
||||
sqlite.pragma("journal_mode = WAL");
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
sqlite.pragma("synchronous = NORMAL");
|
||||
global.__sqlite = sqlite;
|
||||
}
|
||||
|
||||
bootstrap(sqlite);
|
||||
|
||||
function bootstrap(db: Database.Database) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
rel_path TEXT NOT NULL UNIQUE,
|
||||
thumb_path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL UNIQUE,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
bytes INTEGER NOT NULL,
|
||||
raw_metadata TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||
imported_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||
deleted_at INTEGER,
|
||||
parent_image_id INTEGER REFERENCES images(id) ON DELETE CASCADE,
|
||||
code TEXT,
|
||||
title TEXT,
|
||||
release_date TEXT,
|
||||
runtime_min INTEGER,
|
||||
director TEXT,
|
||||
studio_id INTEGER REFERENCES studios(id) ON DELETE SET NULL,
|
||||
label_id INTEGER REFERENCES labels(id) ON DELETE SET NULL,
|
||||
series_id INTEGER REFERENCES series(id) ON DELETE SET NULL,
|
||||
rating INTEGER,
|
||||
watched INTEGER NOT NULL DEFAULT 0,
|
||||
is_vip INTEGER NOT NULL DEFAULT 0,
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
is_owned INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT,
|
||||
phash TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS images_created_idx ON images(created_at);
|
||||
CREATE INDEX IF NOT EXISTS images_deleted_idx ON images(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS images_parent_idx ON images(parent_image_id);
|
||||
CREATE INDEX IF NOT EXISTS images_code_idx ON images(code);
|
||||
CREATE INDEX IF NOT EXISTS images_studio_idx ON images(studio_id);
|
||||
CREATE INDEX IF NOT EXISTS images_label_idx ON images(label_id);
|
||||
CREATE INDEX IF NOT EXISTS images_series_idx ON images(series_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS studios (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
notes TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
notes TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
notes TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS actresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
alt_names TEXT,
|
||||
notes TEXT,
|
||||
portrait_path TEXT,
|
||||
portrait_zoom REAL NOT NULL DEFAULT 1,
|
||||
portrait_offset_x REAL NOT NULL DEFAULT 0,
|
||||
portrait_offset_y REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS genres (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS image_actresses (
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
actress_id INTEGER NOT NULL REFERENCES actresses(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (image_id, actress_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS image_actresses_actress_idx ON image_actresses(actress_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS image_genres (
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
genre_id INTEGER NOT NULL REFERENCES genres(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (image_id, genre_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS image_genres_genre_idx ON image_genres(genre_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tag_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
color TEXT,
|
||||
description TEXT,
|
||||
cover_portrait_path TEXT,
|
||||
cover_portrait_zoom REAL NOT NULL DEFAULT 1,
|
||||
cover_portrait_offset_x REAL NOT NULL DEFAULT 0,
|
||||
cover_portrait_offset_y REAL NOT NULL DEFAULT 0,
|
||||
cover_landscape_path TEXT,
|
||||
cover_landscape_zoom REAL NOT NULL DEFAULT 1,
|
||||
cover_landscape_offset_x REAL NOT NULL DEFAULT 0,
|
||||
cover_landscape_offset_y REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT,
|
||||
category_id INTEGER REFERENCES tag_categories(id) ON DELETE SET NULL,
|
||||
last_used_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
-- tags_category_idx is created AFTER the idempotent ALTER below, so
|
||||
-- it doesn't fire on an old DB whose tags table predates category_id.
|
||||
CREATE TABLE IF NOT EXISTS image_tags (
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (image_id, tag_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS image_tags_tag_idx ON image_tags(tag_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
cover_image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
last_used_at INTEGER NOT NULL DEFAULT 0,
|
||||
cover_portrait_path TEXT,
|
||||
cover_portrait_zoom REAL NOT NULL DEFAULT 1,
|
||||
cover_portrait_offset_x REAL NOT NULL DEFAULT 0,
|
||||
cover_portrait_offset_y REAL NOT NULL DEFAULT 0,
|
||||
cover_landscape_path TEXT,
|
||||
cover_landscape_zoom REAL NOT NULL DEFAULT 1,
|
||||
cover_landscape_offset_x REAL NOT NULL DEFAULT 0,
|
||||
cover_landscape_offset_y REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS collection_images (
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (collection_id, image_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS actress_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
builtin INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS actress_categories_map (
|
||||
actress_id INTEGER NOT NULL REFERENCES actresses(id) ON DELETE CASCADE,
|
||||
category_id INTEGER NOT NULL REFERENCES actress_categories(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (actress_id, category_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS actress_cat_map_cat_idx ON actress_categories_map(category_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS video_metadata (
|
||||
abs_path TEXT PRIMARY KEY,
|
||||
rel_path TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
mtime_ms REAL NOT NULL,
|
||||
probed_at INTEGER,
|
||||
probe_error TEXT,
|
||||
duration_sec REAL,
|
||||
video_codec TEXT,
|
||||
video_b_frames INTEGER,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
video_bitrate INTEGER,
|
||||
playback_mode TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS video_metadata_code_idx ON video_metadata(code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS whisperjav_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
video_abs TEXT NOT NULL,
|
||||
job_dir TEXT NOT NULL,
|
||||
target_subtitle_path TEXT,
|
||||
status TEXT NOT NULL CHECK(status IN ('queued','running','completed','warning','failed','cancelled')),
|
||||
enqueued_at INTEGER NOT NULL,
|
||||
started_at INTEGER,
|
||||
ended_at INTEGER,
|
||||
exit_code INTEGER,
|
||||
error TEXT,
|
||||
stage TEXT,
|
||||
stage_index INTEGER,
|
||||
stage_total INTEGER,
|
||||
cue_count INTEGER,
|
||||
cli_args TEXT NOT NULL,
|
||||
log_path TEXT NOT NULL,
|
||||
stats_path TEXT,
|
||||
video_duration_sec REAL,
|
||||
mode TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS whisperjav_jobs_code_idx ON whisperjav_jobs(code);
|
||||
CREATE INDEX IF NOT EXISTS whisperjav_jobs_status_idx ON whisperjav_jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS whisperjav_jobs_enqueued_idx ON whisperjav_jobs(enqueued_at);
|
||||
|
||||
-- User-attached subtitle files that live outside the indexed roots
|
||||
-- and subtitleExtraPaths. Browse... in the player records the pick
|
||||
-- here so the entry survives modal close / server restart.
|
||||
CREATE TABLE IF NOT EXISTS manual_subtitles (
|
||||
code TEXT NOT NULL,
|
||||
part_idx INTEGER NOT NULL,
|
||||
abs_path TEXT NOT NULL,
|
||||
attached_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (code, part_idx, abs_path)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS manual_subtitles_code_idx ON manual_subtitles(code);
|
||||
CREATE INDEX IF NOT EXISTS manual_subtitles_abs_idx ON manual_subtitles(abs_path);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS covers_fts USING fts5(
|
||||
code, title, director, notes,
|
||||
content='images', content_rowid='id',
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS covers_ai AFTER INSERT ON images BEGIN
|
||||
INSERT INTO covers_fts(rowid, code, title, director, notes)
|
||||
VALUES (new.id, COALESCE(new.code, ''), COALESCE(new.title, ''), COALESCE(new.director, ''), COALESCE(new.notes, ''));
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS covers_ad AFTER DELETE ON images BEGIN
|
||||
INSERT INTO covers_fts(covers_fts, rowid, code, title, director, notes)
|
||||
VALUES ('delete', old.id, COALESCE(old.code, ''), COALESCE(old.title, ''), COALESCE(old.director, ''), COALESCE(old.notes, ''));
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS covers_au AFTER UPDATE ON images BEGIN
|
||||
INSERT INTO covers_fts(covers_fts, rowid, code, title, director, notes)
|
||||
VALUES ('delete', old.id, COALESCE(old.code, ''), COALESCE(old.title, ''), COALESCE(old.director, ''), COALESCE(old.notes, ''));
|
||||
INSERT INTO covers_fts(rowid, code, title, director, notes)
|
||||
VALUES (new.id, COALESCE(new.code, ''), COALESCE(new.title, ''), COALESCE(new.director, ''), COALESCE(new.notes, ''));
|
||||
END;
|
||||
`);
|
||||
|
||||
// Existing databases may have images that predate the FTS table/triggers.
|
||||
// Rebuild once per process bootstrap so metadata search is complete.
|
||||
try {
|
||||
db.prepare(`INSERT INTO covers_fts(covers_fts) VALUES ('rebuild')`).run();
|
||||
} catch {}
|
||||
|
||||
// Seed built-in actress categories (idempotent — only inserts if absent).
|
||||
const seedCat = db.prepare(`
|
||||
INSERT OR IGNORE INTO actress_categories (name, slug, color, icon, priority, builtin)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
`);
|
||||
seedCat.run("Favorite", "favorite", "#fbbf24", "star", 100);
|
||||
seedCat.run("VIP", "vip", "#22d3ee", "gem", 90);
|
||||
|
||||
// Idempotent ALTERs for columns added after initial release.
|
||||
const actressCols = db.prepare(`PRAGMA table_info(actresses)`).all() as Array<{ name: string }>;
|
||||
const hasCol = (n: string) => actressCols.some((c) => c.name === n);
|
||||
if (!hasCol("portrait_path")) db.exec(`ALTER TABLE actresses ADD COLUMN portrait_path TEXT`);
|
||||
if (!hasCol("portrait_zoom")) db.exec(`ALTER TABLE actresses ADD COLUMN portrait_zoom REAL NOT NULL DEFAULT 1`);
|
||||
if (!hasCol("portrait_offset_x")) db.exec(`ALTER TABLE actresses ADD COLUMN portrait_offset_x REAL NOT NULL DEFAULT 0`);
|
||||
if (!hasCol("portrait_offset_y")) db.exec(`ALTER TABLE actresses ADD COLUMN portrait_offset_y REAL NOT NULL DEFAULT 0`);
|
||||
for (const slot of ["2", "3", "4", "h"]) {
|
||||
if (!hasCol(`portrait${slot}_path`)) db.exec(`ALTER TABLE actresses ADD COLUMN portrait${slot}_path TEXT`);
|
||||
if (!hasCol(`portrait${slot}_zoom`)) db.exec(`ALTER TABLE actresses ADD COLUMN portrait${slot}_zoom REAL NOT NULL DEFAULT 1`);
|
||||
if (!hasCol(`portrait${slot}_offset_x`)) db.exec(`ALTER TABLE actresses ADD COLUMN portrait${slot}_offset_x REAL NOT NULL DEFAULT 0`);
|
||||
if (!hasCol(`portrait${slot}_offset_y`)) db.exec(`ALTER TABLE actresses ADD COLUMN portrait${slot}_offset_y REAL NOT NULL DEFAULT 0`);
|
||||
}
|
||||
if (!hasCol("born_on")) db.exec(`ALTER TABLE actresses ADD COLUMN born_on TEXT`);
|
||||
if (!hasCol("height_cm")) db.exec(`ALTER TABLE actresses ADD COLUMN height_cm INTEGER`);
|
||||
if (!hasCol("weight_kg")) db.exec(`ALTER TABLE actresses ADD COLUMN weight_kg INTEGER`);
|
||||
if (!hasCol("cup_size")) db.exec(`ALTER TABLE actresses ADD COLUMN cup_size TEXT`);
|
||||
|
||||
// VIP / Favorite toggles on covers (separate from the actress categories of the same name).
|
||||
const imageCols = db.prepare(`PRAGMA table_info(images)`).all() as Array<{ name: string }>;
|
||||
const hasImgCol = (n: string) => imageCols.some((c) => c.name === n);
|
||||
if (!hasImgCol("is_vip")) db.exec(`ALTER TABLE images ADD COLUMN is_vip INTEGER NOT NULL DEFAULT 0`);
|
||||
if (!hasImgCol("is_favorite")) db.exec(`ALTER TABLE images ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0`);
|
||||
if (!hasImgCol("is_owned")) db.exec(`ALTER TABLE images ADD COLUMN is_owned INTEGER NOT NULL DEFAULT 0`);
|
||||
// Set by the video scanner whenever the on-disk index is rebuilt — see lib/video/index.ts.
|
||||
if (!hasImgCol("has_video")) {
|
||||
db.exec(`ALTER TABLE images ADD COLUMN has_video INTEGER NOT NULL DEFAULT 0`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS images_has_video_idx ON images(has_video)`);
|
||||
}
|
||||
// Mirrors the on-disk subtitle index — true when at least one sidecar
|
||||
// (.srt/.vtt/.ass/.ssa) exists for the code in any of: the video's
|
||||
// folder, configured subtitleExtraPaths, or data/generated-subtitles/.
|
||||
// Embedded streams are NOT counted (cheap-only signal).
|
||||
if (!hasImgCol("has_subtitle")) {
|
||||
db.exec(`ALTER TABLE images ADD COLUMN has_subtitle INTEGER NOT NULL DEFAULT 0`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS images_has_subtitle_idx ON images(has_subtitle)`);
|
||||
}
|
||||
// Lazy-probed video metadata used by playback auto-modes. Populated on
|
||||
// first play of a file; never written by the scanner.
|
||||
if (!hasImgCol("video_codec")) db.exec(`ALTER TABLE images ADD COLUMN video_codec TEXT`);
|
||||
if (!hasImgCol("video_b_frames")) db.exec(`ALTER TABLE images ADD COLUMN video_b_frames INTEGER`);
|
||||
if (!hasImgCol("playback_mode")) db.exec(`ALTER TABLE images ADD COLUMN playback_mode TEXT`);
|
||||
|
||||
const videoMetaCols = db.prepare(`PRAGMA table_info(video_metadata)`).all() as Array<{ name: string }>;
|
||||
const hasVideoMetaCol = (n: string) => videoMetaCols.some((c) => c.name === n);
|
||||
if (!hasVideoMetaCol("rel_path")) db.exec(`ALTER TABLE video_metadata ADD COLUMN rel_path TEXT NOT NULL DEFAULT ''`);
|
||||
if (!hasVideoMetaCol("code")) db.exec(`ALTER TABLE video_metadata ADD COLUMN code TEXT NOT NULL DEFAULT ''`);
|
||||
if (!hasVideoMetaCol("size_bytes")) db.exec(`ALTER TABLE video_metadata ADD COLUMN size_bytes INTEGER NOT NULL DEFAULT 0`);
|
||||
if (!hasVideoMetaCol("mtime_ms")) db.exec(`ALTER TABLE video_metadata ADD COLUMN mtime_ms REAL NOT NULL DEFAULT 0`);
|
||||
if (!hasVideoMetaCol("probed_at")) db.exec(`ALTER TABLE video_metadata ADD COLUMN probed_at INTEGER`);
|
||||
if (!hasVideoMetaCol("probe_error")) db.exec(`ALTER TABLE video_metadata ADD COLUMN probe_error TEXT`);
|
||||
if (!hasVideoMetaCol("duration_sec")) db.exec(`ALTER TABLE video_metadata ADD COLUMN duration_sec REAL`);
|
||||
if (!hasVideoMetaCol("video_codec")) db.exec(`ALTER TABLE video_metadata ADD COLUMN video_codec TEXT`);
|
||||
if (!hasVideoMetaCol("video_b_frames")) db.exec(`ALTER TABLE video_metadata ADD COLUMN video_b_frames INTEGER`);
|
||||
if (!hasVideoMetaCol("width")) db.exec(`ALTER TABLE video_metadata ADD COLUMN width INTEGER`);
|
||||
if (!hasVideoMetaCol("height")) db.exec(`ALTER TABLE video_metadata ADD COLUMN height INTEGER`);
|
||||
if (!hasVideoMetaCol("video_bitrate")) db.exec(`ALTER TABLE video_metadata ADD COLUMN video_bitrate INTEGER`);
|
||||
if (!hasVideoMetaCol("playback_mode")) db.exec(`ALTER TABLE video_metadata ADD COLUMN playback_mode TEXT`);
|
||||
if (!hasVideoMetaCol("part_kind")) db.exec(`ALTER TABLE video_metadata ADD COLUMN part_kind TEXT`);
|
||||
if (!hasVideoMetaCol("part_index")) db.exec(`ALTER TABLE video_metadata ADD COLUMN part_index INTEGER`);
|
||||
if (!hasVideoMetaCol("variant_group")) db.exec(`ALTER TABLE video_metadata ADD COLUMN variant_group TEXT`);
|
||||
// dir_path enables incremental rescan: reuse cached rows for any
|
||||
// directory whose mtime hasn't changed since last scan. Backfilled
|
||||
// below for any pre-existing rows.
|
||||
if (!hasVideoMetaCol("dir_path")) db.exec(`ALTER TABLE video_metadata ADD COLUMN dir_path TEXT NOT NULL DEFAULT ''`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS video_metadata_code_idx ON video_metadata(code)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS video_metadata_dir_idx ON video_metadata(dir_path)`);
|
||||
|
||||
// Per-directory mtime cache. On rescan, dirs whose stat mtime
|
||||
// matches the stored value are treated as unchanged and their cached
|
||||
// file rows are reused without readdir/stat per file.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS video_dir_mtimes (
|
||||
abs_dir TEXT PRIMARY KEY,
|
||||
mtime_ms REAL NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
// Backfill dir_path for existing rows that predate this column.
|
||||
// Cheap: one UPDATE per missing row, derived in SQL via rtrim/instr.
|
||||
// Do nothing if there's nothing to fill (default '' marker).
|
||||
const missing = db.prepare(`SELECT COUNT(*) AS n FROM video_metadata WHERE dir_path = ''`).get() as { n: number };
|
||||
if (missing.n > 0) {
|
||||
type Row = { abs_path: string };
|
||||
const rows = db.prepare(`SELECT abs_path FROM video_metadata WHERE dir_path = ''`).all() as Row[];
|
||||
const upd = db.prepare(`UPDATE video_metadata SET dir_path = ? WHERE abs_path = ?`);
|
||||
const tx = db.transaction(() => {
|
||||
for (const r of rows) {
|
||||
const last = Math.max(r.abs_path.lastIndexOf("/"), r.abs_path.lastIndexOf("\\"));
|
||||
const dir = last >= 0 ? r.abs_path.slice(0, last) : "";
|
||||
upd.run(dir, r.abs_path);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
// whisperjav_jobs: ETA fields added in v1.1 — idempotent migration.
|
||||
const wjCols = db.prepare(`PRAGMA table_info(whisperjav_jobs)`).all() as Array<{ name: string }>;
|
||||
const hasWjCol = (n: string) => wjCols.some((c) => c.name === n);
|
||||
if (!hasWjCol("video_duration_sec")) db.exec(`ALTER TABLE whisperjav_jobs ADD COLUMN video_duration_sec REAL`);
|
||||
if (!hasWjCol("mode")) db.exec(`ALTER TABLE whisperjav_jobs ADD COLUMN mode TEXT`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS whisperjav_jobs_mode_idx ON whisperjav_jobs(mode)`);
|
||||
// Perceptual dHash (16-char hex, 64 bits) for near-duplicate detection.
|
||||
// Backfilled lazily by the maintenance scanner.
|
||||
if (!hasImgCol("phash")) db.exec(`ALTER TABLE images ADD COLUMN phash TEXT`);
|
||||
|
||||
// Recency tracking on tags + collections so the context menu can
|
||||
// surface "recent" chips. Updated whenever the entity is attached to
|
||||
// an image. Default backfill = current time so existing rows show up.
|
||||
const tagCols2 = db.prepare(`PRAGMA table_info(tags)`).all() as Array<{ name: string }>;
|
||||
if (!tagCols2.some((c) => c.name === "last_used_at")) {
|
||||
db.exec(`ALTER TABLE tags ADD COLUMN last_used_at INTEGER NOT NULL DEFAULT 0`);
|
||||
db.exec(`UPDATE tags SET last_used_at = (unixepoch() * 1000)`);
|
||||
}
|
||||
const collectionCols = db.prepare(`PRAGMA table_info(collections)`).all() as Array<{ name: string }>;
|
||||
if (!collectionCols.some((c) => c.name === "last_used_at")) {
|
||||
db.exec(`ALTER TABLE collections ADD COLUMN last_used_at INTEGER NOT NULL DEFAULT 0`);
|
||||
db.exec(`UPDATE collections SET last_used_at = COALESCE(created_at, unixepoch() * 1000)`);
|
||||
}
|
||||
|
||||
// Manual reorder support for the collections index page. Backfill
|
||||
// positions from created_at so existing libraries get a sensible
|
||||
// initial order on first launch with the new schema.
|
||||
const colCols = db.prepare(`PRAGMA table_info(collections)`).all() as Array<{ name: string }>;
|
||||
if (!colCols.some((c) => c.name === "position")) {
|
||||
db.exec(`ALTER TABLE collections ADD COLUMN position INTEGER NOT NULL DEFAULT 0`);
|
||||
const rows = db.prepare(`SELECT id FROM collections ORDER BY created_at ASC, id ASC`).all() as Array<{ id: number }>;
|
||||
const update = db.prepare(`UPDATE collections SET position = ? WHERE id = ?`);
|
||||
for (let i = 0; i < rows.length; i++) update.run(i, rows[i].id);
|
||||
}
|
||||
|
||||
// Tag categories: umbrellas grouping related tags (e.g. "BDSM" containing
|
||||
// bondage / shibari / cuffs). Each tag belongs to at most one category.
|
||||
const tagCols = db.prepare(`PRAGMA table_info(tags)`).all() as Array<{ name: string }>;
|
||||
if (!tagCols.some((c) => c.name === "category_id")) {
|
||||
db.exec(`ALTER TABLE tags ADD COLUMN category_id INTEGER REFERENCES tag_categories(id) ON DELETE SET NULL`);
|
||||
}
|
||||
// Create the index unconditionally — both the fresh-DB CREATE TABLE path
|
||||
// and the migrated path need it, and IF NOT EXISTS makes it idempotent.
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS tags_category_idx ON tags(category_id)`);
|
||||
|
||||
// Tag-category cover art: separate portrait + landscape slots with
|
||||
// pan/zoom transforms, mirroring the actress-portrait shape. Files
|
||||
// live in data/category-covers/.
|
||||
const catCols = db.prepare(`PRAGMA table_info(tag_categories)`).all() as Array<{ name: string }>;
|
||||
const hasCatCol = (n: string) => catCols.some((c) => c.name === n);
|
||||
if (!hasCatCol("cover_portrait_path")) db.exec(`ALTER TABLE tag_categories ADD COLUMN cover_portrait_path TEXT`);
|
||||
if (!hasCatCol("cover_portrait_zoom")) db.exec(`ALTER TABLE tag_categories ADD COLUMN cover_portrait_zoom REAL NOT NULL DEFAULT 1`);
|
||||
if (!hasCatCol("cover_portrait_offset_x")) db.exec(`ALTER TABLE tag_categories ADD COLUMN cover_portrait_offset_x REAL NOT NULL DEFAULT 0`);
|
||||
if (!hasCatCol("cover_portrait_offset_y")) db.exec(`ALTER TABLE tag_categories ADD COLUMN cover_portrait_offset_y REAL NOT NULL DEFAULT 0`);
|
||||
if (!hasCatCol("cover_landscape_path")) db.exec(`ALTER TABLE tag_categories ADD COLUMN cover_landscape_path TEXT`);
|
||||
if (!hasCatCol("cover_landscape_zoom")) db.exec(`ALTER TABLE tag_categories ADD COLUMN cover_landscape_zoom REAL NOT NULL DEFAULT 1`);
|
||||
if (!hasCatCol("cover_landscape_offset_x")) db.exec(`ALTER TABLE tag_categories ADD COLUMN cover_landscape_offset_x REAL NOT NULL DEFAULT 0`);
|
||||
if (!hasCatCol("cover_landscape_offset_y")) db.exec(`ALTER TABLE tag_categories ADD COLUMN cover_landscape_offset_y REAL NOT NULL DEFAULT 0`);
|
||||
fs.mkdirSync(path.join(process.cwd(), "data", "category-covers"), { recursive: true });
|
||||
|
||||
// Collection cover art: same shape as tag_categories — separate
|
||||
// portrait + landscape slots with pan/zoom transforms. Files live in
|
||||
// data/collection-covers/.
|
||||
const collCols = db.prepare(`PRAGMA table_info(collections)`).all() as Array<{ name: string }>;
|
||||
const hasCollCol = (n: string) => collCols.some((c) => c.name === n);
|
||||
if (!hasCollCol("cover_portrait_path")) db.exec(`ALTER TABLE collections ADD COLUMN cover_portrait_path TEXT`);
|
||||
if (!hasCollCol("cover_portrait_zoom")) db.exec(`ALTER TABLE collections ADD COLUMN cover_portrait_zoom REAL NOT NULL DEFAULT 1`);
|
||||
if (!hasCollCol("cover_portrait_offset_x")) db.exec(`ALTER TABLE collections ADD COLUMN cover_portrait_offset_x REAL NOT NULL DEFAULT 0`);
|
||||
if (!hasCollCol("cover_portrait_offset_y")) db.exec(`ALTER TABLE collections ADD COLUMN cover_portrait_offset_y REAL NOT NULL DEFAULT 0`);
|
||||
if (!hasCollCol("cover_landscape_path")) db.exec(`ALTER TABLE collections ADD COLUMN cover_landscape_path TEXT`);
|
||||
if (!hasCollCol("cover_landscape_zoom")) db.exec(`ALTER TABLE collections ADD COLUMN cover_landscape_zoom REAL NOT NULL DEFAULT 1`);
|
||||
if (!hasCollCol("cover_landscape_offset_x")) db.exec(`ALTER TABLE collections ADD COLUMN cover_landscape_offset_x REAL NOT NULL DEFAULT 0`);
|
||||
if (!hasCollCol("cover_landscape_offset_y")) db.exec(`ALTER TABLE collections ADD COLUMN cover_landscape_offset_y REAL NOT NULL DEFAULT 0`);
|
||||
fs.mkdirSync(path.join(process.cwd(), "data", "collection-covers"), { recursive: true });
|
||||
|
||||
// Auto-purge expired trash. Reads retention from app_settings; bails if 0
|
||||
// (forever) or no rows are old enough yet.
|
||||
const retentionRow = db.prepare(`SELECT value FROM app_settings WHERE key = 'trashRetentionDays'`).get() as { value: string } | undefined;
|
||||
const retentionDays = retentionRow ? Number(retentionRow.value) : 30;
|
||||
if (Number.isFinite(retentionDays) && retentionDays > 0) {
|
||||
const cutoff = Date.now() - retentionDays * 86400_000;
|
||||
const stale = db.prepare(`
|
||||
WITH targets AS (
|
||||
SELECT id FROM images WHERE deleted_at IS NOT NULL AND deleted_at < ?
|
||||
)
|
||||
SELECT id, rel_path, thumb_path FROM images
|
||||
WHERE id IN (SELECT id FROM targets)
|
||||
OR parent_image_id IN (SELECT id FROM targets)
|
||||
`).all(cutoff) as Array<{ id: number; rel_path: string; thumb_path: string }>;
|
||||
if (stale.length > 0) {
|
||||
const purgeRow = db.prepare(`SELECT value FROM app_settings WHERE key = 'purgeFilesOnDelete'`).get() as { value: string } | undefined;
|
||||
const purgeFiles = purgeRow ? purgeRow.value === "1" : true;
|
||||
if (purgeFiles) {
|
||||
const libRoot = path.join(process.cwd(), "library");
|
||||
const thumbRoot = path.join(process.cwd(), "data", "thumbs");
|
||||
for (const r of stale) {
|
||||
const fileAbs = safeJoin(libRoot, r.rel_path);
|
||||
const thumbAbs = safeJoin(thumbRoot, r.thumb_path);
|
||||
try { if (fileAbs) fs.rmSync(fileAbs, { force: true }); } catch {}
|
||||
try { if (thumbAbs) fs.rmSync(thumbAbs, { force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
const placeholders = stale.map(() => "?").join(",");
|
||||
db.prepare(`DELETE FROM images WHERE id IN (${placeholders})`).run(...stale.map((r) => r.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-purge old .superseded/ files. These are recovery snapshots
|
||||
// written by the collision-replace path; once N days have passed
|
||||
// without rolling back, they're safe to drop. 0 = keep forever.
|
||||
const supersededRow = db.prepare(`SELECT value FROM app_settings WHERE key = 'supersededRetentionDays'`).get() as { value: string } | undefined;
|
||||
const supersededDays = supersededRow ? Number(supersededRow.value) : 30;
|
||||
if (Number.isFinite(supersededDays) && supersededDays > 0) {
|
||||
const supersededRoot = path.join(process.cwd(), "library", ".superseded");
|
||||
if (fs.existsSync(supersededRoot)) {
|
||||
const cutoff = Date.now() - supersededDays * 86400_000;
|
||||
try {
|
||||
const entries = fs.readdirSync(supersededRoot, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (!e.isFile()) continue;
|
||||
const abs = path.join(supersededRoot, e.name);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.rmSync(abs, { force: true });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function slugify(s: string): string {
|
||||
return s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "untitled";
|
||||
}
|
||||
|
||||
/** Pick a unique slug for a row in a slug-bearing table (collections, studios, labels, series, actresses, genres). */
|
||||
export function uniqueSlug(database: Database.Database, table: string, name: string, excludeId?: number): string {
|
||||
const base = slugify(name);
|
||||
let slug = base;
|
||||
let i = 1;
|
||||
const stmt = database.prepare(`SELECT 1 FROM ${table} WHERE slug = ? AND id != ? LIMIT 1`);
|
||||
while (stmt.get(slug, excludeId ?? -1)) {
|
||||
i++;
|
||||
slug = `${base}-${i}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export const rawDb = sqlite;
|
||||
+1290
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
import { sqliteTable, integer, text, real, primaryKey, index } from "drizzle-orm/sqlite-core";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* The asset table — each row is one image on disk. For "front cover" rows the
|
||||
* cover-metadata columns are populated; for back covers / extra stills, the
|
||||
* row points at its parent via parent_image_id and its own metadata is null.
|
||||
*/
|
||||
export const images = sqliteTable("images", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
filename: text("filename").notNull(),
|
||||
relPath: text("rel_path").notNull().unique(),
|
||||
thumbPath: text("thumb_path").notNull(),
|
||||
sha256: text("sha256").notNull().unique(),
|
||||
width: integer("width").notNull(),
|
||||
height: integer("height").notNull(),
|
||||
bytes: integer("bytes").notNull(),
|
||||
phash: text("phash"),
|
||||
rawMetadata: text("raw_metadata"),
|
||||
createdAt: integer("created_at").notNull().default(sql`(unixepoch() * 1000)`),
|
||||
importedAt: integer("imported_at").notNull().default(sql`(unixepoch() * 1000)`),
|
||||
deletedAt: integer("deleted_at"),
|
||||
// Non-null = this is an attached image (e.g. back cover / still) of another row.
|
||||
parentImageId: integer("parent_image_id").references((): any => images.id, { onDelete: "cascade" }),
|
||||
// Cover metadata — only meaningful when parentImageId IS NULL.
|
||||
code: text("code"),
|
||||
title: text("title"),
|
||||
releaseDate: text("release_date"), // ISO yyyy-mm-dd
|
||||
runtimeMin: integer("runtime_min"),
|
||||
director: text("director"),
|
||||
studioId: integer("studio_id").references((): any => studios.id, { onDelete: "set null" }),
|
||||
labelId: integer("label_id").references((): any => labels.id, { onDelete: "set null" }),
|
||||
seriesId: integer("series_id").references((): any => series.id, { onDelete: "set null" }),
|
||||
rating: integer("rating"), // 0..5
|
||||
watched: integer("watched", { mode: "boolean" }).notNull().default(false),
|
||||
isVip: integer("is_vip", { mode: "boolean" }).notNull().default(false),
|
||||
isFavorite: integer("is_favorite", { mode: "boolean" }).notNull().default(false),
|
||||
isOwned: integer("is_owned", { mode: "boolean" }).notNull().default(false),
|
||||
hasVideo: integer("has_video", { mode: "boolean" }).notNull().default(false),
|
||||
hasSubtitle: integer("has_subtitle", { mode: "boolean" }).notNull().default(false),
|
||||
/** Source video codec, populated by lazy ffprobe on first playback. */
|
||||
videoCodec: text("video_codec"),
|
||||
/** Source video has_b_frames count (0/1/2+) — used by the auto-predicate
|
||||
* playback mode to decide whether transcoding is needed. */
|
||||
videoBFrames: integer("video_b_frames"),
|
||||
/** Cached playback decision from auto-runtime mode: 'direct' / 'transcode'.
|
||||
* Null means "not yet measured". */
|
||||
playbackMode: text("playback_mode"),
|
||||
notes: text("notes"),
|
||||
}, (t) => ({
|
||||
byCreated: index("images_created_idx").on(t.createdAt),
|
||||
byDeleted: index("images_deleted_idx").on(t.deletedAt),
|
||||
byParent: index("images_parent_idx").on(t.parentImageId),
|
||||
byCode: index("images_code_idx").on(t.code),
|
||||
byStudio: index("images_studio_idx").on(t.studioId),
|
||||
byLabel: index("images_label_idx").on(t.labelId),
|
||||
bySeries: index("images_series_idx").on(t.seriesId),
|
||||
byHasVideo: index("images_has_video_idx").on(t.hasVideo),
|
||||
byHasSubtitle: index("images_has_subtitle_idx").on(t.hasSubtitle),
|
||||
}));
|
||||
|
||||
export const videoMetadata = sqliteTable("video_metadata", {
|
||||
absPath: text("abs_path").primaryKey(),
|
||||
relPath: text("rel_path").notNull(),
|
||||
code: text("code").notNull(),
|
||||
sizeBytes: integer("size_bytes").notNull(),
|
||||
mtimeMs: real("mtime_ms").notNull(),
|
||||
probedAt: integer("probed_at"),
|
||||
probeError: text("probe_error"),
|
||||
durationSec: real("duration_sec"),
|
||||
videoCodec: text("video_codec"),
|
||||
videoBFrames: integer("video_b_frames"),
|
||||
width: integer("width"),
|
||||
height: integer("height"),
|
||||
videoBitrate: integer("video_bitrate"),
|
||||
playbackMode: text("playback_mode"),
|
||||
/** Suffix-pattern classification: "part" (sequential), "variant" (alt
|
||||
* encode of the same content), or "single" (lone file). */
|
||||
partKind: text("part_kind"),
|
||||
/** Sort key for parts: 1, 2, ... — null for variants and singles. */
|
||||
partIndex: integer("part_index"),
|
||||
/** Stem with matched suffix stripped — variants of the same part share
|
||||
* this group key. Null for singles. */
|
||||
variantGroup: text("variant_group"),
|
||||
}, (t) => ({
|
||||
byCode: index("video_metadata_code_idx").on(t.code),
|
||||
}));
|
||||
|
||||
export const studios = sqliteTable("studios", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull().unique(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
notes: text("notes"),
|
||||
});
|
||||
|
||||
export const labels = sqliteTable("labels", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull().unique(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
notes: text("notes"),
|
||||
});
|
||||
|
||||
export const series = sqliteTable("series", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull().unique(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
notes: text("notes"),
|
||||
});
|
||||
|
||||
export const actresses = sqliteTable("actresses", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull().unique(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
altNames: text("alt_names"),
|
||||
notes: text("notes"),
|
||||
portraitPath: text("portrait_path"),
|
||||
portraitZoom: real("portrait_zoom").notNull().default(1),
|
||||
portraitOffsetX: real("portrait_offset_x").notNull().default(0),
|
||||
portraitOffsetY: real("portrait_offset_y").notNull().default(0),
|
||||
});
|
||||
|
||||
export const genres = sqliteTable("genres", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull().unique(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
});
|
||||
|
||||
export const imageActresses = sqliteTable("image_actresses", {
|
||||
imageId: integer("image_id").notNull().references(() => images.id, { onDelete: "cascade" }),
|
||||
actressId: integer("actress_id").notNull().references(() => actresses.id, { onDelete: "cascade" }),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.imageId, t.actressId] }),
|
||||
byActress: index("image_actresses_actress_idx").on(t.actressId),
|
||||
}));
|
||||
|
||||
export const imageGenres = sqliteTable("image_genres", {
|
||||
imageId: integer("image_id").notNull().references(() => images.id, { onDelete: "cascade" }),
|
||||
genreId: integer("genre_id").notNull().references(() => genres.id, { onDelete: "cascade" }),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.imageId, t.genreId] }),
|
||||
byGenre: index("image_genres_genre_idx").on(t.genreId),
|
||||
}));
|
||||
|
||||
export const tagCategories = sqliteTable("tag_categories", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull().unique(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
color: text("color"),
|
||||
description: text("description"),
|
||||
coverPortraitPath: text("cover_portrait_path"),
|
||||
coverPortraitZoom: real("cover_portrait_zoom").notNull().default(1),
|
||||
coverPortraitOffsetX: real("cover_portrait_offset_x").notNull().default(0),
|
||||
coverPortraitOffsetY: real("cover_portrait_offset_y").notNull().default(0),
|
||||
coverLandscapePath: text("cover_landscape_path"),
|
||||
coverLandscapeZoom: real("cover_landscape_zoom").notNull().default(1),
|
||||
coverLandscapeOffsetX: real("cover_landscape_offset_x").notNull().default(0),
|
||||
coverLandscapeOffsetY: real("cover_landscape_offset_y").notNull().default(0),
|
||||
});
|
||||
|
||||
export const tags = sqliteTable("tags", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull().unique(),
|
||||
color: text("color"),
|
||||
categoryId: integer("category_id").references((): any => tagCategories.id, { onDelete: "set null" }),
|
||||
lastUsedAt: integer("last_used_at").notNull().default(0),
|
||||
}, (t) => ({
|
||||
byCategory: index("tags_category_idx").on(t.categoryId),
|
||||
}));
|
||||
|
||||
export const imageTags = sqliteTable("image_tags", {
|
||||
imageId: integer("image_id").notNull().references(() => images.id, { onDelete: "cascade" }),
|
||||
tagId: integer("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" }),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.imageId, t.tagId] }),
|
||||
byTag: index("image_tags_tag_idx").on(t.tagId),
|
||||
}));
|
||||
|
||||
export const collections = sqliteTable("collections", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
description: text("description"),
|
||||
coverImageId: integer("cover_image_id").references(() => images.id, { onDelete: "set null" }),
|
||||
createdAt: integer("created_at").notNull().default(sql`(unixepoch() * 1000)`),
|
||||
position: integer("position").notNull().default(0),
|
||||
lastUsedAt: integer("last_used_at").notNull().default(0),
|
||||
coverPortraitPath: text("cover_portrait_path"),
|
||||
coverPortraitZoom: real("cover_portrait_zoom").notNull().default(1),
|
||||
coverPortraitOffsetX: real("cover_portrait_offset_x").notNull().default(0),
|
||||
coverPortraitOffsetY: real("cover_portrait_offset_y").notNull().default(0),
|
||||
coverLandscapePath: text("cover_landscape_path"),
|
||||
coverLandscapeZoom: real("cover_landscape_zoom").notNull().default(1),
|
||||
coverLandscapeOffsetX: real("cover_landscape_offset_x").notNull().default(0),
|
||||
coverLandscapeOffsetY: real("cover_landscape_offset_y").notNull().default(0),
|
||||
});
|
||||
|
||||
export const collectionImages = sqliteTable("collection_images", {
|
||||
collectionId: integer("collection_id").notNull().references(() => collections.id, { onDelete: "cascade" }),
|
||||
imageId: integer("image_id").notNull().references(() => images.id, { onDelete: "cascade" }),
|
||||
position: integer("position").notNull().default(0),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.collectionId, t.imageId] }),
|
||||
}));
|
||||
|
||||
export const imagesRelations = relations(images, ({ one, many }) => ({
|
||||
parent: one(images, { fields: [images.parentImageId], references: [images.id], relationName: "parent" }),
|
||||
studio: one(studios, { fields: [images.studioId], references: [studios.id] }),
|
||||
label: one(labels, { fields: [images.labelId], references: [labels.id] }),
|
||||
series: one(series, { fields: [images.seriesId], references: [series.id] }),
|
||||
imageActresses: many(imageActresses),
|
||||
imageGenres: many(imageGenres),
|
||||
imageTags: many(imageTags),
|
||||
collectionImages: many(collectionImages),
|
||||
}));
|
||||
|
||||
export const studiosRelations = relations(studios, ({ many }) => ({ images: many(images) }));
|
||||
export const labelsRelations = relations(labels, ({ many }) => ({ images: many(images) }));
|
||||
export const seriesRelations = relations(series, ({ many }) => ({ images: many(images) }));
|
||||
export const actressesRelations = relations(actresses, ({ many }) => ({ imageActresses: many(imageActresses) }));
|
||||
export const genresRelations = relations(genres, ({ many }) => ({ imageGenres: many(imageGenres) }));
|
||||
export const tagsRelations = relations(tags, ({ many }) => ({ imageTags: many(imageTags) }));
|
||||
|
||||
export const imageActressesRelations = relations(imageActresses, ({ one }) => ({
|
||||
image: one(images, { fields: [imageActresses.imageId], references: [images.id] }),
|
||||
actress: one(actresses, { fields: [imageActresses.actressId], references: [actresses.id] }),
|
||||
}));
|
||||
export const imageGenresRelations = relations(imageGenres, ({ one }) => ({
|
||||
image: one(images, { fields: [imageGenres.imageId], references: [images.id] }),
|
||||
genre: one(genres, { fields: [imageGenres.genreId], references: [genres.id] }),
|
||||
}));
|
||||
export const imageTagsRelations = relations(imageTags, ({ one }) => ({
|
||||
image: one(images, { fields: [imageTags.imageId], references: [images.id] }),
|
||||
tag: one(tags, { fields: [imageTags.tagId], references: [tags.id] }),
|
||||
}));
|
||||
export const collectionsRelations = relations(collections, ({ many, one }) => ({
|
||||
collectionImages: many(collectionImages),
|
||||
cover: one(images, { fields: [collections.coverImageId], references: [images.id] }),
|
||||
}));
|
||||
export const collectionImagesRelations = relations(collectionImages, ({ one }) => ({
|
||||
collection: one(collections, { fields: [collectionImages.collectionId], references: [collections.id] }),
|
||||
image: one(images, { fields: [collectionImages.imageId], references: [images.id] }),
|
||||
}));
|
||||
|
||||
export type Image = typeof images.$inferSelect;
|
||||
export type Studio = typeof studios.$inferSelect;
|
||||
export type Label = typeof labels.$inferSelect;
|
||||
export type Series = typeof series.$inferSelect;
|
||||
export type Actress = typeof actresses.$inferSelect;
|
||||
export type Genre = typeof genres.$inferSelect;
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type Collection = typeof collections.$inferSelect;
|
||||
export type VideoMetadata = typeof videoMetadata.$inferSelect;
|
||||
Reference in New Issue
Block a user