Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user