390 lines
16 KiB
TypeScript
390 lines
16 KiB
TypeScript
import "server-only";
|
||
import path from "node:path";
|
||
import fs from "node:fs/promises";
|
||
import crypto from "node:crypto";
|
||
import sharp from "sharp";
|
||
import { db, rawDb } from "@/lib/db/client";
|
||
import { images } from "@/lib/db/schema";
|
||
import { eq } from "drizzle-orm";
|
||
import { sanitizeFilename, uniqueFilePath, letterBucket, canonicalThumbName } from "@/lib/filename";
|
||
import { extractCode, normalizeCode } from "@/lib/jav/codeParser";
|
||
import { computeDHash } from "@/lib/jav/phash";
|
||
import { parseNfo, type NfoMetadata } from "@/lib/jav/nfoParser";
|
||
import { upsertStudio, upsertSeries, upsertActress, upsertGenre } from "@/lib/jav/upsert";
|
||
|
||
const LIBRARY_ROOT = path.join(process.cwd(), "library");
|
||
const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs");
|
||
const SUPERSEDED_ROOT = path.join(process.cwd(), "library", ".superseded");
|
||
|
||
export type CollisionBucket = "upgrade" | "downgrade" | "sidegrade" | "mixed";
|
||
|
||
export interface CollisionInfo {
|
||
existingId: number;
|
||
existingFilename: string;
|
||
existingWidth: number;
|
||
existingHeight: number;
|
||
existingBytes: number;
|
||
existingThumbPath: string;
|
||
incomingWidth: number;
|
||
incomingHeight: number;
|
||
incomingBytes: number;
|
||
bucket: CollisionBucket;
|
||
}
|
||
|
||
export interface IngestResult {
|
||
imageId: number;
|
||
duplicate: boolean;
|
||
filename: string;
|
||
code: string | null;
|
||
/** Present when the upload was deferred because a row with the same
|
||
* canonical code already exists. The caller must re-invoke ingest with
|
||
* resolution: "replace" or "skip". When "skip", the staged file has
|
||
* already been cleaned up and no DB write happens. */
|
||
collision?: CollisionInfo;
|
||
}
|
||
|
||
function classifyCollision(
|
||
oldW: number, oldH: number, oldBytes: number,
|
||
newW: number, newH: number, newBytes: number,
|
||
): CollisionBucket {
|
||
const oldPx = oldW * oldH;
|
||
const newPx = newW * newH;
|
||
// Upgrade: incoming has ≥1.5× pixel area.
|
||
if (newPx >= oldPx * 1.5) return "upgrade";
|
||
// Downgrade: incoming smaller in both dims AND bytes.
|
||
if (newW <= oldW && newH <= oldH && newBytes <= oldBytes && (newW < oldW || newH < oldH || newBytes < oldBytes)) {
|
||
return "downgrade";
|
||
}
|
||
// Sidegrade: dims within ±2px and bytes within ±15%.
|
||
const dimsClose = Math.abs(newW - oldW) <= 2 && Math.abs(newH - oldH) <= 2;
|
||
const bytesClose = Math.abs(newBytes - oldBytes) <= oldBytes * 0.15;
|
||
if (dimsClose && bytesClose) return "sidegrade";
|
||
return "mixed";
|
||
}
|
||
|
||
export async function ingestFile(
|
||
buffer: Buffer,
|
||
originalFilename: string,
|
||
opts?: {
|
||
/** Optional .nfo XML payload to seed metadata from. */
|
||
nfoXml?: string;
|
||
autoAssign?: { tagName?: string; collectionId?: number };
|
||
/** When set, the new image is attached as an extra (back cover / still) of this parent. */
|
||
parentImageId?: number;
|
||
/** Override filename to store on disk and in the DB (e.g. "DDK-134.jpg"). */
|
||
targetFilename?: string;
|
||
/** Explicit actress names to attach (link existing or create-new). */
|
||
actressNames?: string[];
|
||
/** When set, controls how a same-code collision (different SHA) is
|
||
* resolved. "detect" (default) returns a collision result without
|
||
* writing. "replace" overwrites the existing row's bytes/sha/dims
|
||
* in place, preserving relational state. "skip" returns the existing
|
||
* row unchanged. */
|
||
onCollision?: "detect" | "replace" | "skip";
|
||
},
|
||
): Promise<IngestResult> {
|
||
const sha = crypto.createHash("sha256").update(buffer).digest("hex");
|
||
|
||
const existing = db.select().from(images).where(eq(images.sha256, sha)).get();
|
||
if (existing) {
|
||
// If we're re-uploading an existing attachment, re-bind that attachment to
|
||
// the requested parent. Do not turn an existing cover into its own child.
|
||
if (opts?.parentImageId != null) {
|
||
if (existing.parentImageId != null && existing.id !== opts.parentImageId) {
|
||
rawDb.prepare(`
|
||
UPDATE images
|
||
SET parent_image_id = ?, deleted_at = NULL
|
||
WHERE id = ?
|
||
`).run(opts.parentImageId, existing.id);
|
||
}
|
||
} else if (existing.deletedAt != null) {
|
||
// Plain re-upload of a soft-deleted cover: revive it.
|
||
rawDb.prepare(`UPDATE images SET deleted_at = NULL WHERE id = ?`).run(existing.id);
|
||
}
|
||
// Re-uploads can carry fresh actress decisions from the preview
|
||
// dialog. Merge them into the existing row's links so duplicates
|
||
// aren't a dead end for metadata. INSERT OR IGNORE keeps already-
|
||
// linked actresses as no-ops; only attach to top-level covers.
|
||
if (opts?.actressNames?.length && existing.parentImageId == null) {
|
||
for (const name of opts.actressNames) {
|
||
const trimmed = name.trim();
|
||
if (!trimmed) continue;
|
||
const id = upsertActress(trimmed);
|
||
rawDb.prepare(`INSERT OR IGNORE INTO image_actresses (image_id, actress_id) VALUES (?, ?)`).run(existing.id, id);
|
||
}
|
||
}
|
||
if (opts?.autoAssign) applyAutoAssign(existing.id, opts.autoAssign);
|
||
return { imageId: existing.id, duplicate: true, filename: existing.filename, code: existing.code };
|
||
}
|
||
|
||
const filenameForStorage = opts?.targetFilename?.trim() || originalFilename;
|
||
const { base, ext } = sanitizeFilename(filenameForStorage);
|
||
|
||
// Resolve metadata BEFORE choosing the bucket: the on-disk partition is
|
||
// keyed off the cover's first letter, so we need the code (or the
|
||
// parent's code, for attached images) up front.
|
||
const isAttached = opts?.parentImageId != null;
|
||
if (isAttached) {
|
||
const parent = rawDb.prepare(`
|
||
SELECT id FROM images
|
||
WHERE id = ? AND deleted_at IS NULL AND parent_image_id IS NULL
|
||
`).get(opts.parentImageId) as { id: number } | undefined;
|
||
if (!parent) throw new Error("Attachment parent not found");
|
||
}
|
||
const nfo = opts?.nfoXml ? parseNfo(opts.nfoXml) : null;
|
||
const code = isAttached
|
||
? null
|
||
: (normalizeCode(nfo?.code) ?? extractCode(filenameForStorage) ?? extractCode(originalFilename));
|
||
let bucketCode: string | null = code;
|
||
if (isAttached) {
|
||
const parentRow = rawDb.prepare(`SELECT code FROM images WHERE id = ?`).get(opts.parentImageId) as
|
||
| { code: string | null }
|
||
| undefined;
|
||
bucketCode = parentRow?.code ?? null;
|
||
}
|
||
|
||
const dirRel = letterBucket(bucketCode).dirRel;
|
||
const dirAbs = path.join(LIBRARY_ROOT, dirRel);
|
||
await fs.mkdir(dirAbs, { recursive: true });
|
||
const fileAbs = await uniqueFilePath(dirAbs, base, ext);
|
||
const fileRel = path.posix.join(dirRel, path.basename(fileAbs));
|
||
await fs.mkdir(THUMB_ROOT, { recursive: true });
|
||
// Use the bucket code (which already accounts for attached → parent's
|
||
// code) as the prefix so attached thumbs sort with their cover.
|
||
const thumbName = canonicalThumbName(isAttached ? bucketCode : code, sha);
|
||
const thumbAbs = path.join(THUMB_ROOT, thumbName);
|
||
|
||
// If thumb generation or metadata extraction fails, clean up the source
|
||
// file we just wrote — otherwise it's an orphan in library/ with no DB row.
|
||
let width = 0;
|
||
let height = 0;
|
||
let phash: string | null = null;
|
||
try {
|
||
await fs.writeFile(fileAbs, buffer);
|
||
const meta = await sharp(buffer, { failOn: "none" }).metadata();
|
||
width = meta.width ?? 0;
|
||
height = meta.height ?? 0;
|
||
await sharp(buffer, { failOn: "none" })
|
||
.rotate()
|
||
.resize({ width: 768, height: 768, fit: "inside", withoutEnlargement: true })
|
||
.webp({ quality: 82 })
|
||
.toFile(thumbAbs);
|
||
// Perceptual hash for near-duplicate detection. Failure here is
|
||
// non-fatal — we just leave phash null and the maintenance scanner
|
||
// can backfill later.
|
||
try { phash = await computeDHash(buffer); } catch { phash = null; }
|
||
} catch (e) {
|
||
await fs.rm(fileAbs, { force: true }).catch(() => {});
|
||
await fs.rm(thumbAbs, { force: true }).catch(() => {});
|
||
throw e;
|
||
}
|
||
|
||
// Collision detection: a primary cover (no parent, not soft-deleted) with
|
||
// the same canonical code already exists. We've already missed SHA dedup,
|
||
// so this is a different encode of the same release.
|
||
if (!isAttached && code) {
|
||
const collision = rawDb.prepare(`
|
||
SELECT id, filename, rel_path, thumb_path, width, height, bytes
|
||
FROM images
|
||
WHERE code = ? AND parent_image_id IS NULL AND deleted_at IS NULL
|
||
ORDER BY id LIMIT 1
|
||
`).get(code) as
|
||
| { id: number; filename: string; rel_path: string; thumb_path: string; width: number; height: number; bytes: number }
|
||
| undefined;
|
||
|
||
if (collision) {
|
||
const mode = opts?.onCollision ?? "detect";
|
||
|
||
if (mode === "skip") {
|
||
await fs.rm(fileAbs, { force: true }).catch(() => {});
|
||
await fs.rm(thumbAbs, { force: true }).catch(() => {});
|
||
return { imageId: collision.id, duplicate: true, filename: collision.filename, code };
|
||
}
|
||
|
||
if (mode === "replace") {
|
||
// Move the old file + thumb to .superseded/ for recovery, then
|
||
// update the existing row in place. All relational state
|
||
// (actresses, tags, collections, rating, watched, notes) is
|
||
// preserved because we keep the same row id.
|
||
await fs.mkdir(SUPERSEDED_ROOT, { recursive: true });
|
||
const stamp = Date.now();
|
||
const oldExt = path.extname(collision.rel_path) || ".bin";
|
||
const supersededFile = path.join(SUPERSEDED_ROOT, `${collision.id}-${stamp}${oldExt}`);
|
||
const supersededThumb = path.join(SUPERSEDED_ROOT, `${collision.id}-${stamp}.thumb.webp`);
|
||
try {
|
||
await fs.rename(path.join(LIBRARY_ROOT, collision.rel_path), supersededFile).catch(() => {});
|
||
await fs.rename(path.join(THUMB_ROOT, collision.thumb_path), supersededThumb).catch(() => {});
|
||
} catch {
|
||
// Best-effort recovery copy; proceed even if the old files
|
||
// were already missing on disk.
|
||
}
|
||
|
||
const update = rawDb.transaction(() => {
|
||
rawDb.prepare(`
|
||
UPDATE images SET
|
||
filename = ?, rel_path = ?, thumb_path = ?, sha256 = ?,
|
||
width = ?, height = ?, bytes = ?, phash = ?
|
||
WHERE id = ?
|
||
`).run(filenameForStorage, fileRel, thumbName, sha, width, height, buffer.length, phash, collision.id);
|
||
});
|
||
try {
|
||
update();
|
||
} catch (e) {
|
||
// Restore on failure (e.g. UNIQUE(sha256) clash with an unrelated row).
|
||
await fs.rename(supersededFile, path.join(LIBRARY_ROOT, collision.rel_path)).catch(() => {});
|
||
await fs.rename(supersededThumb, path.join(THUMB_ROOT, collision.thumb_path)).catch(() => {});
|
||
await fs.rm(fileAbs, { force: true }).catch(() => {});
|
||
await fs.rm(thumbAbs, { force: true }).catch(() => {});
|
||
throw e;
|
||
}
|
||
|
||
// Replace upgrades bytes but should also merge any fresh actress
|
||
// decisions the user made — same semantics as the dedup branch
|
||
// up top. Existing actress links are preserved; INSERT OR IGNORE
|
||
// only adds new ones.
|
||
if (opts?.actressNames?.length) {
|
||
for (const name of opts.actressNames) {
|
||
const trimmed = name.trim();
|
||
if (!trimmed) continue;
|
||
const id = upsertActress(trimmed);
|
||
rawDb.prepare(`INSERT OR IGNORE INTO image_actresses (image_id, actress_id) VALUES (?, ?)`).run(collision.id, id);
|
||
}
|
||
}
|
||
if (opts?.autoAssign) applyAutoAssign(collision.id, opts.autoAssign);
|
||
return { imageId: collision.id, duplicate: false, filename: filenameForStorage, code };
|
||
}
|
||
|
||
// mode === "detect": back out the staged files, return collision
|
||
// info, and let the caller decide. No DB write.
|
||
await fs.rm(fileAbs, { force: true }).catch(() => {});
|
||
await fs.rm(thumbAbs, { force: true }).catch(() => {});
|
||
return {
|
||
imageId: collision.id,
|
||
duplicate: false,
|
||
filename: filenameForStorage,
|
||
code,
|
||
collision: {
|
||
existingId: collision.id,
|
||
existingFilename: collision.filename,
|
||
existingWidth: collision.width,
|
||
existingHeight: collision.height,
|
||
existingBytes: collision.bytes,
|
||
existingThumbPath: collision.thumb_path,
|
||
incomingWidth: width,
|
||
incomingHeight: height,
|
||
incomingBytes: buffer.length,
|
||
bucket: classifyCollision(
|
||
collision.width, collision.height, collision.bytes,
|
||
width, height, buffer.length,
|
||
),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
const studioId = !isAttached && nfo?.studio ? upsertStudio(nfo.studio) : null;
|
||
const seriesId = !isAttached && nfo?.series ? upsertSeries(nfo.series) : null;
|
||
|
||
const insert = rawDb.transaction(() => {
|
||
const result = rawDb.prepare(`
|
||
INSERT INTO images (
|
||
filename, rel_path, thumb_path, sha256, width, height, bytes,
|
||
parent_image_id, code, title, release_date, runtime_min, director,
|
||
studio_id, series_id, notes, phash
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`).run(
|
||
filenameForStorage,
|
||
fileRel,
|
||
thumbName,
|
||
sha,
|
||
width,
|
||
height,
|
||
buffer.length,
|
||
opts?.parentImageId ?? null,
|
||
code,
|
||
isAttached ? null : (nfo?.title ?? null),
|
||
isAttached ? null : (nfo?.releaseDate ?? null),
|
||
isAttached ? null : (nfo?.runtimeMin ?? null),
|
||
isAttached ? null : (nfo?.director ?? null),
|
||
studioId,
|
||
seriesId,
|
||
isAttached ? null : (nfo?.notes ?? null),
|
||
phash,
|
||
);
|
||
const imageId = Number(result.lastInsertRowid);
|
||
|
||
if (!isAttached && nfo) {
|
||
attachNfoChildren(imageId, nfo);
|
||
}
|
||
if (!isAttached && opts?.actressNames && opts.actressNames.length > 0) {
|
||
for (const name of opts.actressNames) {
|
||
const trimmed = name.trim();
|
||
if (!trimmed) continue;
|
||
const id = upsertActress(trimmed);
|
||
rawDb.prepare(`INSERT OR IGNORE INTO image_actresses (image_id, actress_id) VALUES (?, ?)`).run(imageId, id);
|
||
}
|
||
}
|
||
return imageId;
|
||
});
|
||
|
||
let imageId: number;
|
||
try {
|
||
imageId = insert();
|
||
} catch (e) {
|
||
// Concurrent uploads of the same file can race past the dedup check
|
||
// above; the UNIQUE(sha256) / UNIQUE(rel_path) constraints will catch
|
||
// the loser. Treat as duplicate and clean up the file we just wrote.
|
||
const msg = (e as Error).message ?? "";
|
||
if (/UNIQUE constraint failed/i.test(msg)) {
|
||
await fs.rm(fileAbs, { force: true }).catch(() => {});
|
||
await fs.rm(thumbAbs, { force: true }).catch(() => {});
|
||
const winner = db.select().from(images).where(eq(images.sha256, sha)).get();
|
||
if (winner) {
|
||
if (opts?.autoAssign) applyAutoAssign(winner.id, opts.autoAssign);
|
||
return { imageId: winner.id, duplicate: true, filename: winner.filename, code: winner.code };
|
||
}
|
||
}
|
||
throw e;
|
||
}
|
||
if (opts?.autoAssign) applyAutoAssign(imageId, opts.autoAssign);
|
||
return { imageId, duplicate: false, filename: filenameForStorage, code };
|
||
}
|
||
|
||
function attachNfoChildren(imageId: number, nfo: NfoMetadata) {
|
||
if (nfo.actresses) {
|
||
for (const name of nfo.actresses) {
|
||
const id = upsertActress(name);
|
||
rawDb.prepare(`INSERT OR IGNORE INTO image_actresses (image_id, actress_id) VALUES (?, ?)`).run(imageId, id);
|
||
}
|
||
}
|
||
if (nfo.genres) {
|
||
for (const name of nfo.genres) {
|
||
const id = upsertGenre(name);
|
||
rawDb.prepare(`INSERT OR IGNORE INTO image_genres (image_id, genre_id) VALUES (?, ?)`).run(imageId, id);
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyAutoAssign(imageId: number, opts: { tagName?: string; collectionId?: number }) {
|
||
if (opts.tagName) {
|
||
const trimmed = opts.tagName.trim().toLowerCase();
|
||
if (trimmed) {
|
||
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);
|
||
}
|
||
}
|
||
if (opts.collectionId != null) {
|
||
const collectionId = opts.collectionId;
|
||
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);
|
||
});
|
||
tx();
|
||
}
|
||
}
|