Initial commit
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { rawDb, uniqueSlug } from "@/lib/db/client";
|
||||
|
||||
const EXCLUSIVE_GROUPS: string[][] = [["favorite", "vip"]];
|
||||
|
||||
function getExclusivePeers(categoryId: number): number[] {
|
||||
const cat = rawDb.prepare(`SELECT slug FROM actress_categories WHERE id = ?`).get(categoryId) as { slug: string } | undefined;
|
||||
if (!cat) return [];
|
||||
const group = EXCLUSIVE_GROUPS.find((g) => g.includes(cat.slug));
|
||||
if (!group) return [];
|
||||
const peers = group.filter((s) => s !== cat.slug);
|
||||
if (peers.length === 0) return [];
|
||||
const placeholders = peers.map(() => "?").join(",");
|
||||
const rows = rawDb.prepare(`SELECT id FROM actress_categories WHERE slug IN (${placeholders})`).all(...peers) as Array<{ id: number }>;
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
export async function toggleActressCategory(actressId: number, categoryId: number) {
|
||||
const exists = rawDb.prepare(`
|
||||
SELECT 1 FROM actress_categories_map WHERE actress_id = ? AND category_id = ?
|
||||
`).get(actressId, categoryId);
|
||||
if (exists) {
|
||||
rawDb.prepare(`DELETE FROM actress_categories_map WHERE actress_id = ? AND category_id = ?`).run(actressId, categoryId);
|
||||
} else {
|
||||
// Adding: also remove any peer category in an exclusive group.
|
||||
const peers = getExclusivePeers(categoryId);
|
||||
const tx = rawDb.transaction(() => {
|
||||
if (peers.length > 0) {
|
||||
const placeholders = peers.map(() => "?").join(",");
|
||||
rawDb.prepare(`
|
||||
DELETE FROM actress_categories_map
|
||||
WHERE actress_id = ? AND category_id IN (${placeholders})
|
||||
`).run(actressId, ...peers);
|
||||
}
|
||||
rawDb.prepare(`INSERT INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`).run(actressId, categoryId);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
const a = rawDb.prepare(`SELECT slug FROM actresses WHERE id = ?`).get(actressId) as { slug: string } | undefined;
|
||||
revalidatePath("/actress");
|
||||
if (a) revalidatePath(`/actress/${a.slug}`);
|
||||
return { added: !exists };
|
||||
}
|
||||
|
||||
export async function setActressCategories(actressId: number, categoryIds: number[]) {
|
||||
const tx = rawDb.transaction(() => {
|
||||
rawDb.prepare(`DELETE FROM actress_categories_map WHERE actress_id = ?`).run(actressId);
|
||||
const ins = rawDb.prepare(`INSERT INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`);
|
||||
for (const id of categoryIds) ins.run(actressId, id);
|
||||
});
|
||||
tx();
|
||||
const a = rawDb.prepare(`SELECT slug FROM actresses WHERE id = ?`).get(actressId) as { slug: string } | undefined;
|
||||
revalidatePath("/actress");
|
||||
if (a) revalidatePath(`/actress/${a.slug}`);
|
||||
}
|
||||
|
||||
export async function createActressCategory(input: { name: string; color?: string | null; icon?: string | null; priority?: number }) {
|
||||
const trimmed = input.name.trim();
|
||||
if (!trimmed) return null;
|
||||
const existing = rawDb.prepare(`SELECT id, slug FROM actress_categories WHERE name = ?`).get(trimmed) as { id: number; slug: string } | undefined;
|
||||
if (existing) return existing;
|
||||
const slug = uniqueSlug(rawDb, "actress_categories", trimmed);
|
||||
const row = rawDb.prepare(`
|
||||
INSERT INTO actress_categories (name, slug, color, icon, priority, builtin)
|
||||
VALUES (?, ?, ?, ?, ?, 0) RETURNING id
|
||||
`).get(trimmed, slug, input.color ?? null, input.icon ?? null, input.priority ?? 50) as { id: number };
|
||||
revalidatePath("/actress");
|
||||
return { id: row.id, slug };
|
||||
}
|
||||
|
||||
export async function bulkAddCategory(actressIds: number[], categoryId: number) {
|
||||
if (actressIds.length === 0) return;
|
||||
const peers = getExclusivePeers(categoryId);
|
||||
const ins = rawDb.prepare(`INSERT OR IGNORE INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`);
|
||||
const tx = rawDb.transaction(() => {
|
||||
if (peers.length > 0) {
|
||||
const peerPh = peers.map(() => "?").join(",");
|
||||
const idPh = actressIds.map(() => "?").join(",");
|
||||
rawDb.prepare(`
|
||||
DELETE FROM actress_categories_map
|
||||
WHERE actress_id IN (${idPh}) AND category_id IN (${peerPh})
|
||||
`).run(...actressIds, ...peers);
|
||||
}
|
||||
for (const id of actressIds) ins.run(id, categoryId);
|
||||
});
|
||||
tx();
|
||||
revalidatePath("/actress");
|
||||
}
|
||||
|
||||
export async function bulkRemoveCategory(actressIds: number[], categoryId: number) {
|
||||
if (actressIds.length === 0) return;
|
||||
const placeholders = actressIds.map(() => "?").join(",");
|
||||
rawDb.prepare(`
|
||||
DELETE FROM actress_categories_map
|
||||
WHERE category_id = ? AND actress_id IN (${placeholders})
|
||||
`).run(categoryId, ...actressIds);
|
||||
revalidatePath("/actress");
|
||||
}
|
||||
|
||||
export async function deleteActressCategory(categoryId: number) {
|
||||
const row = rawDb.prepare(`SELECT builtin FROM actress_categories WHERE id = ?`).get(categoryId) as { builtin: number } | undefined;
|
||||
if (!row) return { ok: false, reason: "not found" };
|
||||
if (row.builtin) return { ok: false, reason: "built-in category cannot be deleted" };
|
||||
rawDb.prepare(`DELETE FROM actress_categories WHERE id = ?`).run(categoryId);
|
||||
revalidatePath("/actress");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
import { listActressCategories } from "@/lib/db/queries";
|
||||
import type { ActressCategory } from "@/lib/db/queries";
|
||||
|
||||
export async function listActressCategoriesAction(): Promise<ActressCategory[]> {
|
||||
return listActressCategories();
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { rawDb, uniqueSlug } from "@/lib/db/client";
|
||||
|
||||
const PALETTE = ["#fbbf24", "#22d3ee", "#a78bfa", "#f472b6", "#34d399", "#fb7185", "#f97316", "#60a5fa"];
|
||||
|
||||
export interface ImportPreviewLine {
|
||||
raw: string;
|
||||
name: string;
|
||||
altNames: string | null;
|
||||
categories: string[];
|
||||
status: "new" | "exists" | "blank" | "error";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
lines: ImportPreviewLine[];
|
||||
added: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
newCategories: string[];
|
||||
}
|
||||
|
||||
function parseLines(text: string): Array<{ raw: string; name: string; altNames: string | null; categories: string[] }> {
|
||||
const out: Array<{ raw: string; name: string; altNames: string | null; categories: string[] }> = [];
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
out.push({ raw, name: "", altNames: null, categories: [] });
|
||||
continue;
|
||||
}
|
||||
const parts = trimmed.split("|").map((s) => s.trim());
|
||||
const [name, alt, cats] = [parts[0] ?? "", parts[1] ?? "", parts[2] ?? ""];
|
||||
const categories = cats ? cats.split(/[,、,]/).map((s) => s.trim()).filter(Boolean) : [];
|
||||
out.push({ raw: trimmed, name, altNames: alt || null, categories });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry run — parse + classify lines without writing. Drives the preview UI.
|
||||
*/
|
||||
export async function previewActressImport(text: string): Promise<ImportResult> {
|
||||
const parsed = parseLines(text);
|
||||
const lines: ImportPreviewLine[] = [];
|
||||
const existingCats = new Set(
|
||||
(rawDb.prepare(`SELECT name FROM actress_categories`).all() as Array<{ name: string }>)
|
||||
.map((r) => r.name.toLowerCase()),
|
||||
);
|
||||
// Dedupe new-category collection by lowercased key to match the
|
||||
// commit path, which keys its catCache by toLowerCase(). Otherwise
|
||||
// "Action" and "ACTION" in the input would be reported as 2 new
|
||||
// categories in the preview but commit would create only 1.
|
||||
const newCategoriesByKey = new Map<string, string>();
|
||||
// Track within-input new actresses too: a name appearing twice should
|
||||
// be counted once in the preview, matching the commit (which inserts
|
||||
// the first and rejects/sees the second as existing).
|
||||
const seenNewNames = new Set<string>();
|
||||
let added = 0, skipped = 0, errors = 0;
|
||||
|
||||
for (const p of parsed) {
|
||||
if (!p.name) {
|
||||
lines.push({ ...p, status: "blank" });
|
||||
continue;
|
||||
}
|
||||
const existing = rawDb.prepare(`SELECT id FROM actresses WHERE name = ? COLLATE NOCASE`).get(p.name) as { id: number } | undefined;
|
||||
const nameKey = p.name.toLowerCase();
|
||||
if (existing || seenNewNames.has(nameKey)) {
|
||||
lines.push({ ...p, status: "exists" });
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
for (const c of p.categories) {
|
||||
const key = c.toLowerCase();
|
||||
if (!existingCats.has(key) && !newCategoriesByKey.has(key)) {
|
||||
newCategoriesByKey.set(key, c);
|
||||
}
|
||||
}
|
||||
seenNewNames.add(nameKey);
|
||||
lines.push({ ...p, status: "new" });
|
||||
added++;
|
||||
}
|
||||
|
||||
return { lines, added, skipped, errors, newCategories: Array.from(newCategoriesByKey.values()) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit — actually write. Auto-creates unknown categories with palette colors.
|
||||
*/
|
||||
export async function commitActressImport(text: string, defaultCategoryIds: number[] = []): Promise<ImportResult> {
|
||||
const parsed = parseLines(text);
|
||||
const lines: ImportPreviewLine[] = [];
|
||||
let added = 0, skipped = 0, errors = 0;
|
||||
const newCategoriesSet = new Set<string>();
|
||||
|
||||
// Cache existing categories by lowercased name → id.
|
||||
const catCache = new Map<string, number>();
|
||||
for (const row of rawDb.prepare(`SELECT id, name FROM actress_categories`).all() as Array<{ id: number; name: string }>) {
|
||||
catCache.set(row.name.toLowerCase(), row.id);
|
||||
}
|
||||
// Track NEW categories with their own counter so palette colors are
|
||||
// assigned in import order — using catCache.size meant the first new
|
||||
// category's color depended on the existing-category count and often
|
||||
// collided with an existing category's chosen color.
|
||||
let newCategoryCount = 0;
|
||||
|
||||
// Helper to get or create a category.
|
||||
function getOrCreateCategory(name: string): number {
|
||||
const key = name.toLowerCase();
|
||||
const hit = catCache.get(key);
|
||||
if (hit != null) return hit;
|
||||
const slug = uniqueSlug(rawDb, "actress_categories", name);
|
||||
const color = PALETTE[newCategoryCount++ % PALETTE.length];
|
||||
const row = rawDb.prepare(`
|
||||
INSERT INTO actress_categories (name, slug, color, icon, priority, builtin)
|
||||
VALUES (?, ?, ?, 'tag', 50, 0) RETURNING id
|
||||
`).get(name, slug, color) as { id: number };
|
||||
catCache.set(key, row.id);
|
||||
newCategoriesSet.add(name);
|
||||
return row.id;
|
||||
}
|
||||
|
||||
const tx = rawDb.transaction(() => {
|
||||
for (const p of parsed) {
|
||||
if (!p.name) {
|
||||
lines.push({ ...p, status: "blank" });
|
||||
continue;
|
||||
}
|
||||
const existing = rawDb.prepare(`SELECT id FROM actresses WHERE name = ? COLLATE NOCASE`).get(p.name) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
lines.push({ ...p, status: "exists" });
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const slug = uniqueSlug(rawDb, "actresses", p.name);
|
||||
const row = rawDb.prepare(`
|
||||
INSERT INTO actresses (name, slug, alt_names) VALUES (?, ?, ?) RETURNING id
|
||||
`).get(p.name, slug, p.altNames) as { id: number };
|
||||
|
||||
const assignedCatIds = new Set<number>();
|
||||
for (const cat of p.categories) {
|
||||
const catId = getOrCreateCategory(cat);
|
||||
assignedCatIds.add(catId);
|
||||
}
|
||||
for (const id of defaultCategoryIds) assignedCatIds.add(id);
|
||||
for (const id of assignedCatIds) {
|
||||
rawDb.prepare(`INSERT OR IGNORE INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`).run(row.id, id);
|
||||
}
|
||||
lines.push({ ...p, status: "new" });
|
||||
added++;
|
||||
} catch (e) {
|
||||
lines.push({ ...p, status: "error", reason: (e as Error).message });
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
revalidatePath("/actress");
|
||||
return { lines, added, skipped, errors, newCategories: Array.from(newCategoriesSet) };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use server";
|
||||
import { rawDb } from "@/lib/db/client";
|
||||
import { reverseName } from "@/lib/jav/nameUtils";
|
||||
|
||||
export interface ActressLookupResult {
|
||||
name: string;
|
||||
match: { id: number; name: string; slug: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each input name, find an existing actress matching by:
|
||||
* - canonical name (exact, case-insensitive)
|
||||
* - any entry in alt_names (comma-separated)
|
||||
* - the reversed-word-order form (e.g. "Atomi Shuri" matches "Shuri Atomi")
|
||||
* Returns one row per input preserving order.
|
||||
*/
|
||||
export async function lookupActressesByNames(names: string[]): Promise<ActressLookupResult[]> {
|
||||
const trimmed = names.map((n) => n.trim()).filter(Boolean);
|
||||
if (trimmed.length === 0) return [];
|
||||
const rows = rawDb.prepare(`SELECT id, name, slug, alt_names AS altNames FROM actresses`).all() as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
altNames: string | null;
|
||||
}>;
|
||||
|
||||
type Indexed = { id: number; name: string; slug: string; keys: Set<string> };
|
||||
const indexed: Indexed[] = rows.map((r) => {
|
||||
const keys = new Set<string>();
|
||||
keys.add(r.name.toLowerCase());
|
||||
const rev = reverseName(r.name);
|
||||
if (rev) keys.add(rev.toLowerCase());
|
||||
if (r.altNames) {
|
||||
for (const part of r.altNames.split(/[,、,]/)) {
|
||||
const t = part.trim().toLowerCase();
|
||||
if (t) keys.add(t);
|
||||
}
|
||||
}
|
||||
return { id: r.id, name: r.name, slug: r.slug, keys };
|
||||
});
|
||||
|
||||
const findMatch = (q: string) => {
|
||||
const lq = q.toLowerCase();
|
||||
const lqRev = reverseName(q)?.toLowerCase() ?? null;
|
||||
for (const r of indexed) {
|
||||
if (r.keys.has(lq) || (lqRev && r.keys.has(lqRev))) {
|
||||
return { id: r.id, name: r.name, slug: r.slug };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return trimmed.map((name) => ({ name, match: findMatch(name) }));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { rawDb, uniqueSlug } from "@/lib/db/client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function updateActressMeta(
|
||||
actressId: number,
|
||||
data: {
|
||||
name?: string;
|
||||
altNames?: string | null;
|
||||
notes?: string | null;
|
||||
bornOn?: string | null;
|
||||
heightCm?: number | null;
|
||||
weightKg?: number | null;
|
||||
cupSize?: string | null;
|
||||
},
|
||||
): Promise<{ slug: string } | null> {
|
||||
const row = rawDb.prepare(`SELECT name, slug FROM actresses WHERE id = ?`).get(actressId) as
|
||||
| { name: string; slug: string }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
|
||||
let newSlug = row.slug;
|
||||
let newName = row.name;
|
||||
if (data.name != null) {
|
||||
const trimmed = data.name.trim();
|
||||
if (trimmed && trimmed !== row.name) {
|
||||
newName = trimmed;
|
||||
newSlug = uniqueSlug(rawDb, "actresses", trimmed, actressId);
|
||||
}
|
||||
}
|
||||
const altNames = data.altNames == null ? null : data.altNames.trim() || null;
|
||||
const notes = data.notes == null ? null : data.notes.trim() || null;
|
||||
const bornOn = data.bornOn == null ? null : (data.bornOn.trim() || null);
|
||||
const heightCm = data.heightCm == null || !Number.isFinite(data.heightCm) ? null : Math.round(data.heightCm);
|
||||
const weightKg = data.weightKg == null || !Number.isFinite(data.weightKg) ? null : Math.round(data.weightKg);
|
||||
const cupSize = data.cupSize == null ? null : (data.cupSize.trim() || null);
|
||||
|
||||
rawDb.prepare(`
|
||||
UPDATE actresses
|
||||
SET name = ?, slug = ?, alt_names = ?, notes = ?,
|
||||
born_on = ?, height_cm = ?, weight_kg = ?, cup_size = ?
|
||||
WHERE id = ?
|
||||
`).run(newName, newSlug, altNames, notes, bornOn, heightCm, weightKg, cupSize, actressId);
|
||||
|
||||
revalidatePath("/actress");
|
||||
revalidatePath(`/actress/${row.slug}`);
|
||||
if (newSlug !== row.slug) revalidatePath(`/actress/${newSlug}`);
|
||||
return { slug: newSlug };
|
||||
}
|
||||
|
||||
export async function updateActressMetaAction(formData: FormData) {
|
||||
const id = Number(formData.get("id"));
|
||||
if (!Number.isFinite(id)) return;
|
||||
const result = await updateActressMeta(id, {
|
||||
name: String(formData.get("name") ?? ""),
|
||||
altNames: String(formData.get("altNames") ?? ""),
|
||||
notes: String(formData.get("notes") ?? ""),
|
||||
});
|
||||
if (result) redirect(`/actress/${result.slug}`);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { rawDb } from "@/lib/db/client";
|
||||
import type { PortraitSlotKey } from "@/lib/db/queries";
|
||||
import { safeJoin } from "@/lib/safePath";
|
||||
|
||||
const PORTRAIT_ROOT = path.join(process.cwd(), "data", "portraits");
|
||||
|
||||
const SLOT_COLS: Record<PortraitSlotKey, { path: string; zoom: string; ox: string; oy: string }> = {
|
||||
"1": { path: "portrait_path", zoom: "portrait_zoom", ox: "portrait_offset_x", oy: "portrait_offset_y" },
|
||||
"2": { path: "portrait2_path", zoom: "portrait2_zoom", ox: "portrait2_offset_x", oy: "portrait2_offset_y" },
|
||||
"3": { path: "portrait3_path", zoom: "portrait3_zoom", ox: "portrait3_offset_x", oy: "portrait3_offset_y" },
|
||||
"4": { path: "portrait4_path", zoom: "portrait4_zoom", ox: "portrait4_offset_x", oy: "portrait4_offset_y" },
|
||||
"h": { path: "portraith_path", zoom: "portraith_zoom", ox: "portraith_offset_x", oy: "portraith_offset_y" },
|
||||
};
|
||||
|
||||
export async function setActressPortraitTransform(
|
||||
actressId: number,
|
||||
slot: PortraitSlotKey,
|
||||
transform: { zoom: number; offsetX: number; offsetY: number },
|
||||
) {
|
||||
const c = SLOT_COLS[slot];
|
||||
if (!c) return;
|
||||
const zoom = Math.max(0.5, Math.min(5, transform.zoom));
|
||||
rawDb.prepare(`
|
||||
UPDATE actresses SET ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ? WHERE id = ?
|
||||
`).run(zoom, transform.offsetX, transform.offsetY, actressId);
|
||||
const a = rawDb.prepare(`SELECT slug FROM actresses WHERE id = ?`).get(actressId) as { slug: string } | undefined;
|
||||
revalidatePath("/actress");
|
||||
if (a) revalidatePath(`/actress/${a.slug}`);
|
||||
}
|
||||
|
||||
type PortraitTuple = [string | null, number, number, number];
|
||||
|
||||
export async function reorderActressPortraitSlots(
|
||||
actressId: number,
|
||||
src: PortraitSlotKey,
|
||||
dest: PortraitSlotKey,
|
||||
) {
|
||||
if (src === dest) return;
|
||||
if (src === "h" || dest === "h") return;
|
||||
const order: PortraitSlotKey[] = ["1", "2", "3", "4"];
|
||||
if (!order.includes(src) || !order.includes(dest)) return;
|
||||
|
||||
const cols = order.map((s) => SLOT_COLS[s]);
|
||||
const selectFrag = cols
|
||||
.map((c, i) => `${c.path} AS p${i}, ${c.zoom} AS z${i}, ${c.ox} AS x${i}, ${c.oy} AS y${i}`)
|
||||
.join(", ");
|
||||
const row = rawDb.prepare(`SELECT slug, ${selectFrag} FROM actresses WHERE id = ?`).get(actressId) as
|
||||
| (Record<string, string | number | null> & { slug: string })
|
||||
| undefined;
|
||||
if (!row) return;
|
||||
|
||||
const current: PortraitTuple[] = order.map((_, i) => [
|
||||
(row[`p${i}`] as string | null) ?? null,
|
||||
Number(row[`z${i}`] ?? 1),
|
||||
Number(row[`x${i}`] ?? 0),
|
||||
Number(row[`y${i}`] ?? 0),
|
||||
]);
|
||||
|
||||
const srcIdx = order.indexOf(src);
|
||||
const destIdx = order.indexOf(dest);
|
||||
const next = [...current];
|
||||
const [moved] = next.splice(srcIdx, 1);
|
||||
next.splice(destIdx, 0, moved);
|
||||
|
||||
const setFrag = cols.map((c) => `${c.path} = ?, ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ?`).join(", ");
|
||||
const params = next.flatMap((t) => [t[0], t[1], t[2], t[3]]);
|
||||
rawDb.prepare(`UPDATE actresses SET ${setFrag} WHERE id = ?`).run(...params, actressId);
|
||||
|
||||
revalidatePath("/actress");
|
||||
revalidatePath(`/actress/${row.slug}`);
|
||||
}
|
||||
|
||||
export async function clearActressPortrait(actressId: number, slot: PortraitSlotKey) {
|
||||
const c = SLOT_COLS[slot];
|
||||
if (!c) return;
|
||||
const row = rawDb.prepare(`SELECT slug, ${c.path} AS p FROM actresses WHERE id = ?`).get(actressId) as
|
||||
| { slug: string; p: string | null }
|
||||
| undefined;
|
||||
if (!row) return;
|
||||
if (row.p) {
|
||||
const abs = safeJoin(PORTRAIT_ROOT, row.p);
|
||||
if (abs) await fs.rm(abs, { force: true }).catch(() => {});
|
||||
}
|
||||
rawDb.prepare(`
|
||||
UPDATE actresses SET ${c.path} = NULL, ${c.zoom} = 1, ${c.ox} = 0, ${c.oy} = 0 WHERE id = ?
|
||||
`).run(actressId);
|
||||
revalidatePath("/actress");
|
||||
revalidatePath(`/actress/${row.slug}`);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { rawDb } from "@/lib/db/client";
|
||||
import { safeJoin } from "@/lib/safePath";
|
||||
|
||||
const LIBRARY_ROOT = path.join(process.cwd(), "library");
|
||||
const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs");
|
||||
|
||||
export async function deleteAttachedImage(attachedId: number) {
|
||||
const row = rawDb.prepare(`
|
||||
SELECT parent_image_id AS parentId, rel_path AS relPath, thumb_path AS thumbPath
|
||||
FROM images WHERE id = ?
|
||||
`).get(attachedId) as { parentId: number | null; relPath: string; thumbPath: string } | undefined;
|
||||
if (!row || row.parentId == null) return;
|
||||
|
||||
rawDb.prepare(`DELETE FROM images WHERE id = ?`).run(attachedId);
|
||||
const fileAbs = safeJoin(LIBRARY_ROOT, row.relPath);
|
||||
const thumbAbs = safeJoin(THUMB_ROOT, row.thumbPath);
|
||||
if (fileAbs) await fs.rm(fileAbs, { force: true }).catch(() => {});
|
||||
if (thumbAbs) await fs.rm(thumbAbs, { force: true }).catch(() => {});
|
||||
|
||||
const parentCode = rawDb.prepare(`SELECT code FROM images WHERE id = ?`).get(row.parentId) as { code: string | null } | undefined;
|
||||
revalidatePath(`/image/${row.parentId}`);
|
||||
if (parentCode?.code) revalidatePath(`/id/${parentCode.code}`);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { rawDb } from "@/lib/db/client";
|
||||
import { getAppSetting } from "@/lib/db/appSettings";
|
||||
import { safeJoin } from "@/lib/safePath";
|
||||
|
||||
const LIBRARY_ROOT = path.join(process.cwd(), "library");
|
||||
const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs");
|
||||
|
||||
interface DeleteResult {
|
||||
trashed: number;
|
||||
purged: number;
|
||||
}
|
||||
|
||||
export async function bulkSetWatched(ids: number[], watched: boolean): Promise<{ updated: number }> {
|
||||
if (ids.length === 0) return { updated: 0 };
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const result = rawDb
|
||||
.prepare(`UPDATE images SET watched = ? WHERE id IN (${placeholders})`)
|
||||
.run(watched ? 1 : 0, ...ids);
|
||||
revalidatePath("/");
|
||||
for (const id of ids) revalidatePath(`/image/${id}`);
|
||||
return { updated: Number(result.changes) };
|
||||
}
|
||||
|
||||
export async function bulkSetOwned(ids: number[], owned: boolean): Promise<{ updated: number }> {
|
||||
if (ids.length === 0) return { updated: 0 };
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const result = rawDb
|
||||
.prepare(`UPDATE images SET is_owned = ? WHERE id IN (${placeholders})`)
|
||||
.run(owned ? 1 : 0, ...ids);
|
||||
revalidatePath("/");
|
||||
for (const id of ids) revalidatePath(`/image/${id}`);
|
||||
return { updated: Number(result.changes) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk mark covers as VIP / Favorite / Unmarked. VIP and Favorite are mutually
|
||||
* exclusive, so setting one clears the other; "unmark" clears both.
|
||||
*/
|
||||
export async function bulkSetMark(ids: number[], mark: "vip" | "favorite" | "unmarked"): Promise<{ updated: number }> {
|
||||
if (ids.length === 0) return { updated: 0 };
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const sql =
|
||||
mark === "vip" ? `UPDATE images SET is_vip = 1, is_favorite = 0 WHERE id IN (${placeholders})`
|
||||
: mark === "favorite" ? `UPDATE images SET is_favorite = 1, is_vip = 0 WHERE id IN (${placeholders})`
|
||||
: `UPDATE images SET is_vip = 0, is_favorite = 0 WHERE id IN (${placeholders})`;
|
||||
const result = rawDb.prepare(sql).run(...ids);
|
||||
revalidatePath("/");
|
||||
for (const id of ids) revalidatePath(`/image/${id}`);
|
||||
return { updated: Number(result.changes) };
|
||||
}
|
||||
|
||||
export async function deleteImages(
|
||||
ids: number[],
|
||||
options?: { permanent?: boolean },
|
||||
): Promise<DeleteResult> {
|
||||
if (ids.length === 0) return { trashed: 0, purged: 0 };
|
||||
|
||||
const useBin = getAppSetting("useRecycleBin");
|
||||
const goPermanent = options?.permanent ?? !useBin;
|
||||
|
||||
if (!goPermanent) {
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const now = Date.now();
|
||||
const info = rawDb.prepare(
|
||||
`UPDATE images SET deleted_at = ? WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
|
||||
).run(now, ...ids);
|
||||
revalidate();
|
||||
return { trashed: Number(info.changes), purged: 0 };
|
||||
}
|
||||
|
||||
const purged = await hardDelete(ids);
|
||||
revalidate();
|
||||
// Report parents purged, not parents+children — `purged` already
|
||||
// collapses to the top-level id set the caller asked us to delete.
|
||||
return { trashed: 0, purged };
|
||||
}
|
||||
|
||||
/** Convenience for routes that delete a single image. */
|
||||
export async function deleteImage(id: number, options?: { permanent?: boolean }): Promise<DeleteResult> {
|
||||
return deleteImages([id], options);
|
||||
}
|
||||
|
||||
async function hardDelete(ids: number[]): Promise<number> {
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const rows = rawDb.prepare(
|
||||
`
|
||||
SELECT id, rel_path, thumb_path FROM images
|
||||
WHERE id IN (${placeholders}) OR parent_image_id IN (${placeholders})
|
||||
`,
|
||||
).all(...ids, ...ids) as Array<{ id: number; rel_path: string; thumb_path: string }>;
|
||||
|
||||
// DB delete first: if the process crashes mid-purge, files on disk become
|
||||
// orphans (cleanable via the maintenance scanner) rather than DB rows
|
||||
// pointing at vanished files. Children cascade via parent_image_id FK.
|
||||
const info = rawDb.prepare(`DELETE FROM images WHERE id IN (${placeholders})`).run(...ids);
|
||||
|
||||
if (getAppSetting("purgeFilesOnDelete")) {
|
||||
await Promise.all(rows.flatMap((r) => {
|
||||
const fileAbs = safeJoin(LIBRARY_ROOT, r.rel_path);
|
||||
const thumbAbs = safeJoin(THUMB_ROOT, r.thumb_path);
|
||||
const tasks: Array<Promise<unknown>> = [];
|
||||
if (fileAbs) tasks.push(fs.rm(fileAbs, { force: true }));
|
||||
if (thumbAbs) tasks.push(fs.rm(thumbAbs, { force: true }));
|
||||
return tasks;
|
||||
}));
|
||||
}
|
||||
return Number(info.changes);
|
||||
}
|
||||
|
||||
function revalidate() {
|
||||
revalidatePath("/");
|
||||
revalidatePath("/collection");
|
||||
revalidatePath("/tag");
|
||||
revalidatePath("/actress");
|
||||
revalidatePath("/studios");
|
||||
revalidatePath("/series");
|
||||
revalidatePath("/genres");
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { rawDb } from "@/lib/db/client";
|
||||
import { safeJoin } from "@/lib/safePath";
|
||||
|
||||
export type CategoryCoverSlot = "portrait" | "landscape";
|
||||
|
||||
const COVER_ROOT = path.join(process.cwd(), "data", "category-covers");
|
||||
|
||||
const SLOT_COLS: Record<CategoryCoverSlot, { path: string; zoom: string; ox: string; oy: string }> = {
|
||||
portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" },
|
||||
landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" },
|
||||
};
|
||||
|
||||
export async function setCategoryCoverTransform(
|
||||
categoryId: number,
|
||||
slot: CategoryCoverSlot,
|
||||
transform: { zoom: number; offsetX: number; offsetY: number },
|
||||
) {
|
||||
const c = SLOT_COLS[slot];
|
||||
if (!c) return;
|
||||
const zoom = Math.max(0.5, Math.min(5, transform.zoom));
|
||||
rawDb.prepare(`
|
||||
UPDATE tag_categories SET ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ? WHERE id = ?
|
||||
`).run(zoom, transform.offsetX, transform.offsetY, categoryId);
|
||||
const row = rawDb.prepare(`SELECT slug FROM tag_categories WHERE id = ?`).get(categoryId) as { slug: string } | undefined;
|
||||
revalidatePath("/category");
|
||||
if (row) revalidatePath(`/category/${row.slug}`);
|
||||
}
|
||||
|
||||
export async function clearCategoryCover(categoryId: number, slot: CategoryCoverSlot) {
|
||||
const c = SLOT_COLS[slot];
|
||||
if (!c) return;
|
||||
const row = rawDb.prepare(`SELECT slug, ${c.path} AS p FROM tag_categories WHERE id = ?`).get(categoryId) as
|
||||
| { slug: string; p: string | null }
|
||||
| undefined;
|
||||
if (!row) return;
|
||||
if (row.p) {
|
||||
const abs = safeJoin(COVER_ROOT, row.p);
|
||||
if (abs) await fs.rm(abs, { force: true }).catch(() => {});
|
||||
}
|
||||
rawDb.prepare(`
|
||||
UPDATE tag_categories SET ${c.path} = NULL, ${c.zoom} = 1, ${c.ox} = 0, ${c.oy} = 0 WHERE id = ?
|
||||
`).run(categoryId);
|
||||
revalidatePath("/category");
|
||||
revalidatePath(`/category/${row.slug}`);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { rawDb } from "@/lib/db/client";
|
||||
import { safeJoin } from "@/lib/safePath";
|
||||
|
||||
export type CollectionCoverSlot = "portrait" | "landscape";
|
||||
|
||||
const COVER_ROOT = path.join(process.cwd(), "data", "collection-covers");
|
||||
|
||||
const SLOT_COLS: Record<CollectionCoverSlot, { path: string; zoom: string; ox: string; oy: string }> = {
|
||||
portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" },
|
||||
landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" },
|
||||
};
|
||||
|
||||
export async function setCollectionCoverTransform(
|
||||
collectionId: number,
|
||||
slot: CollectionCoverSlot,
|
||||
transform: { zoom: number; offsetX: number; offsetY: number },
|
||||
) {
|
||||
const c = SLOT_COLS[slot];
|
||||
if (!c) return;
|
||||
const zoom = Math.max(0.5, Math.min(5, transform.zoom));
|
||||
rawDb.prepare(`
|
||||
UPDATE collections SET ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ? WHERE id = ?
|
||||
`).run(zoom, transform.offsetX, transform.offsetY, collectionId);
|
||||
const row = rawDb.prepare(`SELECT slug FROM collections WHERE id = ?`).get(collectionId) as { slug: string } | undefined;
|
||||
revalidatePath("/collection");
|
||||
if (row) revalidatePath(`/collection/${row.slug}`);
|
||||
}
|
||||
|
||||
export async function clearCollectionCover(collectionId: number, slot: CollectionCoverSlot) {
|
||||
const c = SLOT_COLS[slot];
|
||||
if (!c) return;
|
||||
const row = rawDb.prepare(`SELECT slug, ${c.path} AS p FROM collections WHERE id = ?`).get(collectionId) as
|
||||
| { slug: string; p: string | null }
|
||||
| undefined;
|
||||
if (!row) return;
|
||||
if (row.p) {
|
||||
const abs = safeJoin(COVER_ROOT, row.p);
|
||||
if (abs) await fs.rm(abs, { force: true }).catch(() => {});
|
||||
}
|
||||
rawDb.prepare(`
|
||||
UPDATE collections SET ${c.path} = NULL, ${c.zoom} = 1, ${c.ox} = 0, ${c.oy} = 0 WHERE id = ?
|
||||
`).run(collectionId);
|
||||
revalidatePath("/collection");
|
||||
revalidatePath(`/collection/${row.slug}`);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { rawDb, uniqueSlug } from "@/lib/db/client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function createCollection(name: string, description?: string): Promise<{ id: number; slug: string } | null> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const slug = uniqueSlug(rawDb, "collections", trimmed);
|
||||
// New collections land at the end of the manual order. Wrap the read +
|
||||
// insert in a tx so concurrent creates don't both pick the same
|
||||
// position.
|
||||
const tx = rawDb.transaction(() => {
|
||||
const max = rawDb.prepare(`SELECT COALESCE(MAX(position), -1) AS m FROM collections`).get() as { m: number };
|
||||
const r = rawDb.prepare(`
|
||||
INSERT INTO collections (name, slug, description, position) VALUES (?, ?, ?, ?) RETURNING id
|
||||
`).get(trimmed, slug, description?.trim() || null, max.m + 1) as { id: number };
|
||||
return r.id;
|
||||
});
|
||||
const id = tx();
|
||||
revalidatePath("/collection");
|
||||
return { id, slug };
|
||||
}
|
||||
|
||||
export async function createCollectionAction(formData: FormData) {
|
||||
const name = String(formData.get("name") ?? "");
|
||||
const description = String(formData.get("description") ?? "");
|
||||
const created = await createCollection(name, description);
|
||||
if (created) redirect(`/collection/${created.slug}`);
|
||||
}
|
||||
|
||||
export async function addImageToCollection(collectionId: number, imageId: number) {
|
||||
// Wrap the read-then-insert in a transaction so concurrent calls
|
||||
// can't both compute the same MAX(position) and produce duplicate
|
||||
// ordering values.
|
||||
const tx = rawDb.transaction(() => {
|
||||
const max = rawDb.prepare(`SELECT COALESCE(MAX(position), -1) AS m FROM collection_images WHERE collection_id = ?`).get(collectionId) as { m: number };
|
||||
rawDb.prepare(`
|
||||
INSERT OR IGNORE INTO collection_images (collection_id, image_id, position) VALUES (?, ?, ?)
|
||||
`).run(collectionId, imageId, max.m + 1);
|
||||
rawDb.prepare(`UPDATE collections SET last_used_at = (unixepoch() * 1000) WHERE id = ?`).run(collectionId);
|
||||
});
|
||||
tx();
|
||||
revalidatePath(`/collection`);
|
||||
revalidatePath(`/image/${imageId}`);
|
||||
}
|
||||
|
||||
export async function removeImageFromCollection(collectionId: number, imageId: number) {
|
||||
rawDb.prepare(`DELETE FROM collection_images WHERE collection_id = ? AND image_id = ?`).run(collectionId, imageId);
|
||||
revalidatePath(`/collection`);
|
||||
revalidatePath(`/image/${imageId}`);
|
||||
}
|
||||
|
||||
export async function deleteCollection(collectionId: number) {
|
||||
rawDb.prepare(`DELETE FROM collections WHERE id = ?`).run(collectionId);
|
||||
revalidatePath("/collection");
|
||||
redirect("/collection");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a single image within a collection. The drag-and-drop UI passes
|
||||
* the image being moved and the image it should now sit before (or null
|
||||
* to drop at the end). We pull the current ordered list, splice the
|
||||
* moved image into its new index, then rewrite every position so the
|
||||
* sequence is dense (0..N-1) regardless of any gaps the previous
|
||||
* ordering may have had.
|
||||
*/
|
||||
export async function reorderCollectionImage(
|
||||
collectionId: number,
|
||||
movedImageId: number,
|
||||
beforeImageId: number | null,
|
||||
): Promise<void> {
|
||||
const rows = rawDb.prepare(`
|
||||
SELECT image_id FROM collection_images
|
||||
WHERE collection_id = ?
|
||||
ORDER BY position ASC, image_id ASC
|
||||
`).all(collectionId) as Array<{ image_id: number }>;
|
||||
const ids = rows.map((r) => r.image_id);
|
||||
const fromIdx = ids.indexOf(movedImageId);
|
||||
if (fromIdx === -1) return;
|
||||
ids.splice(fromIdx, 1);
|
||||
let toIdx = beforeImageId == null ? ids.length : ids.indexOf(beforeImageId);
|
||||
if (toIdx === -1) toIdx = ids.length;
|
||||
ids.splice(toIdx, 0, movedImageId);
|
||||
|
||||
const tx = rawDb.transaction(() => {
|
||||
const update = rawDb.prepare(`
|
||||
UPDATE collection_images SET position = ? WHERE collection_id = ? AND image_id = ?
|
||||
`);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
update.run(i, collectionId, ids[i]);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
const slugRow = rawDb.prepare(`SELECT slug FROM collections WHERE id = ?`).get(collectionId) as { slug: string } | undefined;
|
||||
revalidatePath("/collection");
|
||||
if (slugRow) revalidatePath(`/collection/${slugRow.slug}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a collection in the manual list on /collection. Pulls the
|
||||
* current ordered ids, splices the moved one to its new index, then
|
||||
* rewrites every position so the sequence is dense (0..N-1).
|
||||
*/
|
||||
export async function reorderCollection(
|
||||
movedId: number,
|
||||
beforeId: number | null,
|
||||
): Promise<void> {
|
||||
const rows = rawDb.prepare(`
|
||||
SELECT id FROM collections ORDER BY position ASC, id ASC
|
||||
`).all() as Array<{ id: number }>;
|
||||
const ids = rows.map((r) => r.id);
|
||||
const fromIdx = ids.indexOf(movedId);
|
||||
if (fromIdx === -1) return;
|
||||
ids.splice(fromIdx, 1);
|
||||
let toIdx = beforeId == null ? ids.length : ids.indexOf(beforeId);
|
||||
if (toIdx === -1) toIdx = ids.length;
|
||||
ids.splice(toIdx, 0, movedId);
|
||||
|
||||
const tx = rawDb.transaction(() => {
|
||||
const update = rawDb.prepare(`UPDATE collections SET position = ? WHERE id = ?`);
|
||||
for (let i = 0; i < ids.length; i++) update.run(i, ids[i]);
|
||||
});
|
||||
tx();
|
||||
revalidatePath("/collection");
|
||||
}
|
||||
|
||||
/** Rename a collection. Returns the new slug for client-side redirect.
|
||||
* Wraps the slug-uniqueness check + UPDATE in a transaction so two
|
||||
* concurrent renames can't both compute the same slug and crash on
|
||||
* the UNIQUE constraint (or worse, race past it). */
|
||||
export async function renameCollection(id: number, name: string): Promise<{ slug: string; name: string } | null> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const tx = rawDb.transaction(() => {
|
||||
const current = rawDb.prepare(`SELECT name, slug FROM collections WHERE id = ?`).get(id) as
|
||||
| { name: string; slug: string }
|
||||
| undefined;
|
||||
if (!current) return null;
|
||||
if (current.name === trimmed) {
|
||||
return { slug: current.slug, name: trimmed };
|
||||
}
|
||||
const slug = uniqueSlug(rawDb, "collections", trimmed, id);
|
||||
rawDb.prepare(`UPDATE collections SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id);
|
||||
return { slug, name: trimmed };
|
||||
});
|
||||
const result = tx() as { slug: string; name: string } | null;
|
||||
if (!result) return null;
|
||||
revalidatePath("/collection");
|
||||
revalidatePath(`/collection/${result.slug}`);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { rawDb, uniqueSlug } from "@/lib/db/client";
|
||||
import { sanitizeFilename, uniqueFilePath, letterBucket, canonicalThumbName } from "@/lib/filename";
|
||||
import { safeJoin } from "@/lib/safePath";
|
||||
import { normalizeCode } from "@/lib/jav/codeParser";
|
||||
|
||||
const LIBRARY_ROOT = path.join(process.cwd(), "library");
|
||||
const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs");
|
||||
|
||||
export interface CoverMetaInput {
|
||||
imageId: number;
|
||||
code: string | null;
|
||||
title: string | null;
|
||||
releaseDate: string | null;
|
||||
runtimeMin: number | null;
|
||||
director: string | null;
|
||||
studio: string | null;
|
||||
label: string | null;
|
||||
series: string | null;
|
||||
rating: number | null;
|
||||
watched: boolean;
|
||||
notes: string | null;
|
||||
actresses: string[];
|
||||
genres: string[];
|
||||
}
|
||||
|
||||
function upsertEntity(table: "studios" | "labels" | "series" | "actresses" | "genres", name: string): number {
|
||||
const trimmed = name.trim();
|
||||
const existing = rawDb.prepare(`SELECT id FROM ${table} WHERE name = ?`).get(trimmed) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
const slug = uniqueSlug(rawDb, table, trimmed);
|
||||
const row = rawDb.prepare(`INSERT INTO ${table} (name, slug) VALUES (?, ?) RETURNING id`).get(trimmed, slug) as { id: number };
|
||||
return row.id;
|
||||
}
|
||||
|
||||
export async function saveCoverMeta(input: CoverMetaInput): Promise<{ ok: true }> {
|
||||
const studioId = input.studio?.trim() ? upsertEntity("studios", input.studio) : null;
|
||||
const labelId = input.label?.trim() ? upsertEntity("labels", input.label) : null;
|
||||
const seriesId = input.series?.trim() ? upsertEntity("series", input.series) : null;
|
||||
|
||||
// Snapshot the previous code so we can detect a code rename and move
|
||||
// the file (and its attachments) to the correct letter bucket, plus
|
||||
// rename their thumbnail files to keep the prefix in sync.
|
||||
const prev = rawDb.prepare(`SELECT code FROM images WHERE id = ?`).get(input.imageId) as
|
||||
| { code: string | null }
|
||||
| undefined;
|
||||
|
||||
const tx = rawDb.transaction(() => {
|
||||
rawDb.prepare(`
|
||||
UPDATE images SET
|
||||
code = ?, title = ?, release_date = ?, runtime_min = ?, director = ?,
|
||||
studio_id = ?, label_id = ?, series_id = ?,
|
||||
rating = ?, watched = ?, notes = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
normalizeCoverCode(input.code),
|
||||
norm(input.title),
|
||||
norm(input.releaseDate),
|
||||
// Coerce strings → numbers so FormData callers (where everything
|
||||
// arrives as a string) don't get nulled out. Number.isFinite("5")
|
||||
// is false, but Number("5") is 5.
|
||||
(() => {
|
||||
const raw: unknown = input.runtimeMin;
|
||||
const v = typeof raw === "string" ? Number(raw) : raw;
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
})(),
|
||||
norm(input.director),
|
||||
studioId,
|
||||
labelId,
|
||||
seriesId,
|
||||
typeof input.rating === "number" && input.rating >= 0 && input.rating <= 5 ? input.rating : null,
|
||||
input.watched ? 1 : 0,
|
||||
norm(input.notes),
|
||||
input.imageId,
|
||||
);
|
||||
|
||||
rawDb.prepare(`DELETE FROM image_actresses WHERE image_id = ?`).run(input.imageId);
|
||||
for (const name of dedupeNames(input.actresses)) {
|
||||
const id = upsertEntity("actresses", name);
|
||||
rawDb.prepare(`INSERT OR IGNORE INTO image_actresses (image_id, actress_id) VALUES (?, ?)`).run(input.imageId, id);
|
||||
}
|
||||
rawDb.prepare(`DELETE FROM image_genres WHERE image_id = ?`).run(input.imageId);
|
||||
for (const name of dedupeNames(input.genres)) {
|
||||
const id = upsertEntity("genres", name);
|
||||
rawDb.prepare(`INSERT OR IGNORE INTO image_genres (image_id, genre_id) VALUES (?, ?)`).run(input.imageId, id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
// After the DB update commits, see if the code rename crossed bucket
|
||||
// boundaries. If so, move the cover file and any attached children.
|
||||
// Failure here logs but doesn't roll back the DB — the file move is
|
||||
// best-effort and a later Reorganize run will fix any drift.
|
||||
const oldBucket = letterBucket(prev?.code ?? null).dirRel;
|
||||
const newCode = normalizeCoverCode(input.code);
|
||||
const newBucket = letterBucket(newCode).dirRel;
|
||||
if (oldBucket !== newBucket) {
|
||||
await moveImageBucket(input.imageId, newBucket).catch((e) => {
|
||||
console.error(`[saveCoverMeta] bucket move failed for image ${input.imageId}:`, e);
|
||||
});
|
||||
}
|
||||
// Code embeds in the thumbnail filename — rename whenever the code
|
||||
// changes, regardless of bucket. Cascades to attachments since they
|
||||
// bucket (and prefix) with the parent.
|
||||
if ((prev?.code ?? null) !== newCode) {
|
||||
await renameThumbsForCover(input.imageId, newCode).catch((e) => {
|
||||
console.error(`[saveCoverMeta] thumb rename failed for image ${input.imageId}:`, e);
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/image/${input.imageId}`);
|
||||
revalidatePath("/");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the thumbnail file(s) for a cover and its attachments after the
|
||||
* cover's code changed. Embeds the new code in the filename via
|
||||
* canonicalThumbName(); updates `thumb_path` in the DB.
|
||||
*/
|
||||
async function renameThumbsForCover(imageId: number, newCode: string | null): Promise<void> {
|
||||
const rows = rawDb.prepare(`
|
||||
SELECT id, sha256, thumb_path FROM images
|
||||
WHERE id = ? OR parent_image_id = ?
|
||||
`).all(imageId, imageId) as Array<{ id: number; sha256: string; thumb_path: string }>;
|
||||
|
||||
for (const row of rows) {
|
||||
const target = canonicalThumbName(newCode, row.sha256);
|
||||
if (target === row.thumb_path) continue;
|
||||
const oldAbs = safeJoin(THUMB_ROOT, row.thumb_path);
|
||||
const newAbs = path.join(THUMB_ROOT, target);
|
||||
if (oldAbs) {
|
||||
try {
|
||||
await fs.rename(oldAbs, newAbs);
|
||||
} catch {
|
||||
// Source missing or rename failed; leave thumb_path untouched
|
||||
// so the regenerator can pick it up.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rawDb.prepare(`UPDATE images SET thumb_path = ? WHERE id = ?`).run(target, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an image (and any attachments parented on it) into the given
|
||||
* bucket directory. Updates rel_path for each row to match the new
|
||||
* on-disk location. The thumb dir stays flat — only library/ files
|
||||
* are letter-bucketed.
|
||||
*/
|
||||
async function moveImageBucket(imageId: number, newBucketDir: string): Promise<void> {
|
||||
const targets = rawDb.prepare(`
|
||||
SELECT id, filename, rel_path FROM images
|
||||
WHERE id = ? OR parent_image_id = ?
|
||||
`).all(imageId, imageId) as Array<{ id: number; filename: string; rel_path: string }>;
|
||||
|
||||
await fs.mkdir(path.join(LIBRARY_ROOT, newBucketDir), { recursive: true });
|
||||
|
||||
for (const row of targets) {
|
||||
const oldAbs = safeJoin(LIBRARY_ROOT, row.rel_path);
|
||||
if (!oldAbs) continue;
|
||||
const currentDir = path.posix.dirname(row.rel_path.replace(/\\/g, "/"));
|
||||
if (currentDir === newBucketDir) continue; // already in place
|
||||
const { base, ext } = sanitizeFilename(row.filename || path.basename(row.rel_path));
|
||||
const newAbs = await uniqueFilePath(path.join(LIBRARY_ROOT, newBucketDir), base, ext);
|
||||
// uniqueFilePath reserves the slot by creating a 0-byte file via wx.
|
||||
// On Windows fs.rename() fails when the destination exists, so we
|
||||
// unlink the placeholder right before the rename. Tiny race window
|
||||
// is acceptable — single-process server actions, not concurrent uploads.
|
||||
await fs.rm(newAbs, { force: true }).catch(() => {});
|
||||
try {
|
||||
await fs.rename(oldAbs, newAbs);
|
||||
} catch {
|
||||
// Source missing or rename failed; skip this row.
|
||||
continue;
|
||||
}
|
||||
const newRel = path.posix.join(newBucketDir, path.basename(newAbs));
|
||||
rawDb.prepare(`UPDATE images SET rel_path = ? WHERE id = ?`).run(newRel, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Revalidate every entity index that lists covers. Toggling a flag can
|
||||
* change visibility under any of these (filters by VIP/Favorite/Owned/
|
||||
* Watched are common across listings). Mirrors trash.ts's revalidate(). */
|
||||
function revalidateAllCoverIndexes(imageId: number): void {
|
||||
revalidatePath(`/image/${imageId}`);
|
||||
revalidatePath("/");
|
||||
revalidatePath("/collection");
|
||||
revalidatePath("/tag");
|
||||
revalidatePath("/actress");
|
||||
revalidatePath("/studios");
|
||||
revalidatePath("/series");
|
||||
revalidatePath("/genres");
|
||||
revalidatePath("/labels");
|
||||
}
|
||||
|
||||
export async function setWatched(imageId: number, watched: boolean): Promise<void> {
|
||||
rawDb.prepare(`UPDATE images SET watched = ? WHERE id = ?`).run(watched ? 1 : 0, imageId);
|
||||
revalidateAllCoverIndexes(imageId);
|
||||
}
|
||||
|
||||
export async function setCoverVip(imageId: number, vip: boolean): Promise<void> {
|
||||
// VIP and Favorite are mutually exclusive — turning one on clears the other.
|
||||
if (vip) {
|
||||
rawDb.prepare(`UPDATE images SET is_vip = 1, is_favorite = 0 WHERE id = ?`).run(imageId);
|
||||
} else {
|
||||
rawDb.prepare(`UPDATE images SET is_vip = 0 WHERE id = ?`).run(imageId);
|
||||
}
|
||||
revalidateAllCoverIndexes(imageId);
|
||||
}
|
||||
|
||||
export async function setCoverFavorite(imageId: number, favorite: boolean): Promise<void> {
|
||||
if (favorite) {
|
||||
rawDb.prepare(`UPDATE images SET is_favorite = 1, is_vip = 0 WHERE id = ?`).run(imageId);
|
||||
} else {
|
||||
rawDb.prepare(`UPDATE images SET is_favorite = 0 WHERE id = ?`).run(imageId);
|
||||
}
|
||||
revalidateAllCoverIndexes(imageId);
|
||||
}
|
||||
|
||||
export async function setRating(imageId: number, rating: number | null): Promise<void> {
|
||||
let v: number | null = null;
|
||||
if (rating != null && Number.isFinite(rating)) {
|
||||
v = Math.max(0, Math.min(5, Math.round(rating)));
|
||||
}
|
||||
rawDb.prepare(`UPDATE images SET rating = ? WHERE id = ?`).run(v, imageId);
|
||||
revalidateAllCoverIndexes(imageId);
|
||||
}
|
||||
|
||||
export async function setCoverOwned(imageId: number, owned: boolean): Promise<void> {
|
||||
rawDb.prepare(`UPDATE images SET is_owned = ? WHERE id = ?`).run(owned ? 1 : 0, imageId);
|
||||
revalidateAllCoverIndexes(imageId);
|
||||
}
|
||||
|
||||
function norm(v: string | null): string | null {
|
||||
if (v == null) return null;
|
||||
const t = v.trim();
|
||||
return t === "" ? null : t;
|
||||
}
|
||||
|
||||
function normalizeCoverCode(v: string | null): string | null {
|
||||
const t = norm(v);
|
||||
if (!t) return null;
|
||||
const normalized = normalizeCode(t);
|
||||
if (normalized) return normalized;
|
||||
const safe = t.toUpperCase().replace(/[^A-Z0-9-]/g, "");
|
||||
return safe || null;
|
||||
}
|
||||
|
||||
function dedupeNames(names: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of names) {
|
||||
const t = raw.trim();
|
||||
if (!t) continue;
|
||||
const key = t.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { rawDb, uniqueSlug } from "@/lib/db/client";
|
||||
|
||||
type EntityTable = "actresses" | "studios" | "series";
|
||||
|
||||
function createEntity(table: EntityTable, name: string): { id: number; slug: string } | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const existing = rawDb.prepare(`SELECT id, slug FROM ${table} WHERE name = ?`).get(trimmed) as
|
||||
| { id: number; slug: string }
|
||||
| undefined;
|
||||
if (existing) return existing;
|
||||
const slug = uniqueSlug(rawDb, table, trimmed);
|
||||
const row = rawDb.prepare(`INSERT INTO ${table} (name, slug) VALUES (?, ?) RETURNING id`).get(trimmed, slug) as {
|
||||
id: number;
|
||||
};
|
||||
return { id: row.id, slug };
|
||||
}
|
||||
|
||||
export async function createActressAction(formData: FormData) {
|
||||
createEntity("actresses", String(formData.get("name") ?? ""));
|
||||
revalidatePath("/actress");
|
||||
}
|
||||
|
||||
export async function createStudioAction(formData: FormData) {
|
||||
const created = createEntity("studios", String(formData.get("name") ?? ""));
|
||||
revalidatePath("/studios");
|
||||
if (created) redirect(`/studios/${created.slug}`);
|
||||
}
|
||||
|
||||
export async function createSeriesAction(formData: FormData) {
|
||||
const created = createEntity("series", String(formData.get("name") ?? ""));
|
||||
revalidatePath("/series");
|
||||
if (created) redirect(`/series/${created.slug}`);
|
||||
}
|
||||
|
||||
/** Deletes a studio. Any covers referencing it have studio_id set to NULL. */
|
||||
export async function deleteStudio(id: number) {
|
||||
rawDb.prepare(`UPDATE images SET studio_id = NULL WHERE studio_id = ?`).run(id);
|
||||
rawDb.prepare(`DELETE FROM studios WHERE id = ?`).run(id);
|
||||
revalidatePath("/studios");
|
||||
revalidatePath("/");
|
||||
redirect("/studios");
|
||||
}
|
||||
|
||||
/** Deletes a series. Any covers referencing it have series_id set to NULL. */
|
||||
export async function deleteSeries(id: number) {
|
||||
rawDb.prepare(`UPDATE images SET series_id = NULL WHERE series_id = ?`).run(id);
|
||||
rawDb.prepare(`DELETE FROM series WHERE id = ?`).run(id);
|
||||
revalidatePath("/series");
|
||||
revalidatePath("/");
|
||||
redirect("/series");
|
||||
}
|
||||
|
||||
/** Rename a studio. Returns the new slug (which may be re-uniquified) or null. */
|
||||
export async function renameStudio(id: number, name: string): Promise<{ slug: string } | null> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const row = rawDb.prepare(`SELECT name, slug FROM studios WHERE id = ?`).get(id) as { name: string; slug: string } | undefined;
|
||||
if (!row) return null;
|
||||
if (trimmed === row.name) return { slug: row.slug };
|
||||
const slug = uniqueSlug(rawDb, "studios", trimmed, id);
|
||||
rawDb.prepare(`UPDATE studios SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id);
|
||||
revalidatePath("/studios");
|
||||
revalidatePath(`/studios/${row.slug}`);
|
||||
if (slug !== row.slug) revalidatePath(`/studios/${slug}`);
|
||||
return { slug };
|
||||
}
|
||||
|
||||
/** Rename a series. Returns the new slug or null. */
|
||||
export async function renameSeries(id: number, name: string): Promise<{ slug: string } | null> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const row = rawDb.prepare(`SELECT name, slug FROM series WHERE id = ?`).get(id) as { name: string; slug: string } | undefined;
|
||||
if (!row) return null;
|
||||
if (trimmed === row.name) return { slug: row.slug };
|
||||
const slug = uniqueSlug(rawDb, "series", trimmed, id);
|
||||
rawDb.prepare(`UPDATE series SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id);
|
||||
revalidatePath("/series");
|
||||
revalidatePath(`/series/${row.slug}`);
|
||||
if (slug !== row.slug) revalidatePath(`/series/${slug}`);
|
||||
return { slug };
|
||||
}
|
||||
|
||||
/** Rename a tag (keyed by name). Returns the new name or null. */
|
||||
export async function renameTag(oldName: string, newName: string): Promise<{ name: string } | null> {
|
||||
// Tags are stored lowercased (see tags.ts createTag/addTagToImage).
|
||||
// Lookup by exact match must use the same casing or it silently misses.
|
||||
const oldTrim = oldName.trim().toLowerCase();
|
||||
const newTrim = newName.trim().toLowerCase();
|
||||
if (!oldTrim || !newTrim) return null;
|
||||
if (oldTrim === newTrim) return { name: oldTrim };
|
||||
const row = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(oldTrim) as { id: number } | undefined;
|
||||
if (!row) return null;
|
||||
// Wrap merge in a transaction so a concurrent INSERT into image_tags
|
||||
// for the old tag_id can't slip between the UPDATE re-point and the
|
||||
// DELETE — both run atomically against a consistent snapshot.
|
||||
const merge = rawDb.transaction(() => {
|
||||
const conflict = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(newTrim) as { id: number } | undefined;
|
||||
if (conflict && conflict.id !== row.id) {
|
||||
rawDb.prepare(`UPDATE OR IGNORE image_tags SET tag_id = ? WHERE tag_id = ?`).run(conflict.id, row.id);
|
||||
rawDb.prepare(`DELETE FROM image_tags WHERE tag_id = ?`).run(row.id);
|
||||
rawDb.prepare(`DELETE FROM tags WHERE id = ?`).run(row.id);
|
||||
} else {
|
||||
rawDb.prepare(`UPDATE tags SET name = ? WHERE id = ?`).run(newTrim, row.id);
|
||||
}
|
||||
});
|
||||
merge();
|
||||
revalidatePath("/tag");
|
||||
revalidatePath(`/tag/${encodeURIComponent(oldTrim)}`);
|
||||
revalidatePath(`/tag/${encodeURIComponent(newTrim)}`);
|
||||
return { name: newTrim };
|
||||
}
|
||||
|
||||
/** Deletes a tag. Any image_tags rows referencing it are removed. */
|
||||
export async function deleteTag(name: string) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const row = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(trimmed) as { id: number } | undefined;
|
||||
if (!row) return;
|
||||
rawDb.prepare(`DELETE FROM image_tags WHERE tag_id = ?`).run(row.id);
|
||||
rawDb.prepare(`DELETE FROM tags WHERE id = ?`).run(row.id);
|
||||
revalidatePath("/tag");
|
||||
revalidatePath("/");
|
||||
redirect("/tag");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
"use server";
|
||||
import { getImageContextData } from "@/lib/db/queries";
|
||||
|
||||
export async function fetchImageContextData(imageIds: number[]) {
|
||||
return getImageContextData(imageIds);
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
"use server";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import sharp from "sharp";
|
||||
import { rawDb } from "@/lib/db/client";
|
||||
import { sanitizeFilename, uniqueFilePath, letterBucket, canonicalThumbName } from "@/lib/filename";
|
||||
import { extractCode } from "@/lib/jav/codeParser";
|
||||
import { computeDHash, hammingDistance } from "@/lib/jav/phash";
|
||||
import { clearAppSettingsCache } from "@/lib/db/appSettings";
|
||||
import { safeJoin } from "@/lib/safePath";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
const LIBRARY_ROOT = path.join(process.cwd(), "library");
|
||||
const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs");
|
||||
const PORTRAIT_ROOT = path.join(process.cwd(), "data", "portraits");
|
||||
const CATEGORY_COVER_ROOT = path.join(process.cwd(), "data", "category-covers");
|
||||
const COLLECTION_COVER_ROOT = path.join(process.cwd(), "data", "collection-covers");
|
||||
|
||||
const SYSTEM_FILES = new Set([".ds_store", "thumbs.db", "desktop.ini"]);
|
||||
|
||||
interface OrphanReport {
|
||||
libraryFiles: string[];
|
||||
thumbFiles: string[];
|
||||
portraitFiles: string[];
|
||||
categoryCoverFiles: string[];
|
||||
collectionCoverFiles: string[];
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
let entries: import("node:fs").Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
await Promise.all(entries.map(async (e) => {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
out.push(...(await walk(full)));
|
||||
} else if (e.isFile() && !SYSTEM_FILES.has(e.name.toLowerCase())) {
|
||||
out.push(full);
|
||||
}
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function findOrphans(): Promise<OrphanReport> {
|
||||
const knownLibrary = new Set(
|
||||
(rawDb.prepare(`SELECT rel_path FROM images`).all() as Array<{ rel_path: string }>)
|
||||
.map((r) => path.normalize(r.rel_path)),
|
||||
);
|
||||
const knownThumbs = new Set(
|
||||
(rawDb.prepare(`SELECT thumb_path FROM images`).all() as Array<{ thumb_path: string }>)
|
||||
.map((r) => path.normalize(r.thumb_path)),
|
||||
);
|
||||
const knownPortraits = new Set(
|
||||
(rawDb
|
||||
.prepare(`
|
||||
SELECT portrait_path AS p FROM actresses WHERE portrait_path IS NOT NULL
|
||||
UNION ALL SELECT portrait2_path FROM actresses WHERE portrait2_path IS NOT NULL
|
||||
UNION ALL SELECT portrait3_path FROM actresses WHERE portrait3_path IS NOT NULL
|
||||
UNION ALL SELECT portrait4_path FROM actresses WHERE portrait4_path IS NOT NULL
|
||||
UNION ALL SELECT portraith_path FROM actresses WHERE portraith_path IS NOT NULL
|
||||
`)
|
||||
.all() as Array<{ p: string }>)
|
||||
.map((r) => path.normalize(r.p)),
|
||||
);
|
||||
const knownCategoryCovers = new Set(
|
||||
(rawDb
|
||||
.prepare(`
|
||||
SELECT cover_portrait_path AS p FROM tag_categories WHERE cover_portrait_path IS NOT NULL
|
||||
UNION ALL SELECT cover_landscape_path FROM tag_categories WHERE cover_landscape_path IS NOT NULL
|
||||
`)
|
||||
.all() as Array<{ p: string }>)
|
||||
.map((r) => path.normalize(r.p)),
|
||||
);
|
||||
const knownCollectionCovers = new Set(
|
||||
(rawDb
|
||||
.prepare(`
|
||||
SELECT cover_portrait_path AS p FROM collections WHERE cover_portrait_path IS NOT NULL
|
||||
UNION ALL SELECT cover_landscape_path FROM collections WHERE cover_landscape_path IS NOT NULL
|
||||
`)
|
||||
.all() as Array<{ p: string }>)
|
||||
.map((r) => path.normalize(r.p)),
|
||||
);
|
||||
|
||||
const [libFiles, thumbFiles, portraitFiles, categoryCoverFiles, collectionCoverFiles] = await Promise.all([
|
||||
walk(LIBRARY_ROOT),
|
||||
walk(THUMB_ROOT),
|
||||
walk(PORTRAIT_ROOT),
|
||||
walk(CATEGORY_COVER_ROOT),
|
||||
walk(COLLECTION_COVER_ROOT),
|
||||
]);
|
||||
|
||||
const libraryOrphans = libFiles.filter((abs) => {
|
||||
const rel = path.normalize(path.relative(LIBRARY_ROOT, abs));
|
||||
return !knownLibrary.has(rel);
|
||||
});
|
||||
const thumbOrphans = thumbFiles.filter((abs) => {
|
||||
const rel = path.normalize(path.relative(THUMB_ROOT, abs));
|
||||
return !knownThumbs.has(rel);
|
||||
});
|
||||
const portraitOrphans = portraitFiles.filter((abs) => {
|
||||
const rel = path.normalize(path.relative(PORTRAIT_ROOT, abs));
|
||||
return !knownPortraits.has(rel);
|
||||
});
|
||||
const categoryCoverOrphans = categoryCoverFiles.filter((abs) => {
|
||||
const rel = path.normalize(path.relative(CATEGORY_COVER_ROOT, abs));
|
||||
return !knownCategoryCovers.has(rel);
|
||||
});
|
||||
const collectionCoverOrphans = collectionCoverFiles.filter((abs) => {
|
||||
const rel = path.normalize(path.relative(COLLECTION_COVER_ROOT, abs));
|
||||
return !knownCollectionCovers.has(rel);
|
||||
});
|
||||
|
||||
let bytes = 0;
|
||||
await Promise.all([
|
||||
...libraryOrphans, ...thumbOrphans, ...portraitOrphans,
|
||||
...categoryCoverOrphans, ...collectionCoverOrphans,
|
||||
].map(async (f) => {
|
||||
try { bytes += (await fs.stat(f)).size; } catch {}
|
||||
}));
|
||||
|
||||
return {
|
||||
libraryFiles: libraryOrphans,
|
||||
thumbFiles: thumbOrphans,
|
||||
portraitFiles: portraitOrphans,
|
||||
categoryCoverFiles: categoryCoverOrphans,
|
||||
collectionCoverFiles: collectionCoverOrphans,
|
||||
bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function previewOrphanFiles(): Promise<{ count: number; bytes: number }> {
|
||||
const report = await findOrphans();
|
||||
const count =
|
||||
report.libraryFiles.length +
|
||||
report.thumbFiles.length +
|
||||
report.portraitFiles.length +
|
||||
report.categoryCoverFiles.length +
|
||||
report.collectionCoverFiles.length;
|
||||
return { count, bytes: report.bytes };
|
||||
}
|
||||
|
||||
export async function purgeOrphanFiles(): Promise<{ deleted: number; bytes: number }> {
|
||||
const report = await findOrphans();
|
||||
const all = [
|
||||
...report.libraryFiles,
|
||||
...report.thumbFiles,
|
||||
...report.portraitFiles,
|
||||
...report.categoryCoverFiles,
|
||||
...report.collectionCoverFiles,
|
||||
];
|
||||
// Bound concurrency: Promise.all over thousands of fs.rm calls can
|
||||
// exhaust file descriptors (EMFILE) on Windows / low-ulimit hosts.
|
||||
const CONCURRENCY = 32;
|
||||
for (let i = 0; i < all.length; i += CONCURRENCY) {
|
||||
await Promise.all(all.slice(i, i + CONCURRENCY).map((f) => fs.rm(f, { force: true })));
|
||||
}
|
||||
// Sweep empty subdirs across every root that just shed files.
|
||||
await Promise.all([
|
||||
cleanEmptyDirs(LIBRARY_ROOT),
|
||||
cleanEmptyDirs(THUMB_ROOT),
|
||||
cleanEmptyDirs(PORTRAIT_ROOT),
|
||||
cleanEmptyDirs(CATEGORY_COVER_ROOT),
|
||||
cleanEmptyDirs(COLLECTION_COVER_ROOT),
|
||||
]);
|
||||
// Indexes that show cover/portrait/thumb counts need to refetch.
|
||||
revalidatePath("/");
|
||||
revalidatePath("/category");
|
||||
revalidatePath("/collection");
|
||||
revalidatePath("/actress");
|
||||
return { deleted: all.length, bytes: report.bytes };
|
||||
}
|
||||
|
||||
interface ReorganizePreview {
|
||||
total: number;
|
||||
toMove: number;
|
||||
}
|
||||
|
||||
interface ImageRow {
|
||||
id: number;
|
||||
filename: string;
|
||||
rel_path: string;
|
||||
code: string | null;
|
||||
parent_image_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target letter-bucket directory for a row. Attached images
|
||||
* (parent_image_id set) bucket with their parent's code so related files
|
||||
* stay together on disk.
|
||||
*/
|
||||
function plannedDirRel(row: ImageRow, parentCodeById: Map<number, string | null>): string {
|
||||
if (row.parent_image_id != null) {
|
||||
const parentCode = parentCodeById.get(row.parent_image_id) ?? null;
|
||||
return letterBucket(parentCode).dirRel;
|
||||
}
|
||||
return letterBucket(row.code).dirRel;
|
||||
}
|
||||
|
||||
function loadAllImages(): { rows: ImageRow[]; parentCodeById: Map<number, string | null> } {
|
||||
const rows = rawDb.prepare(`SELECT id, filename, rel_path, code, parent_image_id FROM images`).all() as ImageRow[];
|
||||
const parentCodeById = new Map<number, string | null>();
|
||||
for (const r of rows) parentCodeById.set(r.id, r.code);
|
||||
return { rows, parentCodeById };
|
||||
}
|
||||
|
||||
export async function previewReorganize(): Promise<ReorganizePreview> {
|
||||
const { rows, parentCodeById } = loadAllImages();
|
||||
let toMove = 0;
|
||||
for (const r of rows) {
|
||||
const target = plannedDirRel(r, parentCodeById);
|
||||
const currentDir = path.posix.dirname(r.rel_path.replace(/\\/g, "/"));
|
||||
if (currentDir !== target) toMove++;
|
||||
}
|
||||
return { total: rows.length, toMove };
|
||||
}
|
||||
|
||||
export async function reorganizeFiles(): Promise<{ moved: number; skipped: number; errors: number }> {
|
||||
const { rows, parentCodeById } = loadAllImages();
|
||||
|
||||
let moved = 0, skipped = 0, errors = 0;
|
||||
for (const r of rows) {
|
||||
const target = plannedDirRel(r, parentCodeById);
|
||||
const currentDir = path.posix.dirname(r.rel_path.replace(/\\/g, "/"));
|
||||
if (currentDir === target) { skipped++; continue; }
|
||||
|
||||
const oldAbs = path.join(LIBRARY_ROOT, r.rel_path);
|
||||
try {
|
||||
await fs.access(oldAbs);
|
||||
} catch {
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { base, ext } = sanitizeFilename(r.filename || `image${path.extname(r.rel_path)}`);
|
||||
const dirAbs = path.join(LIBRARY_ROOT, target);
|
||||
try {
|
||||
await fs.mkdir(dirAbs, { recursive: true });
|
||||
const newAbs = await uniqueFilePath(dirAbs, base, ext);
|
||||
await fs.rename(oldAbs, newAbs);
|
||||
const newRel = path.posix.join(target, path.basename(newAbs));
|
||||
rawDb.prepare(`UPDATE images SET rel_path = ? WHERE id = ?`).run(newRel, r.id);
|
||||
moved++;
|
||||
} catch {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
await cleanEmptyDirs(LIBRARY_ROOT);
|
||||
revalidatePath("/");
|
||||
return { moved, skipped, errors };
|
||||
}
|
||||
|
||||
export async function clearCache(): Promise<{ ok: true }> {
|
||||
clearAppSettingsCache();
|
||||
for (const p of ["/", "/collection", "/tag", "/category", "/actress", "/studios", "/series", "/genres", "/queue"]) {
|
||||
revalidatePath(p);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export interface UndersizedCover {
|
||||
id: number;
|
||||
code: string | null;
|
||||
filename: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
thumbPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan top-level covers whose pixel dimensions look smaller than a
|
||||
* standard JAV cover (typically 800x538). Catches accidental imports of
|
||||
* thumbnails, web previews, or other non-cover images.
|
||||
*
|
||||
* Defaults are deliberately permissive — the standard is 800x538 but real
|
||||
* scans/rips drift by a few pixels in either direction. The 147x200
|
||||
* outlier the user spotted falls well below the floor.
|
||||
*/
|
||||
export async function scanUndersizedCovers(opts?: {
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
}): Promise<UndersizedCover[]> {
|
||||
const minW = opts?.minWidth ?? 750;
|
||||
const minH = opts?.minHeight ?? 500;
|
||||
return rawDb.prepare(`
|
||||
SELECT id, code, filename, width, height, bytes, thumb_path AS thumbPath
|
||||
FROM images
|
||||
WHERE parent_image_id IS NULL
|
||||
AND deleted_at IS NULL
|
||||
AND (width < ? OR height < ?)
|
||||
ORDER BY (width * height) ASC, id ASC
|
||||
`).all(minW, minH) as UndersizedCover[];
|
||||
}
|
||||
|
||||
interface RegenThumbsPreview {
|
||||
total: number;
|
||||
missing: number;
|
||||
staleNames: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the planned canonical filename for a row: includes parent code
|
||||
* lookup for attached images so back-covers inherit the prefix.
|
||||
*/
|
||||
function plannedThumbName(row: { sha256: string; code: string | null; parent_image_id: number | null }): string {
|
||||
if (row.parent_image_id != null) {
|
||||
const parent = rawDb.prepare(`SELECT code FROM images WHERE id = ?`).get(row.parent_image_id) as
|
||||
| { code: string | null }
|
||||
| undefined;
|
||||
return canonicalThumbName(parent?.code ?? null, row.sha256);
|
||||
}
|
||||
return canonicalThumbName(row.code, row.sha256);
|
||||
}
|
||||
|
||||
/** Count covers whose thumb file is missing on disk or whose stored name is stale. */
|
||||
export async function previewRegenThumbnails(): Promise<RegenThumbsPreview> {
|
||||
const rows = rawDb.prepare(`
|
||||
SELECT thumb_path, sha256, code, parent_image_id FROM images WHERE deleted_at IS NULL
|
||||
`).all() as Array<{ thumb_path: string; sha256: string; code: string | null; parent_image_id: number | null }>;
|
||||
let missing = 0;
|
||||
let staleNames = 0;
|
||||
// Sequential is fine for personal-library scale; a bulk Promise.all here
|
||||
// can blow up with EMFILE on very large libraries.
|
||||
for (const r of rows) {
|
||||
const target = plannedThumbName(r);
|
||||
if (target !== r.thumb_path) staleNames++;
|
||||
const targetAbs = path.join(THUMB_ROOT, target);
|
||||
try { await fs.access(targetAbs); } catch { missing++; }
|
||||
}
|
||||
return { total: rows.length, missing, staleNames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild thumbnails. Three paths per row:
|
||||
* 1. Canonical file already on disk → skip (unless `force`).
|
||||
* 2. Legacy file (different name from canonical) is on disk → rename it
|
||||
* to canonical and update thumb_path. No re-encode needed; this is
|
||||
* the migration path for libraries that predate the code-prefix
|
||||
* naming.
|
||||
* 3. Neither file is on disk → read original from library/ and encode
|
||||
* from scratch.
|
||||
*/
|
||||
export async function regenerateThumbnails(opts?: { force?: boolean }): Promise<{ regenerated: number; renamed: number; skipped: number; errors: number }> {
|
||||
const force = opts?.force ?? false;
|
||||
const rows = rawDb.prepare(`
|
||||
SELECT id, rel_path, thumb_path, sha256, code, parent_image_id FROM images WHERE deleted_at IS NULL
|
||||
`).all() as Array<{ id: number; rel_path: string; thumb_path: string; sha256: string; code: string | null; parent_image_id: number | null }>;
|
||||
|
||||
await fs.mkdir(THUMB_ROOT, { recursive: true });
|
||||
|
||||
let regenerated = 0, renamed = 0, skipped = 0, errors = 0;
|
||||
for (const r of rows) {
|
||||
const target = plannedThumbName(r);
|
||||
const targetAbs = path.join(THUMB_ROOT, target);
|
||||
|
||||
if (!force) {
|
||||
try {
|
||||
await fs.access(targetAbs);
|
||||
// Canonical file exists. If the DB still has the legacy name,
|
||||
// sync the column so future operations don't drift.
|
||||
if (r.thumb_path !== target) {
|
||||
rawDb.prepare(`UPDATE images SET thumb_path = ? WHERE id = ?`).run(target, r.id);
|
||||
}
|
||||
skipped++;
|
||||
continue;
|
||||
} catch { /* missing — fall through */ }
|
||||
}
|
||||
|
||||
// Try the legacy/current path: if a thumb exists at the stored
|
||||
// thumb_path that's different from canonical, rename it instead of
|
||||
// re-encoding. Faster, lossless, preserves whatever the file already
|
||||
// was.
|
||||
if (r.thumb_path !== target) {
|
||||
const oldAbs = safeJoin(THUMB_ROOT, r.thumb_path);
|
||||
if (oldAbs) {
|
||||
try {
|
||||
await fs.access(oldAbs);
|
||||
if (force) {
|
||||
// Force mode: drop the old file and re-encode at canonical.
|
||||
await fs.rm(oldAbs, { force: true }).catch(() => {});
|
||||
} else {
|
||||
await fs.rename(oldAbs, targetAbs);
|
||||
rawDb.prepare(`UPDATE images SET thumb_path = ? WHERE id = ?`).run(target, r.id);
|
||||
renamed++;
|
||||
continue;
|
||||
}
|
||||
} catch { /* legacy file missing — fall through to encode */ }
|
||||
}
|
||||
}
|
||||
|
||||
const libAbs = safeJoin(LIBRARY_ROOT, r.rel_path);
|
||||
if (!libAbs) {
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Pass the file path to sharp instead of reading into a buffer.
|
||||
// The library can contain multi-GB videos that were misclassified
|
||||
// as images; reading those into memory would OOM the server.
|
||||
// sharp streams from disk and reports its own decode errors.
|
||||
// Mirrors lib/ingest/ingest.ts's resize pipeline.
|
||||
await sharp(libAbs, { failOn: "none" })
|
||||
.rotate()
|
||||
.resize({ width: 768, height: 768, fit: "inside", withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toFile(targetAbs);
|
||||
if (r.thumb_path !== target) {
|
||||
rawDb.prepare(`UPDATE images SET thumb_path = ? WHERE id = ?`).run(target, r.id);
|
||||
}
|
||||
regenerated++;
|
||||
} catch {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
return { regenerated, renamed, skipped, errors };
|
||||
}
|
||||
|
||||
async function cleanEmptyDirs(root: string): Promise<void> {
|
||||
let entries: import("node:fs").Dirent[] = [];
|
||||
try { entries = await fs.readdir(root, { withFileTypes: true }); } catch { return; }
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const dir = path.join(root, e.name);
|
||||
await cleanEmptyDirs(dir);
|
||||
try {
|
||||
const remaining = await fs.readdir(dir);
|
||||
if (remaining.length === 0) await fs.rmdir(dir);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReparseCodesPreview {
|
||||
total: number;
|
||||
/** Rows with no code where extractCode now finds one — safe to fill. */
|
||||
missing: number;
|
||||
/** Rows where extractCode disagrees with the stored code — overwrite
|
||||
* is destructive of any manual edit, so it's gated behind force=true. */
|
||||
changed: number;
|
||||
/** Sample of up to 20 changed rows for the preview UI. */
|
||||
sampleChanges: Array<{ id: number; filename: string; oldCode: string; newCode: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every top-level cover (parent_image_id IS NULL, not soft-deleted)
|
||||
* and re-run extractCode against the stored filename. Reports how many
|
||||
* rows would change so the user can preview before committing.
|
||||
*/
|
||||
export async function previewReparseCodes(): Promise<ReparseCodesPreview> {
|
||||
const rows = rawDb.prepare(`
|
||||
SELECT id, filename, code FROM images
|
||||
WHERE deleted_at IS NULL AND parent_image_id IS NULL
|
||||
`).all() as Array<{ id: number; filename: string; code: string | null }>;
|
||||
let missing = 0, changed = 0;
|
||||
const sampleChanges: ReparseCodesPreview["sampleChanges"] = [];
|
||||
for (const r of rows) {
|
||||
const extracted = extractCode(r.filename);
|
||||
if (!extracted) continue;
|
||||
if (r.code == null) {
|
||||
missing++;
|
||||
} else if (r.code !== extracted) {
|
||||
changed++;
|
||||
if (sampleChanges.length < 20) {
|
||||
sampleChanges.push({ id: r.id, filename: r.filename, oldCode: r.code, newCode: extracted });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { total: rows.length, missing, changed, sampleChanges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the re-parse. By default only fills rows with NULL code (safe);
|
||||
* pass force=true to overwrite codes that disagree with extractCode.
|
||||
*
|
||||
* Note: this only updates the DB. Files won't move into their new
|
||||
* letter buckets until you also run Reorganize. Same for thumbnail
|
||||
* filenames — the code prefix in `<CODE>-<sha>.webp` won't update until
|
||||
* Regenerate Thumbnails runs.
|
||||
*/
|
||||
export async function reparseCodes(opts?: { force?: boolean }): Promise<{ filled: number; updated: number; skipped: number }> {
|
||||
const force = opts?.force ?? false;
|
||||
const rows = rawDb.prepare(`
|
||||
SELECT id, filename, code FROM images
|
||||
WHERE deleted_at IS NULL AND parent_image_id IS NULL
|
||||
`).all() as Array<{ id: number; filename: string; code: string | null }>;
|
||||
|
||||
let filled = 0, updated = 0, skipped = 0;
|
||||
const tx = rawDb.transaction(() => {
|
||||
const update = rawDb.prepare(`UPDATE images SET code = ? WHERE id = ?`);
|
||||
for (const r of rows) {
|
||||
const extracted = extractCode(r.filename);
|
||||
if (!extracted) { skipped++; continue; }
|
||||
if (r.code == null) {
|
||||
update.run(extracted, r.id);
|
||||
filled++;
|
||||
} else if (r.code !== extracted) {
|
||||
if (force) {
|
||||
update.run(extracted, r.id);
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
});
|
||||
tx();
|
||||
revalidatePath("/");
|
||||
return { filled, updated, skipped };
|
||||
}
|
||||
|
||||
export interface NearDupePair {
|
||||
a: { id: number; code: string | null; filename: string; thumbPath: string; width: number; height: number; bytes: number };
|
||||
b: { id: number; code: string | null; filename: string; thumbPath: string; width: number; height: number; bytes: number };
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export interface NearDupesPreview {
|
||||
total: number;
|
||||
hashed: number;
|
||||
unhashed: number;
|
||||
}
|
||||
|
||||
/** Quick stats: how many rows already have a phash vs need backfilling. */
|
||||
export async function previewNearDupes(): Promise<NearDupesPreview> {
|
||||
const row = rawDb.prepare(`
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN phash IS NOT NULL THEN 1 ELSE 0 END) AS hashed
|
||||
FROM images WHERE deleted_at IS NULL
|
||||
`).get() as { total: number; hashed: number };
|
||||
return {
|
||||
total: row.total,
|
||||
hashed: row.hashed,
|
||||
unhashed: row.total - row.hashed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill `phash` for every row that doesn't have one yet. Reads the
|
||||
* library file, computes dHash, writes to DB. Skips rows whose file is
|
||||
* missing on disk.
|
||||
*/
|
||||
export async function backfillPhashes(): Promise<{ hashed: number; skipped: number; errors: number }> {
|
||||
const rows = rawDb.prepare(`
|
||||
SELECT id, rel_path FROM images
|
||||
WHERE deleted_at IS NULL AND phash IS NULL
|
||||
`).all() as Array<{ id: number; rel_path: string }>;
|
||||
|
||||
let hashed = 0, skipped = 0, errors = 0;
|
||||
const update = rawDb.prepare(`UPDATE images SET phash = ? WHERE id = ?`);
|
||||
for (const r of rows) {
|
||||
const abs = safeJoin(LIBRARY_ROOT, r.rel_path);
|
||||
if (!abs) { errors++; continue; }
|
||||
try {
|
||||
const buf = await fs.readFile(abs);
|
||||
const hash = await computeDHash(buf);
|
||||
update.run(hash, r.id);
|
||||
hashed++;
|
||||
} catch {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
return { hashed, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find pairs of covers whose dHashes are within `threshold` Hamming
|
||||
* distance. Brute force O(n²); fine for personal-library scale (5k
|
||||
* covers ≈ 12.5M comparisons, runs in well under a second).
|
||||
*
|
||||
* Excludes pairs that are already SHA-identical (those are caught by
|
||||
* upload dedup) and excludes attached-image pairs (those are
|
||||
* intentionally similar to their parent).
|
||||
*
|
||||
* Default threshold = 10 (out of 64 bits) is a strong "same image,
|
||||
* different encode" signal.
|
||||
*/
|
||||
export async function findNearDuplicates(opts?: { threshold?: number; limit?: number }): Promise<NearDupePair[]> {
|
||||
const threshold = opts?.threshold ?? 10;
|
||||
const limit = opts?.limit ?? 200;
|
||||
const rows = rawDb.prepare(`
|
||||
SELECT id, code, filename, rel_path, thumb_path AS thumbPath, sha256, phash, width, height, bytes
|
||||
FROM images
|
||||
WHERE deleted_at IS NULL AND parent_image_id IS NULL AND phash IS NOT NULL
|
||||
ORDER BY id ASC
|
||||
`).all() as Array<{
|
||||
id: number; code: string | null; filename: string; rel_path: string; thumbPath: string;
|
||||
sha256: string; phash: string; width: number; height: number; bytes: number;
|
||||
}>;
|
||||
|
||||
const pairs: NearDupePair[] = [];
|
||||
for (let i = 0; i < rows.length && pairs.length < limit; i++) {
|
||||
for (let j = i + 1; j < rows.length && pairs.length < limit; j++) {
|
||||
const a = rows[i];
|
||||
const b = rows[j];
|
||||
if (a.sha256 === b.sha256) continue; // SHA-identical pairs handled elsewhere
|
||||
const d = hammingDistance(a.phash, b.phash);
|
||||
if (d <= threshold) {
|
||||
pairs.push({
|
||||
a: { id: a.id, code: a.code, filename: a.filename, thumbPath: a.thumbPath, width: a.width, height: a.height, bytes: a.bytes },
|
||||
b: { id: b.id, code: b.code, filename: b.filename, thumbPath: b.thumbPath, width: b.width, height: b.height, bytes: b.bytes },
|
||||
distance: d,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort tightest matches first, then by lowest id pair for stability.
|
||||
pairs.sort((x, y) => x.distance - y.distance || x.a.id - y.a.id || x.b.id - y.b.id);
|
||||
return pairs;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use server";
|
||||
import { listImagesByIds } from "@/lib/db/queries";
|
||||
import type { CardImage } from "@/components/grid/ImageCard";
|
||||
|
||||
export async function fetchQueueCovers(ids: number[]): Promise<CardImage[]> {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return [];
|
||||
return listImagesByIds(ids);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { setAppSetting, type AppSettings, type WhisperJavSettings, APP_SETTINGS_DEFAULTS } from "@/lib/db/appSettings";
|
||||
|
||||
export async function setBoolSetting(
|
||||
key: "fadeTransitions" | "purgeFilesOnDelete" | "useRecycleBin",
|
||||
value: boolean,
|
||||
) {
|
||||
setAppSetting(key, value);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setTranscodeMode(value: "off" | "always" | "auto-predicate" | "auto-runtime") {
|
||||
if (value !== "off" && value !== "always" && value !== "auto-predicate" && value !== "auto-runtime") return;
|
||||
setAppSetting("transcodeMode", value);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setNumberSetting(
|
||||
key: "fadeDurationMs" | "trashRetentionDays" | "gridColumns" | "gridColumnsPortrait" | "supersededRetentionDays" | "coverPageSize",
|
||||
value: number,
|
||||
) {
|
||||
if (!Number.isFinite(value)) return;
|
||||
if (key === "gridColumns" && (value < 2 || value > 4)) return;
|
||||
if (key === "gridColumnsPortrait" && (value < 4 || value > 10)) return;
|
||||
if (key === "trashRetentionDays" && value < 0) return;
|
||||
if (key === "supersededRetentionDays" && value < 0) return;
|
||||
if (key === "coverPageSize" && (value < 25 || value > 500)) return;
|
||||
setAppSetting(key, value);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
export async function setColorSetting(
|
||||
key: "accentPrimary" | "accentSecondary",
|
||||
value: string,
|
||||
) {
|
||||
const normalized = value === "" ? "" : value.toLowerCase();
|
||||
if (normalized !== "" && !HEX_RE.test(normalized)) return;
|
||||
setAppSetting(key, normalized);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setPaginationMode(value: "url" | "scroll") {
|
||||
if (value !== "url" && value !== "scroll") return;
|
||||
setAppSetting("paginationMode", value);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setSettingsLayout(value: "sidebar" | "three-column") {
|
||||
if (value !== "sidebar" && value !== "three-column") return;
|
||||
setAppSetting("settingsLayout", value);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setVideoLibraryPath(value: string) {
|
||||
setAppSetting("videoLibraryPath", value.trim());
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setPartSuffixPatterns(values: string[]) {
|
||||
// Trim, drop blanks, preserve order. Validation of token grammar
|
||||
// (e.g. `{N}`, `{L}`) happens client-side; storage accepts whatever
|
||||
// the user typed so a malformed pattern doesn't silently disappear.
|
||||
const cleaned = (values ?? []).map((v) => (v ?? "").trim()).filter(Boolean);
|
||||
setAppSetting("partSuffixPatterns", cleaned);
|
||||
// Reclassify on the next video scan; trigger a rescan so the change
|
||||
// takes effect without a manual refresh.
|
||||
try {
|
||||
const { rescanVideoIndex } = await import("@/lib/video");
|
||||
await rescanVideoIndex();
|
||||
} catch (e) {
|
||||
console.error("[settings] failed to rescan video index after pattern change:", e);
|
||||
}
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setWhisperJavSettings(values: Partial<WhisperJavSettings>) {
|
||||
const sanitized: WhisperJavSettings = {
|
||||
...APP_SETTINGS_DEFAULTS.whisperjav,
|
||||
...values,
|
||||
cliPath: typeof values.cliPath === "string" ? values.cliPath.trim() : APP_SETTINGS_DEFAULTS.whisperjav.cliPath,
|
||||
};
|
||||
// Validate enum members so a bad client payload can't poison the row.
|
||||
const QUALITIES: WhisperJavSettings["quality"][] = ["fast", "balanced", "qwen"];
|
||||
const SOURCE_LANGS: WhisperJavSettings["sourceLanguage"][] = ["japanese", "korean", "chinese", "english"];
|
||||
const OUTPUT_MODES: WhisperJavSettings["outputMode"][] = ["native", "direct-to-english"];
|
||||
const SENSITIVITIES: WhisperJavSettings["sensitivity"][] = ["conservative", "balanced", "aggressive"];
|
||||
const LOCATIONS: WhisperJavSettings["outputLocation"][] = ["beside-video", "data-folder"];
|
||||
if (!QUALITIES.includes(sanitized.quality)) sanitized.quality = "balanced";
|
||||
if (!SOURCE_LANGS.includes(sanitized.sourceLanguage)) sanitized.sourceLanguage = "japanese";
|
||||
if (!OUTPUT_MODES.includes(sanitized.outputMode)) sanitized.outputMode = "native";
|
||||
if (!SENSITIVITIES.includes(sanitized.sensitivity)) sanitized.sensitivity = "balanced";
|
||||
if (!LOCATIONS.includes(sanitized.outputLocation)) sanitized.outputLocation = "beside-video";
|
||||
sanitized.noSignature = sanitized.noSignature !== false;
|
||||
const retention = Number(sanitized.retentionDays);
|
||||
sanitized.retentionDays = Number.isFinite(retention) && retention >= 0 ? Math.floor(retention) : 30;
|
||||
setAppSetting("whisperjav", sanitized);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setSubtitleCacheLimitMb(value: number) {
|
||||
if (!Number.isFinite(value) || value < 0) return;
|
||||
setAppSetting("subtitleCacheLimitMb", Math.floor(value));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setSubtitleExtraPaths(values: string[]) {
|
||||
const seen = new Set<string>();
|
||||
const cleaned: string[] = [];
|
||||
for (const v of values) {
|
||||
const t = (v ?? "").trim();
|
||||
if (!t) continue;
|
||||
const key = t.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
cleaned.push(t);
|
||||
}
|
||||
setAppSetting("subtitleExtraPaths", cleaned);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setVideoExtraPaths(values: string[]) {
|
||||
// Trim, drop blanks, dedupe (case-insensitive on Windows-friendly compare).
|
||||
const seen = new Set<string>();
|
||||
const cleaned: string[] = [];
|
||||
for (const v of values) {
|
||||
const t = (v ?? "").trim();
|
||||
if (!t) continue;
|
||||
const key = t.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
cleaned.push(t);
|
||||
}
|
||||
setAppSetting("videoExtraPaths", cleaned);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export type WritableBoolKey = Parameters<typeof setBoolSetting>[0];
|
||||
export type WritableNumberKey = Parameters<typeof setNumberSetting>[0];
|
||||
export type WritableColorKey = Parameters<typeof setColorSetting>[0];
|
||||
export type WritableSettings = Pick<AppSettings, WritableBoolKey | WritableNumberKey | WritableColorKey>;
|
||||
@@ -0,0 +1,22 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import { setAppSetting } from "@/lib/db/appSettings";
|
||||
import { isValidSort, SORT_COOKIE } from "@/lib/sort";
|
||||
|
||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||
|
||||
export async function setDefaultSort(sort: string) {
|
||||
if (!isValidSort(sort)) return;
|
||||
setAppSetting("defaultSort", sort);
|
||||
(await cookies()).set(SORT_COOKIE, sort, {
|
||||
path: "/",
|
||||
maxAge: ONE_YEAR,
|
||||
sameSite: "lax",
|
||||
});
|
||||
revalidatePath("/");
|
||||
revalidatePath("/collection");
|
||||
revalidatePath("/actress");
|
||||
revalidatePath("/studios");
|
||||
revalidatePath("/series");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { rawDb, uniqueSlug } from "@/lib/db/client";
|
||||
|
||||
export interface TagCategoryRow {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
const PALETTE = ["#fbbf24", "#22d3ee", "#a78bfa", "#f472b6", "#34d399", "#fb7185", "#f97316", "#60a5fa"];
|
||||
|
||||
function nextPaletteColor(): string {
|
||||
const taken = (rawDb.prepare(`SELECT color FROM tag_categories WHERE color IS NOT NULL`).all() as Array<{ color: string }>)
|
||||
.map((r) => r.color);
|
||||
for (const c of PALETTE) if (!taken.includes(c)) return c;
|
||||
return PALETTE[Math.floor(Math.random() * PALETTE.length)];
|
||||
}
|
||||
|
||||
export async function createTagCategory(name: string, color?: string, description?: string): Promise<TagCategoryRow | null> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const slug = uniqueSlug(rawDb, "tag_categories", trimmed);
|
||||
const finalColor = color?.trim() || nextPaletteColor();
|
||||
const finalDesc = description?.trim() || null;
|
||||
const row = rawDb.prepare(`
|
||||
INSERT INTO tag_categories (name, slug, color, description) VALUES (?, ?, ?, ?) RETURNING *
|
||||
`).get(trimmed, slug, finalColor, finalDesc) as TagCategoryRow;
|
||||
revalidatePath("/category");
|
||||
revalidatePath("/tag");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function createTagCategoryAction(formData: FormData) {
|
||||
const name = String(formData.get("name") ?? "");
|
||||
const color = String(formData.get("color") ?? "");
|
||||
const description = String(formData.get("description") ?? "");
|
||||
await createTagCategory(name, color || undefined, description || undefined);
|
||||
}
|
||||
|
||||
export async function renameTagCategory(id: number, name: string, color?: string, description?: string): Promise<{ slug: string; name: string } | null> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const current = rawDb.prepare(`SELECT name FROM tag_categories WHERE id = ?`).get(id) as { name: string } | undefined;
|
||||
if (!current) return null;
|
||||
const slug = uniqueSlug(rawDb, "tag_categories", trimmed, id);
|
||||
// COALESCE both color AND description: passing `undefined` (caller
|
||||
// omitted the field) preserves the existing value; passing an empty
|
||||
// string clears it. Without COALESCE on description, the prior code
|
||||
// wiped any existing description on every rename.
|
||||
const colorArg = color === undefined ? null : (color.trim() || null);
|
||||
const descArg = description === undefined ? null : (description.trim() || null);
|
||||
if (color === undefined && description === undefined) {
|
||||
rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id);
|
||||
} else if (description === undefined) {
|
||||
rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ?, color = COALESCE(?, color) WHERE id = ?`)
|
||||
.run(trimmed, slug, colorArg, id);
|
||||
} else if (color === undefined) {
|
||||
rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ?, description = ? WHERE id = ?`)
|
||||
.run(trimmed, slug, descArg, id);
|
||||
} else {
|
||||
rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ?, color = COALESCE(?, color), description = ? WHERE id = ?`)
|
||||
.run(trimmed, slug, colorArg, descArg, id);
|
||||
}
|
||||
revalidatePath("/category");
|
||||
revalidatePath("/tag");
|
||||
return { slug, name: trimmed };
|
||||
}
|
||||
|
||||
export async function deleteTagCategory(id: number) {
|
||||
// ON DELETE SET NULL on tags.category_id keeps every tag intact; they
|
||||
// simply become uncategorised again.
|
||||
rawDb.prepare(`DELETE FROM tag_categories WHERE id = ?`).run(id);
|
||||
revalidatePath("/category");
|
||||
revalidatePath("/tag");
|
||||
}
|
||||
|
||||
export async function setTagCategory(tagId: number, categoryId: number | null) {
|
||||
rawDb.prepare(`UPDATE tags SET category_id = ? WHERE id = ?`).run(categoryId, tagId);
|
||||
revalidatePath("/category");
|
||||
revalidatePath("/tag");
|
||||
}
|
||||
|
||||
export async function setTagCategoryByName(tagName: string, categoryId: number | null) {
|
||||
const tag = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(tagName.trim()) as { id: number } | undefined;
|
||||
if (!tag) return;
|
||||
await setTagCategory(tag.id, categoryId);
|
||||
revalidatePath(`/tag/${encodeURIComponent(tagName.trim())}`);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { rawDb, uniqueSlug } from "@/lib/db/client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function createTag(name: string) {
|
||||
const trimmed = name.trim().toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
const row = rawDb.prepare(`
|
||||
INSERT INTO tags (name) VALUES (?)
|
||||
ON CONFLICT(name) DO UPDATE SET name=excluded.name
|
||||
RETURNING id
|
||||
`).get(trimmed) as { id: number };
|
||||
revalidatePath("/tag");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
export async function createTagAction(formData: FormData) {
|
||||
const name = String(formData.get("name") ?? "");
|
||||
await createTag(name);
|
||||
redirect("/tag");
|
||||
}
|
||||
|
||||
export async function addTagToImage(imageId: number, name: string) {
|
||||
const trimmed = name.trim().toLowerCase();
|
||||
if (!trimmed) return;
|
||||
const tag = rawDb.prepare(`
|
||||
INSERT INTO tags (name) VALUES (?) ON CONFLICT(name) DO UPDATE SET name=excluded.name RETURNING id
|
||||
`).get(trimmed) as { id: number };
|
||||
rawDb.prepare(`INSERT OR IGNORE INTO image_tags (image_id, tag_id) VALUES (?, ?)`).run(imageId, tag.id);
|
||||
// Bump recency so this tag floats up in the context-menu Recent strip.
|
||||
rawDb.prepare(`UPDATE tags SET last_used_at = (unixepoch() * 1000) WHERE id = ?`).run(tag.id);
|
||||
revalidatePath(`/image/${imageId}`);
|
||||
revalidatePath("/tag");
|
||||
}
|
||||
|
||||
export type BulkImportRow = {
|
||||
name: string;
|
||||
category?: string | null;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
export type BulkImportResult = {
|
||||
ok: boolean;
|
||||
added: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
categoriesCreated: number;
|
||||
errors: Array<{ row: number; message: string }>;
|
||||
};
|
||||
|
||||
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
export async function bulkImportTags(
|
||||
rows: BulkImportRow[],
|
||||
opts: { createMissingCategories: boolean; updateExisting: boolean },
|
||||
): Promise<BulkImportResult> {
|
||||
const errors: Array<{ row: number; message: string }> = [];
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let categoriesCreated = 0;
|
||||
|
||||
// Cache category id lookups so we don't requery for every row that shares
|
||||
// the same category.
|
||||
const catCache = new Map<string, number>();
|
||||
function resolveCategoryId(catName: string | null | undefined): number | null {
|
||||
if (!catName) return null;
|
||||
const key = catName.trim().toLowerCase();
|
||||
if (!key) return null;
|
||||
const cached = catCache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
const existing = rawDb.prepare(`SELECT id FROM tag_categories WHERE LOWER(name) = ?`).get(key) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
catCache.set(key, existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
if (!opts.createMissingCategories) return null;
|
||||
const trimmedName = catName.trim();
|
||||
const slug = uniqueSlug(rawDb, "tag_categories", trimmedName);
|
||||
const ins = rawDb.prepare(`INSERT INTO tag_categories (name, slug) VALUES (?, ?) RETURNING id`).get(trimmedName, slug) as { id: number };
|
||||
categoriesCreated++;
|
||||
catCache.set(key, ins.id);
|
||||
return ins.id;
|
||||
}
|
||||
|
||||
const tx = rawDb.transaction(() => {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
const name = (r.name ?? "").trim().toLowerCase();
|
||||
if (!name) {
|
||||
errors.push({ row: i + 1, message: "blank name" });
|
||||
continue;
|
||||
}
|
||||
if (name.length > 48) {
|
||||
errors.push({ row: i + 1, message: `name too long (${name.length} > 48)` });
|
||||
continue;
|
||||
}
|
||||
const color = r.color?.trim() || null;
|
||||
if (color && !COLOR_RE.test(color)) {
|
||||
errors.push({ row: i + 1, message: `invalid color "${color}" — expected #rrggbb` });
|
||||
continue;
|
||||
}
|
||||
const categoryId = resolveCategoryId(r.category);
|
||||
|
||||
const existing = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(name) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
if (opts.updateExisting && (color || categoryId !== null)) {
|
||||
rawDb.prepare(`UPDATE tags SET color = COALESCE(?, color), category_id = COALESCE(?, category_id) WHERE id = ?`)
|
||||
.run(color, categoryId, existing.id);
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
rawDb.prepare(`INSERT INTO tags (name, color, category_id) VALUES (?, ?, ?)`).run(name, color, categoryId);
|
||||
added++;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
tx();
|
||||
} catch (e) {
|
||||
return { ok: false, added: 0, updated: 0, skipped: 0, categoriesCreated: 0, errors: [{ row: 0, message: (e as Error).message }] };
|
||||
}
|
||||
|
||||
revalidatePath("/tag");
|
||||
revalidatePath("/category");
|
||||
return { ok: true, added, updated, skipped, categoriesCreated, errors };
|
||||
}
|
||||
|
||||
export async function bulkDeleteTags(ids: number[]): Promise<{ deleted: number }> {
|
||||
if (!ids || ids.length === 0) return { deleted: 0 };
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
// image_tags has ON DELETE CASCADE on tag_id, so removing tag rows
|
||||
// also drops every image association.
|
||||
const info = rawDb.prepare(`DELETE FROM tags WHERE id IN (${placeholders})`).run(...ids);
|
||||
revalidatePath("/tag");
|
||||
revalidatePath("/category");
|
||||
return { deleted: info.changes };
|
||||
}
|
||||
|
||||
export async function removeTagFromImage(imageId: number, tagId: number) {
|
||||
rawDb.prepare(`DELETE FROM image_tags WHERE image_id = ? AND tag_id = ?`).run(imageId, tagId);
|
||||
revalidatePath(`/image/${imageId}`);
|
||||
revalidatePath("/tag");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { rawDb } from "@/lib/db/client";
|
||||
import { getAppSetting } from "@/lib/db/appSettings";
|
||||
import { safeJoin } from "@/lib/safePath";
|
||||
|
||||
const LIBRARY_ROOT = path.join(process.cwd(), "library");
|
||||
const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs");
|
||||
|
||||
export async function restoreImages(ids: number[]): Promise<{ restored: number }> {
|
||||
if (ids.length === 0) return { restored: 0 };
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const r = rawDb.prepare(
|
||||
`UPDATE images SET deleted_at = NULL WHERE id IN (${placeholders}) AND deleted_at IS NOT NULL`,
|
||||
).run(...ids);
|
||||
revalidate();
|
||||
return { restored: r.changes };
|
||||
}
|
||||
|
||||
export async function purgeFromTrash(ids: number[]): Promise<{ purged: number }> {
|
||||
if (ids.length === 0) return { purged: 0 };
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const rows = rawDb.prepare(
|
||||
`
|
||||
WITH targets AS (
|
||||
SELECT id FROM images WHERE deleted_at IS NOT NULL AND id IN (${placeholders})
|
||||
)
|
||||
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(...ids) as Array<{ id: number; rel_path: string; thumb_path: string }>;
|
||||
if (rows.length === 0) return { purged: 0 };
|
||||
if (getAppSetting("purgeFilesOnDelete")) {
|
||||
await Promise.all(rows.flatMap((r) => {
|
||||
const fileAbs = safeJoin(LIBRARY_ROOT, r.rel_path);
|
||||
const thumbAbs = safeJoin(THUMB_ROOT, r.thumb_path);
|
||||
return [
|
||||
fileAbs ? fs.rm(fileAbs, { force: true }) : null,
|
||||
thumbAbs ? fs.rm(thumbAbs, { force: true }) : null,
|
||||
].filter((p): p is Promise<void> => !!p);
|
||||
}));
|
||||
}
|
||||
rawDb.prepare(`DELETE FROM images WHERE id IN (${rows.map(() => "?").join(",")})`).run(...rows.map((r) => r.id));
|
||||
revalidate();
|
||||
return { purged: rows.length };
|
||||
}
|
||||
|
||||
export async function emptyTrash(): Promise<{ purged: number }> {
|
||||
const ids = (rawDb.prepare(`SELECT id FROM images WHERE deleted_at IS NOT NULL`).all() as Array<{ id: number }>).map((r) => r.id);
|
||||
return purgeFromTrash(ids);
|
||||
}
|
||||
|
||||
function revalidate() {
|
||||
revalidatePath("/");
|
||||
revalidatePath("/collection");
|
||||
revalidatePath("/tag");
|
||||
revalidatePath("/actress");
|
||||
revalidatePath("/studios");
|
||||
revalidatePath("/series");
|
||||
revalidatePath("/genres");
|
||||
}
|
||||
Reference in New Issue
Block a user