"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 { 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 { 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 { rawDb.prepare(`UPDATE images SET watched = ? WHERE id = ?`).run(watched ? 1 : 0, imageId); revalidateAllCoverIndexes(imageId); } export async function setCoverVip(imageId: number, vip: boolean): Promise { // 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 { 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 { 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 { 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(); 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; }