266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
"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;
|
|
}
|