Files
2026-05-26 22:46:00 +02:00

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;
}