commit 7e2c2ff89c00f0ec81d38dc9811ff87f0c2c6a27 Author: admin Date: Tue May 26 22:46:00 2026 +0200 Initial commit diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..40b673c --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"4e3e606d-0a01-443a-a148-f45c4201f93c","pid":40700,"acquiredAt":1777427820346} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ccc7171 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +node_modules/ +.next/ +out/ +dist/ +*.log +.DS_Store +.env*.local +data/library.db* +data/thumbs/ +data/category-covers/ +data/collection-covers/ +data/portraits/ +data/subtitle-cache/ +data/whisperjav-jobs/ +library/ +tsconfig.tsbuildinfo +.idea/ +.vscode/ +.vercel +next-env.d.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..6c59086 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +enable-pre-post-scripts=true diff --git a/actress-rating-mockup.html b/actress-rating-mockup.html new file mode 100644 index 0000000..6ff591c --- /dev/null +++ b/actress-rating-mockup.html @@ -0,0 +1,599 @@ + + + + +Pinkudex — Actress rating banner mockup + + + + +
+

Actress rating banner — visual mockup

+
+ Click the stars in the editor to apply a rating to all cards. Use the controls to swap color / position / style. +
+ +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+
+ +
+ + +
+
+
+ +
+ + + + + +
+
+
+ +

Actress grid · current view

+
+ +
+

Meta editor — set ratings

+
+
+ +

Detail page hero

+
+
+
+
+
+
+
+
+

Vina Sky

+
+ Age 27 · 20 covers · 5 categories +
+
+
EthnicityAsian
+
CountryUSA
+
Eye colorBrown
+
Hair colorBlack
+
First seen2018
+
ActiveYes
+
+
+
+ +

Position trade-off · with category badges

+
+ The clash. The actress card already renders category chips at top-2 left-2 z-10 (see ActressCard.tsx:182). + Top-left ribbon overlaps them. Top-right is conflict-free. +
+
+
+
✗ Top-left collides with category chips
+
+
+
+ ★ Top + ◆ VIP +
+
+
+
+
+
+
+
Xoey Li
+
4
+
+
+
+
+
✓ Top-right keeps both visible
+
+
+
+ ★ Top + ◆ VIP +
+
+
+
+
+
+
+
Xoey Li
+
4
+
+
+
+
+ +
+ + + + + diff --git a/app/actions/actressCategories.ts b/app/actions/actressCategories.ts new file mode 100644 index 0000000..39e82b8 --- /dev/null +++ b/app/actions/actressCategories.ts @@ -0,0 +1,108 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { rawDb, uniqueSlug } from "@/lib/db/client"; + +const EXCLUSIVE_GROUPS: string[][] = [["favorite", "vip"]]; + +function getExclusivePeers(categoryId: number): number[] { + const cat = rawDb.prepare(`SELECT slug FROM actress_categories WHERE id = ?`).get(categoryId) as { slug: string } | undefined; + if (!cat) return []; + const group = EXCLUSIVE_GROUPS.find((g) => g.includes(cat.slug)); + if (!group) return []; + const peers = group.filter((s) => s !== cat.slug); + if (peers.length === 0) return []; + const placeholders = peers.map(() => "?").join(","); + const rows = rawDb.prepare(`SELECT id FROM actress_categories WHERE slug IN (${placeholders})`).all(...peers) as Array<{ id: number }>; + return rows.map((r) => r.id); +} + +export async function toggleActressCategory(actressId: number, categoryId: number) { + const exists = rawDb.prepare(` + SELECT 1 FROM actress_categories_map WHERE actress_id = ? AND category_id = ? + `).get(actressId, categoryId); + if (exists) { + rawDb.prepare(`DELETE FROM actress_categories_map WHERE actress_id = ? AND category_id = ?`).run(actressId, categoryId); + } else { + // Adding: also remove any peer category in an exclusive group. + const peers = getExclusivePeers(categoryId); + const tx = rawDb.transaction(() => { + if (peers.length > 0) { + const placeholders = peers.map(() => "?").join(","); + rawDb.prepare(` + DELETE FROM actress_categories_map + WHERE actress_id = ? AND category_id IN (${placeholders}) + `).run(actressId, ...peers); + } + rawDb.prepare(`INSERT INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`).run(actressId, categoryId); + }); + tx(); + } + const a = rawDb.prepare(`SELECT slug FROM actresses WHERE id = ?`).get(actressId) as { slug: string } | undefined; + revalidatePath("/actress"); + if (a) revalidatePath(`/actress/${a.slug}`); + return { added: !exists }; +} + +export async function setActressCategories(actressId: number, categoryIds: number[]) { + const tx = rawDb.transaction(() => { + rawDb.prepare(`DELETE FROM actress_categories_map WHERE actress_id = ?`).run(actressId); + const ins = rawDb.prepare(`INSERT INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`); + for (const id of categoryIds) ins.run(actressId, id); + }); + tx(); + const a = rawDb.prepare(`SELECT slug FROM actresses WHERE id = ?`).get(actressId) as { slug: string } | undefined; + revalidatePath("/actress"); + if (a) revalidatePath(`/actress/${a.slug}`); +} + +export async function createActressCategory(input: { name: string; color?: string | null; icon?: string | null; priority?: number }) { + const trimmed = input.name.trim(); + if (!trimmed) return null; + const existing = rawDb.prepare(`SELECT id, slug FROM actress_categories WHERE name = ?`).get(trimmed) as { id: number; slug: string } | undefined; + if (existing) return existing; + const slug = uniqueSlug(rawDb, "actress_categories", trimmed); + const row = rawDb.prepare(` + INSERT INTO actress_categories (name, slug, color, icon, priority, builtin) + VALUES (?, ?, ?, ?, ?, 0) RETURNING id + `).get(trimmed, slug, input.color ?? null, input.icon ?? null, input.priority ?? 50) as { id: number }; + revalidatePath("/actress"); + return { id: row.id, slug }; +} + +export async function bulkAddCategory(actressIds: number[], categoryId: number) { + if (actressIds.length === 0) return; + const peers = getExclusivePeers(categoryId); + const ins = rawDb.prepare(`INSERT OR IGNORE INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`); + const tx = rawDb.transaction(() => { + if (peers.length > 0) { + const peerPh = peers.map(() => "?").join(","); + const idPh = actressIds.map(() => "?").join(","); + rawDb.prepare(` + DELETE FROM actress_categories_map + WHERE actress_id IN (${idPh}) AND category_id IN (${peerPh}) + `).run(...actressIds, ...peers); + } + for (const id of actressIds) ins.run(id, categoryId); + }); + tx(); + revalidatePath("/actress"); +} + +export async function bulkRemoveCategory(actressIds: number[], categoryId: number) { + if (actressIds.length === 0) return; + const placeholders = actressIds.map(() => "?").join(","); + rawDb.prepare(` + DELETE FROM actress_categories_map + WHERE category_id = ? AND actress_id IN (${placeholders}) + `).run(categoryId, ...actressIds); + revalidatePath("/actress"); +} + +export async function deleteActressCategory(categoryId: number) { + const row = rawDb.prepare(`SELECT builtin FROM actress_categories WHERE id = ?`).get(categoryId) as { builtin: number } | undefined; + if (!row) return { ok: false, reason: "not found" }; + if (row.builtin) return { ok: false, reason: "built-in category cannot be deleted" }; + rawDb.prepare(`DELETE FROM actress_categories WHERE id = ?`).run(categoryId); + revalidatePath("/actress"); + return { ok: true }; +} diff --git a/app/actions/actressCategoriesQuery.ts b/app/actions/actressCategoriesQuery.ts new file mode 100644 index 0000000..ac8fdf4 --- /dev/null +++ b/app/actions/actressCategoriesQuery.ts @@ -0,0 +1,7 @@ +"use server"; +import { listActressCategories } from "@/lib/db/queries"; +import type { ActressCategory } from "@/lib/db/queries"; + +export async function listActressCategoriesAction(): Promise { + return listActressCategories(); +} diff --git a/app/actions/actressImport.ts b/app/actions/actressImport.ts new file mode 100644 index 0000000..3f24aa3 --- /dev/null +++ b/app/actions/actressImport.ts @@ -0,0 +1,162 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { rawDb, uniqueSlug } from "@/lib/db/client"; + +const PALETTE = ["#fbbf24", "#22d3ee", "#a78bfa", "#f472b6", "#34d399", "#fb7185", "#f97316", "#60a5fa"]; + +export interface ImportPreviewLine { + raw: string; + name: string; + altNames: string | null; + categories: string[]; + status: "new" | "exists" | "blank" | "error"; + reason?: string; +} + +export interface ImportResult { + lines: ImportPreviewLine[]; + added: number; + skipped: number; + errors: number; + newCategories: string[]; +} + +function parseLines(text: string): Array<{ raw: string; name: string; altNames: string | null; categories: string[] }> { + const out: Array<{ raw: string; name: string; altNames: string | null; categories: string[] }> = []; + for (const raw of text.split(/\r?\n/)) { + const trimmed = raw.trim(); + if (!trimmed) { + out.push({ raw, name: "", altNames: null, categories: [] }); + continue; + } + const parts = trimmed.split("|").map((s) => s.trim()); + const [name, alt, cats] = [parts[0] ?? "", parts[1] ?? "", parts[2] ?? ""]; + const categories = cats ? cats.split(/[,、,]/).map((s) => s.trim()).filter(Boolean) : []; + out.push({ raw: trimmed, name, altNames: alt || null, categories }); + } + return out; +} + +/** + * Dry run — parse + classify lines without writing. Drives the preview UI. + */ +export async function previewActressImport(text: string): Promise { + const parsed = parseLines(text); + const lines: ImportPreviewLine[] = []; + const existingCats = new Set( + (rawDb.prepare(`SELECT name FROM actress_categories`).all() as Array<{ name: string }>) + .map((r) => r.name.toLowerCase()), + ); + // Dedupe new-category collection by lowercased key to match the + // commit path, which keys its catCache by toLowerCase(). Otherwise + // "Action" and "ACTION" in the input would be reported as 2 new + // categories in the preview but commit would create only 1. + const newCategoriesByKey = new Map(); + // Track within-input new actresses too: a name appearing twice should + // be counted once in the preview, matching the commit (which inserts + // the first and rejects/sees the second as existing). + const seenNewNames = new Set(); + let added = 0, skipped = 0, errors = 0; + + for (const p of parsed) { + if (!p.name) { + lines.push({ ...p, status: "blank" }); + continue; + } + const existing = rawDb.prepare(`SELECT id FROM actresses WHERE name = ? COLLATE NOCASE`).get(p.name) as { id: number } | undefined; + const nameKey = p.name.toLowerCase(); + if (existing || seenNewNames.has(nameKey)) { + lines.push({ ...p, status: "exists" }); + skipped++; + continue; + } + for (const c of p.categories) { + const key = c.toLowerCase(); + if (!existingCats.has(key) && !newCategoriesByKey.has(key)) { + newCategoriesByKey.set(key, c); + } + } + seenNewNames.add(nameKey); + lines.push({ ...p, status: "new" }); + added++; + } + + return { lines, added, skipped, errors, newCategories: Array.from(newCategoriesByKey.values()) }; +} + +/** + * Commit — actually write. Auto-creates unknown categories with palette colors. + */ +export async function commitActressImport(text: string, defaultCategoryIds: number[] = []): Promise { + const parsed = parseLines(text); + const lines: ImportPreviewLine[] = []; + let added = 0, skipped = 0, errors = 0; + const newCategoriesSet = new Set(); + + // Cache existing categories by lowercased name → id. + const catCache = new Map(); + for (const row of rawDb.prepare(`SELECT id, name FROM actress_categories`).all() as Array<{ id: number; name: string }>) { + catCache.set(row.name.toLowerCase(), row.id); + } + // Track NEW categories with their own counter so palette colors are + // assigned in import order — using catCache.size meant the first new + // category's color depended on the existing-category count and often + // collided with an existing category's chosen color. + let newCategoryCount = 0; + + // Helper to get or create a category. + function getOrCreateCategory(name: string): number { + const key = name.toLowerCase(); + const hit = catCache.get(key); + if (hit != null) return hit; + const slug = uniqueSlug(rawDb, "actress_categories", name); + const color = PALETTE[newCategoryCount++ % PALETTE.length]; + const row = rawDb.prepare(` + INSERT INTO actress_categories (name, slug, color, icon, priority, builtin) + VALUES (?, ?, ?, 'tag', 50, 0) RETURNING id + `).get(name, slug, color) as { id: number }; + catCache.set(key, row.id); + newCategoriesSet.add(name); + return row.id; + } + + const tx = rawDb.transaction(() => { + for (const p of parsed) { + if (!p.name) { + lines.push({ ...p, status: "blank" }); + continue; + } + const existing = rawDb.prepare(`SELECT id FROM actresses WHERE name = ? COLLATE NOCASE`).get(p.name) as { id: number } | undefined; + if (existing) { + lines.push({ ...p, status: "exists" }); + skipped++; + continue; + } + try { + const slug = uniqueSlug(rawDb, "actresses", p.name); + const row = rawDb.prepare(` + INSERT INTO actresses (name, slug, alt_names) VALUES (?, ?, ?) RETURNING id + `).get(p.name, slug, p.altNames) as { id: number }; + + const assignedCatIds = new Set(); + for (const cat of p.categories) { + const catId = getOrCreateCategory(cat); + assignedCatIds.add(catId); + } + for (const id of defaultCategoryIds) assignedCatIds.add(id); + for (const id of assignedCatIds) { + rawDb.prepare(`INSERT OR IGNORE INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`).run(row.id, id); + } + lines.push({ ...p, status: "new" }); + added++; + } catch (e) { + lines.push({ ...p, status: "error", reason: (e as Error).message }); + errors++; + } + } + }); + tx(); + + revalidatePath("/actress"); + return { lines, added, skipped, errors, newCategories: Array.from(newCategoriesSet) }; +} diff --git a/app/actions/actressLookup.ts b/app/actions/actressLookup.ts new file mode 100644 index 0000000..d2f16d5 --- /dev/null +++ b/app/actions/actressLookup.ts @@ -0,0 +1,54 @@ +"use server"; +import { rawDb } from "@/lib/db/client"; +import { reverseName } from "@/lib/jav/nameUtils"; + +export interface ActressLookupResult { + name: string; + match: { id: number; name: string; slug: string } | null; +} + +/** + * For each input name, find an existing actress matching by: + * - canonical name (exact, case-insensitive) + * - any entry in alt_names (comma-separated) + * - the reversed-word-order form (e.g. "Atomi Shuri" matches "Shuri Atomi") + * Returns one row per input preserving order. + */ +export async function lookupActressesByNames(names: string[]): Promise { + const trimmed = names.map((n) => n.trim()).filter(Boolean); + if (trimmed.length === 0) return []; + const rows = rawDb.prepare(`SELECT id, name, slug, alt_names AS altNames FROM actresses`).all() as Array<{ + id: number; + name: string; + slug: string; + altNames: string | null; + }>; + + type Indexed = { id: number; name: string; slug: string; keys: Set }; + const indexed: Indexed[] = rows.map((r) => { + const keys = new Set(); + keys.add(r.name.toLowerCase()); + const rev = reverseName(r.name); + if (rev) keys.add(rev.toLowerCase()); + if (r.altNames) { + for (const part of r.altNames.split(/[,、,]/)) { + const t = part.trim().toLowerCase(); + if (t) keys.add(t); + } + } + return { id: r.id, name: r.name, slug: r.slug, keys }; + }); + + const findMatch = (q: string) => { + const lq = q.toLowerCase(); + const lqRev = reverseName(q)?.toLowerCase() ?? null; + for (const r of indexed) { + if (r.keys.has(lq) || (lqRev && r.keys.has(lqRev))) { + return { id: r.id, name: r.name, slug: r.slug }; + } + } + return null; + }; + + return trimmed.map((name) => ({ name, match: findMatch(name) })); +} diff --git a/app/actions/actressMeta.ts b/app/actions/actressMeta.ts new file mode 100644 index 0000000..ce49496 --- /dev/null +++ b/app/actions/actressMeta.ts @@ -0,0 +1,61 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { rawDb, uniqueSlug } from "@/lib/db/client"; +import { redirect } from "next/navigation"; + +export async function updateActressMeta( + actressId: number, + data: { + name?: string; + altNames?: string | null; + notes?: string | null; + bornOn?: string | null; + heightCm?: number | null; + weightKg?: number | null; + cupSize?: string | null; + }, +): Promise<{ slug: string } | null> { + const row = rawDb.prepare(`SELECT name, slug FROM actresses WHERE id = ?`).get(actressId) as + | { name: string; slug: string } + | undefined; + if (!row) return null; + + let newSlug = row.slug; + let newName = row.name; + if (data.name != null) { + const trimmed = data.name.trim(); + if (trimmed && trimmed !== row.name) { + newName = trimmed; + newSlug = uniqueSlug(rawDb, "actresses", trimmed, actressId); + } + } + const altNames = data.altNames == null ? null : data.altNames.trim() || null; + const notes = data.notes == null ? null : data.notes.trim() || null; + const bornOn = data.bornOn == null ? null : (data.bornOn.trim() || null); + const heightCm = data.heightCm == null || !Number.isFinite(data.heightCm) ? null : Math.round(data.heightCm); + const weightKg = data.weightKg == null || !Number.isFinite(data.weightKg) ? null : Math.round(data.weightKg); + const cupSize = data.cupSize == null ? null : (data.cupSize.trim() || null); + + rawDb.prepare(` + UPDATE actresses + SET name = ?, slug = ?, alt_names = ?, notes = ?, + born_on = ?, height_cm = ?, weight_kg = ?, cup_size = ? + WHERE id = ? + `).run(newName, newSlug, altNames, notes, bornOn, heightCm, weightKg, cupSize, actressId); + + revalidatePath("/actress"); + revalidatePath(`/actress/${row.slug}`); + if (newSlug !== row.slug) revalidatePath(`/actress/${newSlug}`); + return { slug: newSlug }; +} + +export async function updateActressMetaAction(formData: FormData) { + const id = Number(formData.get("id")); + if (!Number.isFinite(id)) return; + const result = await updateActressMeta(id, { + name: String(formData.get("name") ?? ""), + altNames: String(formData.get("altNames") ?? ""), + notes: String(formData.get("notes") ?? ""), + }); + if (result) redirect(`/actress/${result.slug}`); +} diff --git a/app/actions/actressPortrait.ts b/app/actions/actressPortrait.ts new file mode 100644 index 0000000..38869df --- /dev/null +++ b/app/actions/actressPortrait.ts @@ -0,0 +1,93 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { rawDb } from "@/lib/db/client"; +import type { PortraitSlotKey } from "@/lib/db/queries"; +import { safeJoin } from "@/lib/safePath"; + +const PORTRAIT_ROOT = path.join(process.cwd(), "data", "portraits"); + +const SLOT_COLS: Record = { + "1": { path: "portrait_path", zoom: "portrait_zoom", ox: "portrait_offset_x", oy: "portrait_offset_y" }, + "2": { path: "portrait2_path", zoom: "portrait2_zoom", ox: "portrait2_offset_x", oy: "portrait2_offset_y" }, + "3": { path: "portrait3_path", zoom: "portrait3_zoom", ox: "portrait3_offset_x", oy: "portrait3_offset_y" }, + "4": { path: "portrait4_path", zoom: "portrait4_zoom", ox: "portrait4_offset_x", oy: "portrait4_offset_y" }, + "h": { path: "portraith_path", zoom: "portraith_zoom", ox: "portraith_offset_x", oy: "portraith_offset_y" }, +}; + +export async function setActressPortraitTransform( + actressId: number, + slot: PortraitSlotKey, + transform: { zoom: number; offsetX: number; offsetY: number }, +) { + const c = SLOT_COLS[slot]; + if (!c) return; + const zoom = Math.max(0.5, Math.min(5, transform.zoom)); + rawDb.prepare(` + UPDATE actresses SET ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ? WHERE id = ? + `).run(zoom, transform.offsetX, transform.offsetY, actressId); + const a = rawDb.prepare(`SELECT slug FROM actresses WHERE id = ?`).get(actressId) as { slug: string } | undefined; + revalidatePath("/actress"); + if (a) revalidatePath(`/actress/${a.slug}`); +} + +type PortraitTuple = [string | null, number, number, number]; + +export async function reorderActressPortraitSlots( + actressId: number, + src: PortraitSlotKey, + dest: PortraitSlotKey, +) { + if (src === dest) return; + if (src === "h" || dest === "h") return; + const order: PortraitSlotKey[] = ["1", "2", "3", "4"]; + if (!order.includes(src) || !order.includes(dest)) return; + + const cols = order.map((s) => SLOT_COLS[s]); + const selectFrag = cols + .map((c, i) => `${c.path} AS p${i}, ${c.zoom} AS z${i}, ${c.ox} AS x${i}, ${c.oy} AS y${i}`) + .join(", "); + const row = rawDb.prepare(`SELECT slug, ${selectFrag} FROM actresses WHERE id = ?`).get(actressId) as + | (Record & { slug: string }) + | undefined; + if (!row) return; + + const current: PortraitTuple[] = order.map((_, i) => [ + (row[`p${i}`] as string | null) ?? null, + Number(row[`z${i}`] ?? 1), + Number(row[`x${i}`] ?? 0), + Number(row[`y${i}`] ?? 0), + ]); + + const srcIdx = order.indexOf(src); + const destIdx = order.indexOf(dest); + const next = [...current]; + const [moved] = next.splice(srcIdx, 1); + next.splice(destIdx, 0, moved); + + const setFrag = cols.map((c) => `${c.path} = ?, ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ?`).join(", "); + const params = next.flatMap((t) => [t[0], t[1], t[2], t[3]]); + rawDb.prepare(`UPDATE actresses SET ${setFrag} WHERE id = ?`).run(...params, actressId); + + revalidatePath("/actress"); + revalidatePath(`/actress/${row.slug}`); +} + +export async function clearActressPortrait(actressId: number, slot: PortraitSlotKey) { + const c = SLOT_COLS[slot]; + if (!c) return; + const row = rawDb.prepare(`SELECT slug, ${c.path} AS p FROM actresses WHERE id = ?`).get(actressId) as + | { slug: string; p: string | null } + | undefined; + if (!row) return; + if (row.p) { + const abs = safeJoin(PORTRAIT_ROOT, row.p); + if (abs) await fs.rm(abs, { force: true }).catch(() => {}); + } + rawDb.prepare(` + UPDATE actresses SET ${c.path} = NULL, ${c.zoom} = 1, ${c.ox} = 0, ${c.oy} = 0 WHERE id = ? + `).run(actressId); + revalidatePath("/actress"); + revalidatePath(`/actress/${row.slug}`); +} diff --git a/app/actions/attachments.ts b/app/actions/attachments.ts new file mode 100644 index 0000000..1fa2466 --- /dev/null +++ b/app/actions/attachments.ts @@ -0,0 +1,27 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { rawDb } from "@/lib/db/client"; +import { safeJoin } from "@/lib/safePath"; + +const LIBRARY_ROOT = path.join(process.cwd(), "library"); +const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs"); + +export async function deleteAttachedImage(attachedId: number) { + const row = rawDb.prepare(` + SELECT parent_image_id AS parentId, rel_path AS relPath, thumb_path AS thumbPath + FROM images WHERE id = ? + `).get(attachedId) as { parentId: number | null; relPath: string; thumbPath: string } | undefined; + if (!row || row.parentId == null) return; + + rawDb.prepare(`DELETE FROM images WHERE id = ?`).run(attachedId); + const fileAbs = safeJoin(LIBRARY_ROOT, row.relPath); + const thumbAbs = safeJoin(THUMB_ROOT, row.thumbPath); + if (fileAbs) await fs.rm(fileAbs, { force: true }).catch(() => {}); + if (thumbAbs) await fs.rm(thumbAbs, { force: true }).catch(() => {}); + + const parentCode = rawDb.prepare(`SELECT code FROM images WHERE id = ?`).get(row.parentId) as { code: string | null } | undefined; + revalidatePath(`/image/${row.parentId}`); + if (parentCode?.code) revalidatePath(`/id/${parentCode.code}`); +} diff --git a/app/actions/bulk.ts b/app/actions/bulk.ts new file mode 100644 index 0000000..7aaffbd --- /dev/null +++ b/app/actions/bulk.ts @@ -0,0 +1,122 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { rawDb } from "@/lib/db/client"; +import { getAppSetting } from "@/lib/db/appSettings"; +import { safeJoin } from "@/lib/safePath"; + +const LIBRARY_ROOT = path.join(process.cwd(), "library"); +const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs"); + +interface DeleteResult { + trashed: number; + purged: number; +} + +export async function bulkSetWatched(ids: number[], watched: boolean): Promise<{ updated: number }> { + if (ids.length === 0) return { updated: 0 }; + const placeholders = ids.map(() => "?").join(","); + const result = rawDb + .prepare(`UPDATE images SET watched = ? WHERE id IN (${placeholders})`) + .run(watched ? 1 : 0, ...ids); + revalidatePath("/"); + for (const id of ids) revalidatePath(`/image/${id}`); + return { updated: Number(result.changes) }; +} + +export async function bulkSetOwned(ids: number[], owned: boolean): Promise<{ updated: number }> { + if (ids.length === 0) return { updated: 0 }; + const placeholders = ids.map(() => "?").join(","); + const result = rawDb + .prepare(`UPDATE images SET is_owned = ? WHERE id IN (${placeholders})`) + .run(owned ? 1 : 0, ...ids); + revalidatePath("/"); + for (const id of ids) revalidatePath(`/image/${id}`); + return { updated: Number(result.changes) }; +} + +/** + * Bulk mark covers as VIP / Favorite / Unmarked. VIP and Favorite are mutually + * exclusive, so setting one clears the other; "unmark" clears both. + */ +export async function bulkSetMark(ids: number[], mark: "vip" | "favorite" | "unmarked"): Promise<{ updated: number }> { + if (ids.length === 0) return { updated: 0 }; + const placeholders = ids.map(() => "?").join(","); + const sql = + mark === "vip" ? `UPDATE images SET is_vip = 1, is_favorite = 0 WHERE id IN (${placeholders})` + : mark === "favorite" ? `UPDATE images SET is_favorite = 1, is_vip = 0 WHERE id IN (${placeholders})` + : `UPDATE images SET is_vip = 0, is_favorite = 0 WHERE id IN (${placeholders})`; + const result = rawDb.prepare(sql).run(...ids); + revalidatePath("/"); + for (const id of ids) revalidatePath(`/image/${id}`); + return { updated: Number(result.changes) }; +} + +export async function deleteImages( + ids: number[], + options?: { permanent?: boolean }, +): Promise { + if (ids.length === 0) return { trashed: 0, purged: 0 }; + + const useBin = getAppSetting("useRecycleBin"); + const goPermanent = options?.permanent ?? !useBin; + + if (!goPermanent) { + const placeholders = ids.map(() => "?").join(","); + const now = Date.now(); + const info = rawDb.prepare( + `UPDATE images SET deleted_at = ? WHERE id IN (${placeholders}) AND deleted_at IS NULL`, + ).run(now, ...ids); + revalidate(); + return { trashed: Number(info.changes), purged: 0 }; + } + + const purged = await hardDelete(ids); + revalidate(); + // Report parents purged, not parents+children — `purged` already + // collapses to the top-level id set the caller asked us to delete. + return { trashed: 0, purged }; +} + +/** Convenience for routes that delete a single image. */ +export async function deleteImage(id: number, options?: { permanent?: boolean }): Promise { + return deleteImages([id], options); +} + +async function hardDelete(ids: number[]): Promise { + const placeholders = ids.map(() => "?").join(","); + const rows = rawDb.prepare( + ` + SELECT id, rel_path, thumb_path FROM images + WHERE id IN (${placeholders}) OR parent_image_id IN (${placeholders}) + `, + ).all(...ids, ...ids) as Array<{ id: number; rel_path: string; thumb_path: string }>; + + // DB delete first: if the process crashes mid-purge, files on disk become + // orphans (cleanable via the maintenance scanner) rather than DB rows + // pointing at vanished files. Children cascade via parent_image_id FK. + const info = rawDb.prepare(`DELETE FROM images WHERE id IN (${placeholders})`).run(...ids); + + if (getAppSetting("purgeFilesOnDelete")) { + await Promise.all(rows.flatMap((r) => { + const fileAbs = safeJoin(LIBRARY_ROOT, r.rel_path); + const thumbAbs = safeJoin(THUMB_ROOT, r.thumb_path); + const tasks: Array> = []; + if (fileAbs) tasks.push(fs.rm(fileAbs, { force: true })); + if (thumbAbs) tasks.push(fs.rm(thumbAbs, { force: true })); + return tasks; + })); + } + return Number(info.changes); +} + +function revalidate() { + revalidatePath("/"); + revalidatePath("/collection"); + revalidatePath("/tag"); + revalidatePath("/actress"); + revalidatePath("/studios"); + revalidatePath("/series"); + revalidatePath("/genres"); +} diff --git a/app/actions/categoryCover.ts b/app/actions/categoryCover.ts new file mode 100644 index 0000000..db34f5b --- /dev/null +++ b/app/actions/categoryCover.ts @@ -0,0 +1,49 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { rawDb } from "@/lib/db/client"; +import { safeJoin } from "@/lib/safePath"; + +export type CategoryCoverSlot = "portrait" | "landscape"; + +const COVER_ROOT = path.join(process.cwd(), "data", "category-covers"); + +const SLOT_COLS: Record = { + portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" }, + landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" }, +}; + +export async function setCategoryCoverTransform( + categoryId: number, + slot: CategoryCoverSlot, + transform: { zoom: number; offsetX: number; offsetY: number }, +) { + const c = SLOT_COLS[slot]; + if (!c) return; + const zoom = Math.max(0.5, Math.min(5, transform.zoom)); + rawDb.prepare(` + UPDATE tag_categories SET ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ? WHERE id = ? + `).run(zoom, transform.offsetX, transform.offsetY, categoryId); + const row = rawDb.prepare(`SELECT slug FROM tag_categories WHERE id = ?`).get(categoryId) as { slug: string } | undefined; + revalidatePath("/category"); + if (row) revalidatePath(`/category/${row.slug}`); +} + +export async function clearCategoryCover(categoryId: number, slot: CategoryCoverSlot) { + const c = SLOT_COLS[slot]; + if (!c) return; + const row = rawDb.prepare(`SELECT slug, ${c.path} AS p FROM tag_categories WHERE id = ?`).get(categoryId) as + | { slug: string; p: string | null } + | undefined; + if (!row) return; + if (row.p) { + const abs = safeJoin(COVER_ROOT, row.p); + if (abs) await fs.rm(abs, { force: true }).catch(() => {}); + } + rawDb.prepare(` + UPDATE tag_categories SET ${c.path} = NULL, ${c.zoom} = 1, ${c.ox} = 0, ${c.oy} = 0 WHERE id = ? + `).run(categoryId); + revalidatePath("/category"); + revalidatePath(`/category/${row.slug}`); +} diff --git a/app/actions/collectionCover.ts b/app/actions/collectionCover.ts new file mode 100644 index 0000000..29e50ca --- /dev/null +++ b/app/actions/collectionCover.ts @@ -0,0 +1,49 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { rawDb } from "@/lib/db/client"; +import { safeJoin } from "@/lib/safePath"; + +export type CollectionCoverSlot = "portrait" | "landscape"; + +const COVER_ROOT = path.join(process.cwd(), "data", "collection-covers"); + +const SLOT_COLS: Record = { + portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" }, + landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" }, +}; + +export async function setCollectionCoverTransform( + collectionId: number, + slot: CollectionCoverSlot, + transform: { zoom: number; offsetX: number; offsetY: number }, +) { + const c = SLOT_COLS[slot]; + if (!c) return; + const zoom = Math.max(0.5, Math.min(5, transform.zoom)); + rawDb.prepare(` + UPDATE collections SET ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ? WHERE id = ? + `).run(zoom, transform.offsetX, transform.offsetY, collectionId); + const row = rawDb.prepare(`SELECT slug FROM collections WHERE id = ?`).get(collectionId) as { slug: string } | undefined; + revalidatePath("/collection"); + if (row) revalidatePath(`/collection/${row.slug}`); +} + +export async function clearCollectionCover(collectionId: number, slot: CollectionCoverSlot) { + const c = SLOT_COLS[slot]; + if (!c) return; + const row = rawDb.prepare(`SELECT slug, ${c.path} AS p FROM collections WHERE id = ?`).get(collectionId) as + | { slug: string; p: string | null } + | undefined; + if (!row) return; + if (row.p) { + const abs = safeJoin(COVER_ROOT, row.p); + if (abs) await fs.rm(abs, { force: true }).catch(() => {}); + } + rawDb.prepare(` + UPDATE collections SET ${c.path} = NULL, ${c.zoom} = 1, ${c.ox} = 0, ${c.oy} = 0 WHERE id = ? + `).run(collectionId); + revalidatePath("/collection"); + revalidatePath(`/collection/${row.slug}`); +} diff --git a/app/actions/collections.ts b/app/actions/collections.ts new file mode 100644 index 0000000..6e17e01 --- /dev/null +++ b/app/actions/collections.ts @@ -0,0 +1,153 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { rawDb, uniqueSlug } from "@/lib/db/client"; +import { redirect } from "next/navigation"; + +export async function createCollection(name: string, description?: string): Promise<{ id: number; slug: string } | null> { + const trimmed = name.trim(); + if (!trimmed) return null; + const slug = uniqueSlug(rawDb, "collections", trimmed); + // New collections land at the end of the manual order. Wrap the read + + // insert in a tx so concurrent creates don't both pick the same + // position. + const tx = rawDb.transaction(() => { + const max = rawDb.prepare(`SELECT COALESCE(MAX(position), -1) AS m FROM collections`).get() as { m: number }; + const r = rawDb.prepare(` + INSERT INTO collections (name, slug, description, position) VALUES (?, ?, ?, ?) RETURNING id + `).get(trimmed, slug, description?.trim() || null, max.m + 1) as { id: number }; + return r.id; + }); + const id = tx(); + revalidatePath("/collection"); + return { id, slug }; +} + +export async function createCollectionAction(formData: FormData) { + const name = String(formData.get("name") ?? ""); + const description = String(formData.get("description") ?? ""); + const created = await createCollection(name, description); + if (created) redirect(`/collection/${created.slug}`); +} + +export async function addImageToCollection(collectionId: number, imageId: number) { + // Wrap the read-then-insert in a transaction so concurrent calls + // can't both compute the same MAX(position) and produce duplicate + // ordering values. + 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); + rawDb.prepare(`UPDATE collections SET last_used_at = (unixepoch() * 1000) WHERE id = ?`).run(collectionId); + }); + tx(); + revalidatePath(`/collection`); + revalidatePath(`/image/${imageId}`); +} + +export async function removeImageFromCollection(collectionId: number, imageId: number) { + rawDb.prepare(`DELETE FROM collection_images WHERE collection_id = ? AND image_id = ?`).run(collectionId, imageId); + revalidatePath(`/collection`); + revalidatePath(`/image/${imageId}`); +} + +export async function deleteCollection(collectionId: number) { + rawDb.prepare(`DELETE FROM collections WHERE id = ?`).run(collectionId); + revalidatePath("/collection"); + redirect("/collection"); +} + +/** + * Reorder a single image within a collection. The drag-and-drop UI passes + * the image being moved and the image it should now sit before (or null + * to drop at the end). We pull the current ordered list, splice the + * moved image into its new index, then rewrite every position so the + * sequence is dense (0..N-1) regardless of any gaps the previous + * ordering may have had. + */ +export async function reorderCollectionImage( + collectionId: number, + movedImageId: number, + beforeImageId: number | null, +): Promise { + const rows = rawDb.prepare(` + SELECT image_id FROM collection_images + WHERE collection_id = ? + ORDER BY position ASC, image_id ASC + `).all(collectionId) as Array<{ image_id: number }>; + const ids = rows.map((r) => r.image_id); + const fromIdx = ids.indexOf(movedImageId); + if (fromIdx === -1) return; + ids.splice(fromIdx, 1); + let toIdx = beforeImageId == null ? ids.length : ids.indexOf(beforeImageId); + if (toIdx === -1) toIdx = ids.length; + ids.splice(toIdx, 0, movedImageId); + + const tx = rawDb.transaction(() => { + const update = rawDb.prepare(` + UPDATE collection_images SET position = ? WHERE collection_id = ? AND image_id = ? + `); + for (let i = 0; i < ids.length; i++) { + update.run(i, collectionId, ids[i]); + } + }); + tx(); + + const slugRow = rawDb.prepare(`SELECT slug FROM collections WHERE id = ?`).get(collectionId) as { slug: string } | undefined; + revalidatePath("/collection"); + if (slugRow) revalidatePath(`/collection/${slugRow.slug}`); +} + +/** + * Reorder a collection in the manual list on /collection. Pulls the + * current ordered ids, splices the moved one to its new index, then + * rewrites every position so the sequence is dense (0..N-1). + */ +export async function reorderCollection( + movedId: number, + beforeId: number | null, +): Promise { + const rows = rawDb.prepare(` + SELECT id FROM collections ORDER BY position ASC, id ASC + `).all() as Array<{ id: number }>; + const ids = rows.map((r) => r.id); + const fromIdx = ids.indexOf(movedId); + if (fromIdx === -1) return; + ids.splice(fromIdx, 1); + let toIdx = beforeId == null ? ids.length : ids.indexOf(beforeId); + if (toIdx === -1) toIdx = ids.length; + ids.splice(toIdx, 0, movedId); + + const tx = rawDb.transaction(() => { + const update = rawDb.prepare(`UPDATE collections SET position = ? WHERE id = ?`); + for (let i = 0; i < ids.length; i++) update.run(i, ids[i]); + }); + tx(); + revalidatePath("/collection"); +} + +/** Rename a collection. Returns the new slug for client-side redirect. + * Wraps the slug-uniqueness check + UPDATE in a transaction so two + * concurrent renames can't both compute the same slug and crash on + * the UNIQUE constraint (or worse, race past it). */ +export async function renameCollection(id: number, name: string): Promise<{ slug: string; name: string } | null> { + const trimmed = name.trim(); + if (!trimmed) return null; + const tx = rawDb.transaction(() => { + const current = rawDb.prepare(`SELECT name, slug FROM collections WHERE id = ?`).get(id) as + | { name: string; slug: string } + | undefined; + if (!current) return null; + if (current.name === trimmed) { + return { slug: current.slug, name: trimmed }; + } + const slug = uniqueSlug(rawDb, "collections", trimmed, id); + rawDb.prepare(`UPDATE collections SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id); + return { slug, name: trimmed }; + }); + const result = tx() as { slug: string; name: string } | null; + if (!result) return null; + revalidatePath("/collection"); + revalidatePath(`/collection/${result.slug}`); + return result; +} diff --git a/app/actions/coverMeta.ts b/app/actions/coverMeta.ts new file mode 100644 index 0000000..3fc45e9 --- /dev/null +++ b/app/actions/coverMeta.ts @@ -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 { + 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; +} diff --git a/app/actions/entities.ts b/app/actions/entities.ts new file mode 100644 index 0000000..2061f40 --- /dev/null +++ b/app/actions/entities.ts @@ -0,0 +1,128 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { rawDb, uniqueSlug } from "@/lib/db/client"; + +type EntityTable = "actresses" | "studios" | "series"; + +function createEntity(table: EntityTable, name: string): { id: number; slug: string } | null { + const trimmed = name.trim(); + if (!trimmed) return null; + const existing = rawDb.prepare(`SELECT id, slug FROM ${table} WHERE name = ?`).get(trimmed) as + | { id: number; slug: string } + | undefined; + if (existing) return existing; + 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 { id: row.id, slug }; +} + +export async function createActressAction(formData: FormData) { + createEntity("actresses", String(formData.get("name") ?? "")); + revalidatePath("/actress"); +} + +export async function createStudioAction(formData: FormData) { + const created = createEntity("studios", String(formData.get("name") ?? "")); + revalidatePath("/studios"); + if (created) redirect(`/studios/${created.slug}`); +} + +export async function createSeriesAction(formData: FormData) { + const created = createEntity("series", String(formData.get("name") ?? "")); + revalidatePath("/series"); + if (created) redirect(`/series/${created.slug}`); +} + +/** Deletes a studio. Any covers referencing it have studio_id set to NULL. */ +export async function deleteStudio(id: number) { + rawDb.prepare(`UPDATE images SET studio_id = NULL WHERE studio_id = ?`).run(id); + rawDb.prepare(`DELETE FROM studios WHERE id = ?`).run(id); + revalidatePath("/studios"); + revalidatePath("/"); + redirect("/studios"); +} + +/** Deletes a series. Any covers referencing it have series_id set to NULL. */ +export async function deleteSeries(id: number) { + rawDb.prepare(`UPDATE images SET series_id = NULL WHERE series_id = ?`).run(id); + rawDb.prepare(`DELETE FROM series WHERE id = ?`).run(id); + revalidatePath("/series"); + revalidatePath("/"); + redirect("/series"); +} + +/** Rename a studio. Returns the new slug (which may be re-uniquified) or null. */ +export async function renameStudio(id: number, name: string): Promise<{ slug: string } | null> { + const trimmed = name.trim(); + if (!trimmed) return null; + const row = rawDb.prepare(`SELECT name, slug FROM studios WHERE id = ?`).get(id) as { name: string; slug: string } | undefined; + if (!row) return null; + if (trimmed === row.name) return { slug: row.slug }; + const slug = uniqueSlug(rawDb, "studios", trimmed, id); + rawDb.prepare(`UPDATE studios SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id); + revalidatePath("/studios"); + revalidatePath(`/studios/${row.slug}`); + if (slug !== row.slug) revalidatePath(`/studios/${slug}`); + return { slug }; +} + +/** Rename a series. Returns the new slug or null. */ +export async function renameSeries(id: number, name: string): Promise<{ slug: string } | null> { + const trimmed = name.trim(); + if (!trimmed) return null; + const row = rawDb.prepare(`SELECT name, slug FROM series WHERE id = ?`).get(id) as { name: string; slug: string } | undefined; + if (!row) return null; + if (trimmed === row.name) return { slug: row.slug }; + const slug = uniqueSlug(rawDb, "series", trimmed, id); + rawDb.prepare(`UPDATE series SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id); + revalidatePath("/series"); + revalidatePath(`/series/${row.slug}`); + if (slug !== row.slug) revalidatePath(`/series/${slug}`); + return { slug }; +} + +/** Rename a tag (keyed by name). Returns the new name or null. */ +export async function renameTag(oldName: string, newName: string): Promise<{ name: string } | null> { + // Tags are stored lowercased (see tags.ts createTag/addTagToImage). + // Lookup by exact match must use the same casing or it silently misses. + const oldTrim = oldName.trim().toLowerCase(); + const newTrim = newName.trim().toLowerCase(); + if (!oldTrim || !newTrim) return null; + if (oldTrim === newTrim) return { name: oldTrim }; + const row = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(oldTrim) as { id: number } | undefined; + if (!row) return null; + // Wrap merge in a transaction so a concurrent INSERT into image_tags + // for the old tag_id can't slip between the UPDATE re-point and the + // DELETE — both run atomically against a consistent snapshot. + const merge = rawDb.transaction(() => { + const conflict = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(newTrim) as { id: number } | undefined; + if (conflict && conflict.id !== row.id) { + rawDb.prepare(`UPDATE OR IGNORE image_tags SET tag_id = ? WHERE tag_id = ?`).run(conflict.id, row.id); + rawDb.prepare(`DELETE FROM image_tags WHERE tag_id = ?`).run(row.id); + rawDb.prepare(`DELETE FROM tags WHERE id = ?`).run(row.id); + } else { + rawDb.prepare(`UPDATE tags SET name = ? WHERE id = ?`).run(newTrim, row.id); + } + }); + merge(); + revalidatePath("/tag"); + revalidatePath(`/tag/${encodeURIComponent(oldTrim)}`); + revalidatePath(`/tag/${encodeURIComponent(newTrim)}`); + return { name: newTrim }; +} + +/** Deletes a tag. Any image_tags rows referencing it are removed. */ +export async function deleteTag(name: string) { + const trimmed = name.trim(); + if (!trimmed) return; + const row = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(trimmed) as { id: number } | undefined; + if (!row) return; + rawDb.prepare(`DELETE FROM image_tags WHERE tag_id = ?`).run(row.id); + rawDb.prepare(`DELETE FROM tags WHERE id = ?`).run(row.id); + revalidatePath("/tag"); + revalidatePath("/"); + redirect("/tag"); +} diff --git a/app/actions/imageMeta.ts b/app/actions/imageMeta.ts new file mode 100644 index 0000000..bdf6f52 --- /dev/null +++ b/app/actions/imageMeta.ts @@ -0,0 +1,6 @@ +"use server"; +import { getImageContextData } from "@/lib/db/queries"; + +export async function fetchImageContextData(imageIds: number[]) { + return getImageContextData(imageIds); +} diff --git a/app/actions/maintenance.ts b/app/actions/maintenance.ts new file mode 100644 index 0000000..5d80562 --- /dev/null +++ b/app/actions/maintenance.ts @@ -0,0 +1,620 @@ +"use server"; +import path from "node:path"; +import fs from "node:fs/promises"; +import sharp from "sharp"; +import { rawDb } from "@/lib/db/client"; +import { sanitizeFilename, uniqueFilePath, letterBucket, canonicalThumbName } from "@/lib/filename"; +import { extractCode } from "@/lib/jav/codeParser"; +import { computeDHash, hammingDistance } from "@/lib/jav/phash"; +import { clearAppSettingsCache } from "@/lib/db/appSettings"; +import { safeJoin } from "@/lib/safePath"; +import { revalidatePath } from "next/cache"; + +const LIBRARY_ROOT = path.join(process.cwd(), "library"); +const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs"); +const PORTRAIT_ROOT = path.join(process.cwd(), "data", "portraits"); +const CATEGORY_COVER_ROOT = path.join(process.cwd(), "data", "category-covers"); +const COLLECTION_COVER_ROOT = path.join(process.cwd(), "data", "collection-covers"); + +const SYSTEM_FILES = new Set([".ds_store", "thumbs.db", "desktop.ini"]); + +interface OrphanReport { + libraryFiles: string[]; + thumbFiles: string[]; + portraitFiles: string[]; + categoryCoverFiles: string[]; + collectionCoverFiles: string[]; + bytes: number; +} + +async function walk(dir: string): Promise { + let entries: import("node:fs").Dirent[] = []; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + const out: string[] = []; + await Promise.all(entries.map(async (e) => { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + out.push(...(await walk(full))); + } else if (e.isFile() && !SYSTEM_FILES.has(e.name.toLowerCase())) { + out.push(full); + } + })); + return out; +} + +async function findOrphans(): Promise { + const knownLibrary = new Set( + (rawDb.prepare(`SELECT rel_path FROM images`).all() as Array<{ rel_path: string }>) + .map((r) => path.normalize(r.rel_path)), + ); + const knownThumbs = new Set( + (rawDb.prepare(`SELECT thumb_path FROM images`).all() as Array<{ thumb_path: string }>) + .map((r) => path.normalize(r.thumb_path)), + ); + const knownPortraits = new Set( + (rawDb + .prepare(` + SELECT portrait_path AS p FROM actresses WHERE portrait_path IS NOT NULL + UNION ALL SELECT portrait2_path FROM actresses WHERE portrait2_path IS NOT NULL + UNION ALL SELECT portrait3_path FROM actresses WHERE portrait3_path IS NOT NULL + UNION ALL SELECT portrait4_path FROM actresses WHERE portrait4_path IS NOT NULL + UNION ALL SELECT portraith_path FROM actresses WHERE portraith_path IS NOT NULL + `) + .all() as Array<{ p: string }>) + .map((r) => path.normalize(r.p)), + ); + const knownCategoryCovers = new Set( + (rawDb + .prepare(` + SELECT cover_portrait_path AS p FROM tag_categories WHERE cover_portrait_path IS NOT NULL + UNION ALL SELECT cover_landscape_path FROM tag_categories WHERE cover_landscape_path IS NOT NULL + `) + .all() as Array<{ p: string }>) + .map((r) => path.normalize(r.p)), + ); + const knownCollectionCovers = new Set( + (rawDb + .prepare(` + SELECT cover_portrait_path AS p FROM collections WHERE cover_portrait_path IS NOT NULL + UNION ALL SELECT cover_landscape_path FROM collections WHERE cover_landscape_path IS NOT NULL + `) + .all() as Array<{ p: string }>) + .map((r) => path.normalize(r.p)), + ); + + const [libFiles, thumbFiles, portraitFiles, categoryCoverFiles, collectionCoverFiles] = await Promise.all([ + walk(LIBRARY_ROOT), + walk(THUMB_ROOT), + walk(PORTRAIT_ROOT), + walk(CATEGORY_COVER_ROOT), + walk(COLLECTION_COVER_ROOT), + ]); + + const libraryOrphans = libFiles.filter((abs) => { + const rel = path.normalize(path.relative(LIBRARY_ROOT, abs)); + return !knownLibrary.has(rel); + }); + const thumbOrphans = thumbFiles.filter((abs) => { + const rel = path.normalize(path.relative(THUMB_ROOT, abs)); + return !knownThumbs.has(rel); + }); + const portraitOrphans = portraitFiles.filter((abs) => { + const rel = path.normalize(path.relative(PORTRAIT_ROOT, abs)); + return !knownPortraits.has(rel); + }); + const categoryCoverOrphans = categoryCoverFiles.filter((abs) => { + const rel = path.normalize(path.relative(CATEGORY_COVER_ROOT, abs)); + return !knownCategoryCovers.has(rel); + }); + const collectionCoverOrphans = collectionCoverFiles.filter((abs) => { + const rel = path.normalize(path.relative(COLLECTION_COVER_ROOT, abs)); + return !knownCollectionCovers.has(rel); + }); + + let bytes = 0; + await Promise.all([ + ...libraryOrphans, ...thumbOrphans, ...portraitOrphans, + ...categoryCoverOrphans, ...collectionCoverOrphans, + ].map(async (f) => { + try { bytes += (await fs.stat(f)).size; } catch {} + })); + + return { + libraryFiles: libraryOrphans, + thumbFiles: thumbOrphans, + portraitFiles: portraitOrphans, + categoryCoverFiles: categoryCoverOrphans, + collectionCoverFiles: collectionCoverOrphans, + bytes, + }; +} + +export async function previewOrphanFiles(): Promise<{ count: number; bytes: number }> { + const report = await findOrphans(); + const count = + report.libraryFiles.length + + report.thumbFiles.length + + report.portraitFiles.length + + report.categoryCoverFiles.length + + report.collectionCoverFiles.length; + return { count, bytes: report.bytes }; +} + +export async function purgeOrphanFiles(): Promise<{ deleted: number; bytes: number }> { + const report = await findOrphans(); + const all = [ + ...report.libraryFiles, + ...report.thumbFiles, + ...report.portraitFiles, + ...report.categoryCoverFiles, + ...report.collectionCoverFiles, + ]; + // Bound concurrency: Promise.all over thousands of fs.rm calls can + // exhaust file descriptors (EMFILE) on Windows / low-ulimit hosts. + const CONCURRENCY = 32; + for (let i = 0; i < all.length; i += CONCURRENCY) { + await Promise.all(all.slice(i, i + CONCURRENCY).map((f) => fs.rm(f, { force: true }))); + } + // Sweep empty subdirs across every root that just shed files. + await Promise.all([ + cleanEmptyDirs(LIBRARY_ROOT), + cleanEmptyDirs(THUMB_ROOT), + cleanEmptyDirs(PORTRAIT_ROOT), + cleanEmptyDirs(CATEGORY_COVER_ROOT), + cleanEmptyDirs(COLLECTION_COVER_ROOT), + ]); + // Indexes that show cover/portrait/thumb counts need to refetch. + revalidatePath("/"); + revalidatePath("/category"); + revalidatePath("/collection"); + revalidatePath("/actress"); + return { deleted: all.length, bytes: report.bytes }; +} + +interface ReorganizePreview { + total: number; + toMove: number; +} + +interface ImageRow { + id: number; + filename: string; + rel_path: string; + code: string | null; + parent_image_id: number | null; +} + +/** + * Resolve the target letter-bucket directory for a row. Attached images + * (parent_image_id set) bucket with their parent's code so related files + * stay together on disk. + */ +function plannedDirRel(row: ImageRow, parentCodeById: Map): string { + if (row.parent_image_id != null) { + const parentCode = parentCodeById.get(row.parent_image_id) ?? null; + return letterBucket(parentCode).dirRel; + } + return letterBucket(row.code).dirRel; +} + +function loadAllImages(): { rows: ImageRow[]; parentCodeById: Map } { + const rows = rawDb.prepare(`SELECT id, filename, rel_path, code, parent_image_id FROM images`).all() as ImageRow[]; + const parentCodeById = new Map(); + for (const r of rows) parentCodeById.set(r.id, r.code); + return { rows, parentCodeById }; +} + +export async function previewReorganize(): Promise { + const { rows, parentCodeById } = loadAllImages(); + let toMove = 0; + for (const r of rows) { + const target = plannedDirRel(r, parentCodeById); + const currentDir = path.posix.dirname(r.rel_path.replace(/\\/g, "/")); + if (currentDir !== target) toMove++; + } + return { total: rows.length, toMove }; +} + +export async function reorganizeFiles(): Promise<{ moved: number; skipped: number; errors: number }> { + const { rows, parentCodeById } = loadAllImages(); + + let moved = 0, skipped = 0, errors = 0; + for (const r of rows) { + const target = plannedDirRel(r, parentCodeById); + const currentDir = path.posix.dirname(r.rel_path.replace(/\\/g, "/")); + if (currentDir === target) { skipped++; continue; } + + const oldAbs = path.join(LIBRARY_ROOT, r.rel_path); + try { + await fs.access(oldAbs); + } catch { + errors++; + continue; + } + + const { base, ext } = sanitizeFilename(r.filename || `image${path.extname(r.rel_path)}`); + const dirAbs = path.join(LIBRARY_ROOT, target); + try { + await fs.mkdir(dirAbs, { recursive: true }); + const newAbs = await uniqueFilePath(dirAbs, base, ext); + await fs.rename(oldAbs, newAbs); + const newRel = path.posix.join(target, path.basename(newAbs)); + rawDb.prepare(`UPDATE images SET rel_path = ? WHERE id = ?`).run(newRel, r.id); + moved++; + } catch { + errors++; + } + } + + await cleanEmptyDirs(LIBRARY_ROOT); + revalidatePath("/"); + return { moved, skipped, errors }; +} + +export async function clearCache(): Promise<{ ok: true }> { + clearAppSettingsCache(); + for (const p of ["/", "/collection", "/tag", "/category", "/actress", "/studios", "/series", "/genres", "/queue"]) { + revalidatePath(p); + } + return { ok: true }; +} + +export interface UndersizedCover { + id: number; + code: string | null; + filename: string; + width: number; + height: number; + bytes: number; + thumbPath: string; +} + +/** + * Scan top-level covers whose pixel dimensions look smaller than a + * standard JAV cover (typically 800x538). Catches accidental imports of + * thumbnails, web previews, or other non-cover images. + * + * Defaults are deliberately permissive — the standard is 800x538 but real + * scans/rips drift by a few pixels in either direction. The 147x200 + * outlier the user spotted falls well below the floor. + */ +export async function scanUndersizedCovers(opts?: { + minWidth?: number; + minHeight?: number; +}): Promise { + const minW = opts?.minWidth ?? 750; + const minH = opts?.minHeight ?? 500; + return rawDb.prepare(` + SELECT id, code, filename, width, height, bytes, thumb_path AS thumbPath + FROM images + WHERE parent_image_id IS NULL + AND deleted_at IS NULL + AND (width < ? OR height < ?) + ORDER BY (width * height) ASC, id ASC + `).all(minW, minH) as UndersizedCover[]; +} + +interface RegenThumbsPreview { + total: number; + missing: number; + staleNames: number; +} + +/** + * Resolve the planned canonical filename for a row: includes parent code + * lookup for attached images so back-covers inherit the prefix. + */ +function plannedThumbName(row: { sha256: string; code: string | null; parent_image_id: number | null }): string { + if (row.parent_image_id != null) { + const parent = rawDb.prepare(`SELECT code FROM images WHERE id = ?`).get(row.parent_image_id) as + | { code: string | null } + | undefined; + return canonicalThumbName(parent?.code ?? null, row.sha256); + } + return canonicalThumbName(row.code, row.sha256); +} + +/** Count covers whose thumb file is missing on disk or whose stored name is stale. */ +export async function previewRegenThumbnails(): Promise { + const rows = rawDb.prepare(` + SELECT thumb_path, sha256, code, parent_image_id FROM images WHERE deleted_at IS NULL + `).all() as Array<{ thumb_path: string; sha256: string; code: string | null; parent_image_id: number | null }>; + let missing = 0; + let staleNames = 0; + // Sequential is fine for personal-library scale; a bulk Promise.all here + // can blow up with EMFILE on very large libraries. + for (const r of rows) { + const target = plannedThumbName(r); + if (target !== r.thumb_path) staleNames++; + const targetAbs = path.join(THUMB_ROOT, target); + try { await fs.access(targetAbs); } catch { missing++; } + } + return { total: rows.length, missing, staleNames }; +} + +/** + * Rebuild thumbnails. Three paths per row: + * 1. Canonical file already on disk → skip (unless `force`). + * 2. Legacy file (different name from canonical) is on disk → rename it + * to canonical and update thumb_path. No re-encode needed; this is + * the migration path for libraries that predate the code-prefix + * naming. + * 3. Neither file is on disk → read original from library/ and encode + * from scratch. + */ +export async function regenerateThumbnails(opts?: { force?: boolean }): Promise<{ regenerated: number; renamed: number; skipped: number; errors: number }> { + const force = opts?.force ?? false; + const rows = rawDb.prepare(` + SELECT id, rel_path, thumb_path, sha256, code, parent_image_id FROM images WHERE deleted_at IS NULL + `).all() as Array<{ id: number; rel_path: string; thumb_path: string; sha256: string; code: string | null; parent_image_id: number | null }>; + + await fs.mkdir(THUMB_ROOT, { recursive: true }); + + let regenerated = 0, renamed = 0, skipped = 0, errors = 0; + for (const r of rows) { + const target = plannedThumbName(r); + const targetAbs = path.join(THUMB_ROOT, target); + + if (!force) { + try { + await fs.access(targetAbs); + // Canonical file exists. If the DB still has the legacy name, + // sync the column so future operations don't drift. + if (r.thumb_path !== target) { + rawDb.prepare(`UPDATE images SET thumb_path = ? WHERE id = ?`).run(target, r.id); + } + skipped++; + continue; + } catch { /* missing — fall through */ } + } + + // Try the legacy/current path: if a thumb exists at the stored + // thumb_path that's different from canonical, rename it instead of + // re-encoding. Faster, lossless, preserves whatever the file already + // was. + if (r.thumb_path !== target) { + const oldAbs = safeJoin(THUMB_ROOT, r.thumb_path); + if (oldAbs) { + try { + await fs.access(oldAbs); + if (force) { + // Force mode: drop the old file and re-encode at canonical. + await fs.rm(oldAbs, { force: true }).catch(() => {}); + } else { + await fs.rename(oldAbs, targetAbs); + rawDb.prepare(`UPDATE images SET thumb_path = ? WHERE id = ?`).run(target, r.id); + renamed++; + continue; + } + } catch { /* legacy file missing — fall through to encode */ } + } + } + + const libAbs = safeJoin(LIBRARY_ROOT, r.rel_path); + if (!libAbs) { + errors++; + continue; + } + + try { + // Pass the file path to sharp instead of reading into a buffer. + // The library can contain multi-GB videos that were misclassified + // as images; reading those into memory would OOM the server. + // sharp streams from disk and reports its own decode errors. + // Mirrors lib/ingest/ingest.ts's resize pipeline. + await sharp(libAbs, { failOn: "none" }) + .rotate() + .resize({ width: 768, height: 768, fit: "inside", withoutEnlargement: true }) + .webp({ quality: 82 }) + .toFile(targetAbs); + if (r.thumb_path !== target) { + rawDb.prepare(`UPDATE images SET thumb_path = ? WHERE id = ?`).run(target, r.id); + } + regenerated++; + } catch { + errors++; + } + } + + revalidatePath("/"); + return { regenerated, renamed, skipped, errors }; +} + +async function cleanEmptyDirs(root: string): Promise { + let entries: import("node:fs").Dirent[] = []; + try { entries = await fs.readdir(root, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + if (!e.isDirectory()) continue; + const dir = path.join(root, e.name); + await cleanEmptyDirs(dir); + try { + const remaining = await fs.readdir(dir); + if (remaining.length === 0) await fs.rmdir(dir); + } catch {} + } +} + +export interface ReparseCodesPreview { + total: number; + /** Rows with no code where extractCode now finds one — safe to fill. */ + missing: number; + /** Rows where extractCode disagrees with the stored code — overwrite + * is destructive of any manual edit, so it's gated behind force=true. */ + changed: number; + /** Sample of up to 20 changed rows for the preview UI. */ + sampleChanges: Array<{ id: number; filename: string; oldCode: string; newCode: string }>; +} + +/** + * Walk every top-level cover (parent_image_id IS NULL, not soft-deleted) + * and re-run extractCode against the stored filename. Reports how many + * rows would change so the user can preview before committing. + */ +export async function previewReparseCodes(): Promise { + const rows = rawDb.prepare(` + SELECT id, filename, code FROM images + WHERE deleted_at IS NULL AND parent_image_id IS NULL + `).all() as Array<{ id: number; filename: string; code: string | null }>; + let missing = 0, changed = 0; + const sampleChanges: ReparseCodesPreview["sampleChanges"] = []; + for (const r of rows) { + const extracted = extractCode(r.filename); + if (!extracted) continue; + if (r.code == null) { + missing++; + } else if (r.code !== extracted) { + changed++; + if (sampleChanges.length < 20) { + sampleChanges.push({ id: r.id, filename: r.filename, oldCode: r.code, newCode: extracted }); + } + } + } + return { total: rows.length, missing, changed, sampleChanges }; +} + +/** + * Apply the re-parse. By default only fills rows with NULL code (safe); + * pass force=true to overwrite codes that disagree with extractCode. + * + * Note: this only updates the DB. Files won't move into their new + * letter buckets until you also run Reorganize. Same for thumbnail + * filenames — the code prefix in `-.webp` won't update until + * Regenerate Thumbnails runs. + */ +export async function reparseCodes(opts?: { force?: boolean }): Promise<{ filled: number; updated: number; skipped: number }> { + const force = opts?.force ?? false; + const rows = rawDb.prepare(` + SELECT id, filename, code FROM images + WHERE deleted_at IS NULL AND parent_image_id IS NULL + `).all() as Array<{ id: number; filename: string; code: string | null }>; + + let filled = 0, updated = 0, skipped = 0; + const tx = rawDb.transaction(() => { + const update = rawDb.prepare(`UPDATE images SET code = ? WHERE id = ?`); + for (const r of rows) { + const extracted = extractCode(r.filename); + if (!extracted) { skipped++; continue; } + if (r.code == null) { + update.run(extracted, r.id); + filled++; + } else if (r.code !== extracted) { + if (force) { + update.run(extracted, r.id); + updated++; + } else { + skipped++; + } + } else { + skipped++; + } + } + }); + tx(); + revalidatePath("/"); + return { filled, updated, skipped }; +} + +export interface NearDupePair { + a: { id: number; code: string | null; filename: string; thumbPath: string; width: number; height: number; bytes: number }; + b: { id: number; code: string | null; filename: string; thumbPath: string; width: number; height: number; bytes: number }; + distance: number; +} + +export interface NearDupesPreview { + total: number; + hashed: number; + unhashed: number; +} + +/** Quick stats: how many rows already have a phash vs need backfilling. */ +export async function previewNearDupes(): Promise { + const row = rawDb.prepare(` + SELECT + COUNT(*) AS total, + SUM(CASE WHEN phash IS NOT NULL THEN 1 ELSE 0 END) AS hashed + FROM images WHERE deleted_at IS NULL + `).get() as { total: number; hashed: number }; + return { + total: row.total, + hashed: row.hashed, + unhashed: row.total - row.hashed, + }; +} + +/** + * Backfill `phash` for every row that doesn't have one yet. Reads the + * library file, computes dHash, writes to DB. Skips rows whose file is + * missing on disk. + */ +export async function backfillPhashes(): Promise<{ hashed: number; skipped: number; errors: number }> { + const rows = rawDb.prepare(` + SELECT id, rel_path FROM images + WHERE deleted_at IS NULL AND phash IS NULL + `).all() as Array<{ id: number; rel_path: string }>; + + let hashed = 0, skipped = 0, errors = 0; + const update = rawDb.prepare(`UPDATE images SET phash = ? WHERE id = ?`); + for (const r of rows) { + const abs = safeJoin(LIBRARY_ROOT, r.rel_path); + if (!abs) { errors++; continue; } + try { + const buf = await fs.readFile(abs); + const hash = await computeDHash(buf); + update.run(hash, r.id); + hashed++; + } catch { + errors++; + } + } + return { hashed, skipped, errors }; +} + +/** + * Find pairs of covers whose dHashes are within `threshold` Hamming + * distance. Brute force O(n²); fine for personal-library scale (5k + * covers ≈ 12.5M comparisons, runs in well under a second). + * + * Excludes pairs that are already SHA-identical (those are caught by + * upload dedup) and excludes attached-image pairs (those are + * intentionally similar to their parent). + * + * Default threshold = 10 (out of 64 bits) is a strong "same image, + * different encode" signal. + */ +export async function findNearDuplicates(opts?: { threshold?: number; limit?: number }): Promise { + const threshold = opts?.threshold ?? 10; + const limit = opts?.limit ?? 200; + const rows = rawDb.prepare(` + SELECT id, code, filename, rel_path, thumb_path AS thumbPath, sha256, phash, width, height, bytes + FROM images + WHERE deleted_at IS NULL AND parent_image_id IS NULL AND phash IS NOT NULL + ORDER BY id ASC + `).all() as Array<{ + id: number; code: string | null; filename: string; rel_path: string; thumbPath: string; + sha256: string; phash: string; width: number; height: number; bytes: number; + }>; + + const pairs: NearDupePair[] = []; + for (let i = 0; i < rows.length && pairs.length < limit; i++) { + for (let j = i + 1; j < rows.length && pairs.length < limit; j++) { + const a = rows[i]; + const b = rows[j]; + if (a.sha256 === b.sha256) continue; // SHA-identical pairs handled elsewhere + const d = hammingDistance(a.phash, b.phash); + if (d <= threshold) { + pairs.push({ + a: { id: a.id, code: a.code, filename: a.filename, thumbPath: a.thumbPath, width: a.width, height: a.height, bytes: a.bytes }, + b: { id: b.id, code: b.code, filename: b.filename, thumbPath: b.thumbPath, width: b.width, height: b.height, bytes: b.bytes }, + distance: d, + }); + } + } + } + // Sort tightest matches first, then by lowest id pair for stability. + pairs.sort((x, y) => x.distance - y.distance || x.a.id - y.a.id || x.b.id - y.b.id); + return pairs; +} diff --git a/app/actions/queue.ts b/app/actions/queue.ts new file mode 100644 index 0000000..2333a9e --- /dev/null +++ b/app/actions/queue.ts @@ -0,0 +1,8 @@ +"use server"; +import { listImagesByIds } from "@/lib/db/queries"; +import type { CardImage } from "@/components/grid/ImageCard"; + +export async function fetchQueueCovers(ids: number[]): Promise { + if (!Array.isArray(ids) || ids.length === 0) return []; + return listImagesByIds(ids); +} diff --git a/app/actions/settings.ts b/app/actions/settings.ts new file mode 100644 index 0000000..d44223c --- /dev/null +++ b/app/actions/settings.ts @@ -0,0 +1,142 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { setAppSetting, type AppSettings, type WhisperJavSettings, APP_SETTINGS_DEFAULTS } from "@/lib/db/appSettings"; + +export async function setBoolSetting( + key: "fadeTransitions" | "purgeFilesOnDelete" | "useRecycleBin", + value: boolean, +) { + setAppSetting(key, value); + revalidatePath("/"); +} + +export async function setTranscodeMode(value: "off" | "always" | "auto-predicate" | "auto-runtime") { + if (value !== "off" && value !== "always" && value !== "auto-predicate" && value !== "auto-runtime") return; + setAppSetting("transcodeMode", value); + revalidatePath("/"); +} + +export async function setNumberSetting( + key: "fadeDurationMs" | "trashRetentionDays" | "gridColumns" | "gridColumnsPortrait" | "supersededRetentionDays" | "coverPageSize", + value: number, +) { + if (!Number.isFinite(value)) return; + if (key === "gridColumns" && (value < 2 || value > 4)) return; + if (key === "gridColumnsPortrait" && (value < 4 || value > 10)) return; + if (key === "trashRetentionDays" && value < 0) return; + if (key === "supersededRetentionDays" && value < 0) return; + if (key === "coverPageSize" && (value < 25 || value > 500)) return; + setAppSetting(key, value); + revalidatePath("/"); +} + +const HEX_RE = /^#[0-9a-fA-F]{6}$/; +export async function setColorSetting( + key: "accentPrimary" | "accentSecondary", + value: string, +) { + const normalized = value === "" ? "" : value.toLowerCase(); + if (normalized !== "" && !HEX_RE.test(normalized)) return; + setAppSetting(key, normalized); + revalidatePath("/"); +} + +export async function setPaginationMode(value: "url" | "scroll") { + if (value !== "url" && value !== "scroll") return; + setAppSetting("paginationMode", value); + revalidatePath("/"); +} + +export async function setSettingsLayout(value: "sidebar" | "three-column") { + if (value !== "sidebar" && value !== "three-column") return; + setAppSetting("settingsLayout", value); + revalidatePath("/"); +} + +export async function setVideoLibraryPath(value: string) { + setAppSetting("videoLibraryPath", value.trim()); + revalidatePath("/"); +} + +export async function setPartSuffixPatterns(values: string[]) { + // Trim, drop blanks, preserve order. Validation of token grammar + // (e.g. `{N}`, `{L}`) happens client-side; storage accepts whatever + // the user typed so a malformed pattern doesn't silently disappear. + const cleaned = (values ?? []).map((v) => (v ?? "").trim()).filter(Boolean); + setAppSetting("partSuffixPatterns", cleaned); + // Reclassify on the next video scan; trigger a rescan so the change + // takes effect without a manual refresh. + try { + const { rescanVideoIndex } = await import("@/lib/video"); + await rescanVideoIndex(); + } catch (e) { + console.error("[settings] failed to rescan video index after pattern change:", e); + } + revalidatePath("/"); +} + +export async function setWhisperJavSettings(values: Partial) { + const sanitized: WhisperJavSettings = { + ...APP_SETTINGS_DEFAULTS.whisperjav, + ...values, + cliPath: typeof values.cliPath === "string" ? values.cliPath.trim() : APP_SETTINGS_DEFAULTS.whisperjav.cliPath, + }; + // Validate enum members so a bad client payload can't poison the row. + const QUALITIES: WhisperJavSettings["quality"][] = ["fast", "balanced", "qwen"]; + const SOURCE_LANGS: WhisperJavSettings["sourceLanguage"][] = ["japanese", "korean", "chinese", "english"]; + const OUTPUT_MODES: WhisperJavSettings["outputMode"][] = ["native", "direct-to-english"]; + const SENSITIVITIES: WhisperJavSettings["sensitivity"][] = ["conservative", "balanced", "aggressive"]; + const LOCATIONS: WhisperJavSettings["outputLocation"][] = ["beside-video", "data-folder"]; + if (!QUALITIES.includes(sanitized.quality)) sanitized.quality = "balanced"; + if (!SOURCE_LANGS.includes(sanitized.sourceLanguage)) sanitized.sourceLanguage = "japanese"; + if (!OUTPUT_MODES.includes(sanitized.outputMode)) sanitized.outputMode = "native"; + if (!SENSITIVITIES.includes(sanitized.sensitivity)) sanitized.sensitivity = "balanced"; + if (!LOCATIONS.includes(sanitized.outputLocation)) sanitized.outputLocation = "beside-video"; + sanitized.noSignature = sanitized.noSignature !== false; + const retention = Number(sanitized.retentionDays); + sanitized.retentionDays = Number.isFinite(retention) && retention >= 0 ? Math.floor(retention) : 30; + setAppSetting("whisperjav", sanitized); + revalidatePath("/"); +} + +export async function setSubtitleCacheLimitMb(value: number) { + if (!Number.isFinite(value) || value < 0) return; + setAppSetting("subtitleCacheLimitMb", Math.floor(value)); + revalidatePath("/"); +} + +export async function setSubtitleExtraPaths(values: string[]) { + const seen = new Set(); + const cleaned: string[] = []; + for (const v of values) { + const t = (v ?? "").trim(); + if (!t) continue; + const key = t.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + cleaned.push(t); + } + setAppSetting("subtitleExtraPaths", cleaned); + revalidatePath("/"); +} + +export async function setVideoExtraPaths(values: string[]) { + // Trim, drop blanks, dedupe (case-insensitive on Windows-friendly compare). + const seen = new Set(); + const cleaned: string[] = []; + for (const v of values) { + const t = (v ?? "").trim(); + if (!t) continue; + const key = t.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + cleaned.push(t); + } + setAppSetting("videoExtraPaths", cleaned); + revalidatePath("/"); +} + +export type WritableBoolKey = Parameters[0]; +export type WritableNumberKey = Parameters[0]; +export type WritableColorKey = Parameters[0]; +export type WritableSettings = Pick; diff --git a/app/actions/sort.ts b/app/actions/sort.ts new file mode 100644 index 0000000..c680c3b --- /dev/null +++ b/app/actions/sort.ts @@ -0,0 +1,22 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { setAppSetting } from "@/lib/db/appSettings"; +import { isValidSort, SORT_COOKIE } from "@/lib/sort"; + +const ONE_YEAR = 60 * 60 * 24 * 365; + +export async function setDefaultSort(sort: string) { + if (!isValidSort(sort)) return; + setAppSetting("defaultSort", sort); + (await cookies()).set(SORT_COOKIE, sort, { + path: "/", + maxAge: ONE_YEAR, + sameSite: "lax", + }); + revalidatePath("/"); + revalidatePath("/collection"); + revalidatePath("/actress"); + revalidatePath("/studios"); + revalidatePath("/series"); +} diff --git a/app/actions/tagCategories.ts b/app/actions/tagCategories.ts new file mode 100644 index 0000000..45c1fec --- /dev/null +++ b/app/actions/tagCategories.ts @@ -0,0 +1,91 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { rawDb, uniqueSlug } from "@/lib/db/client"; + +export interface TagCategoryRow { + id: number; + name: string; + slug: string; + color: string | null; + description: string | null; +} + +const PALETTE = ["#fbbf24", "#22d3ee", "#a78bfa", "#f472b6", "#34d399", "#fb7185", "#f97316", "#60a5fa"]; + +function nextPaletteColor(): string { + const taken = (rawDb.prepare(`SELECT color FROM tag_categories WHERE color IS NOT NULL`).all() as Array<{ color: string }>) + .map((r) => r.color); + for (const c of PALETTE) if (!taken.includes(c)) return c; + return PALETTE[Math.floor(Math.random() * PALETTE.length)]; +} + +export async function createTagCategory(name: string, color?: string, description?: string): Promise { + const trimmed = name.trim(); + if (!trimmed) return null; + const slug = uniqueSlug(rawDb, "tag_categories", trimmed); + const finalColor = color?.trim() || nextPaletteColor(); + const finalDesc = description?.trim() || null; + const row = rawDb.prepare(` + INSERT INTO tag_categories (name, slug, color, description) VALUES (?, ?, ?, ?) RETURNING * + `).get(trimmed, slug, finalColor, finalDesc) as TagCategoryRow; + revalidatePath("/category"); + revalidatePath("/tag"); + return row; +} + +export async function createTagCategoryAction(formData: FormData) { + const name = String(formData.get("name") ?? ""); + const color = String(formData.get("color") ?? ""); + const description = String(formData.get("description") ?? ""); + await createTagCategory(name, color || undefined, description || undefined); +} + +export async function renameTagCategory(id: number, name: string, color?: string, description?: string): Promise<{ slug: string; name: string } | null> { + const trimmed = name.trim(); + if (!trimmed) return null; + const current = rawDb.prepare(`SELECT name FROM tag_categories WHERE id = ?`).get(id) as { name: string } | undefined; + if (!current) return null; + const slug = uniqueSlug(rawDb, "tag_categories", trimmed, id); + // COALESCE both color AND description: passing `undefined` (caller + // omitted the field) preserves the existing value; passing an empty + // string clears it. Without COALESCE on description, the prior code + // wiped any existing description on every rename. + const colorArg = color === undefined ? null : (color.trim() || null); + const descArg = description === undefined ? null : (description.trim() || null); + if (color === undefined && description === undefined) { + rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id); + } else if (description === undefined) { + rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ?, color = COALESCE(?, color) WHERE id = ?`) + .run(trimmed, slug, colorArg, id); + } else if (color === undefined) { + rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ?, description = ? WHERE id = ?`) + .run(trimmed, slug, descArg, id); + } else { + rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ?, color = COALESCE(?, color), description = ? WHERE id = ?`) + .run(trimmed, slug, colorArg, descArg, id); + } + revalidatePath("/category"); + revalidatePath("/tag"); + return { slug, name: trimmed }; +} + +export async function deleteTagCategory(id: number) { + // ON DELETE SET NULL on tags.category_id keeps every tag intact; they + // simply become uncategorised again. + rawDb.prepare(`DELETE FROM tag_categories WHERE id = ?`).run(id); + revalidatePath("/category"); + revalidatePath("/tag"); +} + +export async function setTagCategory(tagId: number, categoryId: number | null) { + rawDb.prepare(`UPDATE tags SET category_id = ? WHERE id = ?`).run(categoryId, tagId); + revalidatePath("/category"); + revalidatePath("/tag"); +} + +export async function setTagCategoryByName(tagName: string, categoryId: number | null) { + const tag = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(tagName.trim()) as { id: number } | undefined; + if (!tag) return; + await setTagCategory(tag.id, categoryId); + revalidatePath(`/tag/${encodeURIComponent(tagName.trim())}`); +} diff --git a/app/actions/tags.ts b/app/actions/tags.ts new file mode 100644 index 0000000..3fdcd35 --- /dev/null +++ b/app/actions/tags.ts @@ -0,0 +1,149 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { rawDb, uniqueSlug } from "@/lib/db/client"; +import { redirect } from "next/navigation"; + +export async function createTag(name: string) { + const trimmed = name.trim().toLowerCase(); + if (!trimmed) return null; + const row = rawDb.prepare(` + INSERT INTO tags (name) VALUES (?) + ON CONFLICT(name) DO UPDATE SET name=excluded.name + RETURNING id + `).get(trimmed) as { id: number }; + revalidatePath("/tag"); + return row.id; +} + +export async function createTagAction(formData: FormData) { + const name = String(formData.get("name") ?? ""); + await createTag(name); + redirect("/tag"); +} + +export async function addTagToImage(imageId: number, name: string) { + const trimmed = name.trim().toLowerCase(); + if (!trimmed) return; + 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); + // Bump recency so this tag floats up in the context-menu Recent strip. + rawDb.prepare(`UPDATE tags SET last_used_at = (unixepoch() * 1000) WHERE id = ?`).run(tag.id); + revalidatePath(`/image/${imageId}`); + revalidatePath("/tag"); +} + +export type BulkImportRow = { + name: string; + category?: string | null; + color?: string | null; +}; + +export type BulkImportResult = { + ok: boolean; + added: number; + updated: number; + skipped: number; + categoriesCreated: number; + errors: Array<{ row: number; message: string }>; +}; + +const COLOR_RE = /^#[0-9a-fA-F]{6}$/; + +export async function bulkImportTags( + rows: BulkImportRow[], + opts: { createMissingCategories: boolean; updateExisting: boolean }, +): Promise { + const errors: Array<{ row: number; message: string }> = []; + let added = 0; + let updated = 0; + let skipped = 0; + let categoriesCreated = 0; + + // Cache category id lookups so we don't requery for every row that shares + // the same category. + const catCache = new Map(); + function resolveCategoryId(catName: string | null | undefined): number | null { + if (!catName) return null; + const key = catName.trim().toLowerCase(); + if (!key) return null; + const cached = catCache.get(key); + if (cached !== undefined) return cached; + const existing = rawDb.prepare(`SELECT id FROM tag_categories WHERE LOWER(name) = ?`).get(key) as { id: number } | undefined; + if (existing) { + catCache.set(key, existing.id); + return existing.id; + } + if (!opts.createMissingCategories) return null; + const trimmedName = catName.trim(); + const slug = uniqueSlug(rawDb, "tag_categories", trimmedName); + const ins = rawDb.prepare(`INSERT INTO tag_categories (name, slug) VALUES (?, ?) RETURNING id`).get(trimmedName, slug) as { id: number }; + categoriesCreated++; + catCache.set(key, ins.id); + return ins.id; + } + + const tx = rawDb.transaction(() => { + for (let i = 0; i < rows.length; i++) { + const r = rows[i]; + const name = (r.name ?? "").trim().toLowerCase(); + if (!name) { + errors.push({ row: i + 1, message: "blank name" }); + continue; + } + if (name.length > 48) { + errors.push({ row: i + 1, message: `name too long (${name.length} > 48)` }); + continue; + } + const color = r.color?.trim() || null; + if (color && !COLOR_RE.test(color)) { + errors.push({ row: i + 1, message: `invalid color "${color}" — expected #rrggbb` }); + continue; + } + const categoryId = resolveCategoryId(r.category); + + const existing = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(name) as { id: number } | undefined; + if (existing) { + if (opts.updateExisting && (color || categoryId !== null)) { + rawDb.prepare(`UPDATE tags SET color = COALESCE(?, color), category_id = COALESCE(?, category_id) WHERE id = ?`) + .run(color, categoryId, existing.id); + updated++; + } else { + skipped++; + } + continue; + } + rawDb.prepare(`INSERT INTO tags (name, color, category_id) VALUES (?, ?, ?)`).run(name, color, categoryId); + added++; + } + }); + + try { + tx(); + } catch (e) { + return { ok: false, added: 0, updated: 0, skipped: 0, categoriesCreated: 0, errors: [{ row: 0, message: (e as Error).message }] }; + } + + revalidatePath("/tag"); + revalidatePath("/category"); + return { ok: true, added, updated, skipped, categoriesCreated, errors }; +} + +export async function bulkDeleteTags(ids: number[]): Promise<{ deleted: number }> { + if (!ids || ids.length === 0) return { deleted: 0 }; + const placeholders = ids.map(() => "?").join(","); + // image_tags has ON DELETE CASCADE on tag_id, so removing tag rows + // also drops every image association. + const info = rawDb.prepare(`DELETE FROM tags WHERE id IN (${placeholders})`).run(...ids); + revalidatePath("/tag"); + revalidatePath("/category"); + return { deleted: info.changes }; +} + +export async function removeTagFromImage(imageId: number, tagId: number) { + rawDb.prepare(`DELETE FROM image_tags WHERE image_id = ? AND tag_id = ?`).run(imageId, tagId); + revalidatePath(`/image/${imageId}`); + revalidatePath("/tag"); +} + diff --git a/app/actions/trash.ts b/app/actions/trash.ts new file mode 100644 index 0000000..11780a7 --- /dev/null +++ b/app/actions/trash.ts @@ -0,0 +1,64 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { rawDb } from "@/lib/db/client"; +import { getAppSetting } from "@/lib/db/appSettings"; +import { safeJoin } from "@/lib/safePath"; + +const LIBRARY_ROOT = path.join(process.cwd(), "library"); +const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs"); + +export async function restoreImages(ids: number[]): Promise<{ restored: number }> { + if (ids.length === 0) return { restored: 0 }; + const placeholders = ids.map(() => "?").join(","); + const r = rawDb.prepare( + `UPDATE images SET deleted_at = NULL WHERE id IN (${placeholders}) AND deleted_at IS NOT NULL`, + ).run(...ids); + revalidate(); + return { restored: r.changes }; +} + +export async function purgeFromTrash(ids: number[]): Promise<{ purged: number }> { + if (ids.length === 0) return { purged: 0 }; + const placeholders = ids.map(() => "?").join(","); + const rows = rawDb.prepare( + ` + WITH targets AS ( + SELECT id FROM images WHERE deleted_at IS NOT NULL AND id IN (${placeholders}) + ) + SELECT id, rel_path, thumb_path FROM images + WHERE id IN (SELECT id FROM targets) + OR parent_image_id IN (SELECT id FROM targets) + `, + ).all(...ids) as Array<{ id: number; rel_path: string; thumb_path: string }>; + if (rows.length === 0) return { purged: 0 }; + if (getAppSetting("purgeFilesOnDelete")) { + await Promise.all(rows.flatMap((r) => { + const fileAbs = safeJoin(LIBRARY_ROOT, r.rel_path); + const thumbAbs = safeJoin(THUMB_ROOT, r.thumb_path); + return [ + fileAbs ? fs.rm(fileAbs, { force: true }) : null, + thumbAbs ? fs.rm(thumbAbs, { force: true }) : null, + ].filter((p): p is Promise => !!p); + })); + } + rawDb.prepare(`DELETE FROM images WHERE id IN (${rows.map(() => "?").join(",")})`).run(...rows.map((r) => r.id)); + revalidate(); + return { purged: rows.length }; +} + +export async function emptyTrash(): Promise<{ purged: number }> { + const ids = (rawDb.prepare(`SELECT id FROM images WHERE deleted_at IS NOT NULL`).all() as Array<{ id: number }>).map((r) => r.id); + return purgeFromTrash(ids); +} + +function revalidate() { + revalidatePath("/"); + revalidatePath("/collection"); + revalidatePath("/tag"); + revalidatePath("/actress"); + revalidatePath("/studios"); + revalidatePath("/series"); + revalidatePath("/genres"); +} diff --git a/app/actress/[slug]/page.tsx b/app/actress/[slug]/page.tsx new file mode 100644 index 0000000..bf6894c --- /dev/null +++ b/app/actress/[slug]/page.tsx @@ -0,0 +1,87 @@ +import { notFound } from "next/navigation"; +import { getActressBySlug, listImages, listActressCategories, libraryLetterCounts, listCoStars } from "@/lib/db/queries"; +import { MasonryGrid } from "@/components/grid/MasonryGrid"; +import { RegisterVisible } from "@/components/select/RegisterVisible"; +import { FilterBar } from "@/components/grid/FilterBar"; +import { LetterBar } from "@/components/grid/LetterBar"; +import { resolveSort } from "@/lib/sortServer"; +import { ActressHero } from "@/components/actress/ActressHero"; +import { CoStarsRow } from "@/components/actress/CoStarsRow"; +import { parseFilterCriteria, statusToFlags } from "@/lib/filters"; + +export const dynamic = "force-dynamic"; + +export default async function ActressPage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise>; +}) { + const { slug } = await params; + const sp = await searchParams; + const sort = await resolveSort(typeof sp.sort === "string" ? sp.sort : undefined); + const rawLetter = (typeof sp.letter === "string" ? sp.letter : "").toUpperCase(); + const letter = rawLetter === "#" ? "#" : (/^[A-Z]$/.test(rawLetter) ? rawLetter : null); + const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined; + const criteria = parseFilterCriteria(sp); + const a = getActressBySlug(decodeURIComponent(slug)); + if (!a) notFound(); + const items = listImages({ + actressId: a.id, + sort, + letter: letter ?? undefined, + search, + ...statusToFlags(criteria.status), + marks: criteria.marks, + actressIds: criteria.ids.actresses, + actressMode: criteria.mode.actresses, + studioIds: criteria.ids.studios, + seriesIds: criteria.ids.series, + genreIds: criteria.ids.genres, + genreMode: criteria.mode.genres, + collectionIds: criteria.ids.collections, + collectionMode: criteria.mode.collections, + tagIds: criteria.ids.tags, + tagMode: criteria.mode.tags, + categoryIds: criteria.ids.categories, + categoryMode: criteria.mode.categories, + }); + const allCategories = listActressCategories(); + const costars = listCoStars(a.id, 24); + const letterCounts = libraryLetterCounts({ + actressId: a.id, + search, + ...statusToFlags(criteria.status), + marks: criteria.marks, + actressIds: criteria.ids.actresses, + actressMode: criteria.mode.actresses, + studioIds: criteria.ids.studios, + seriesIds: criteria.ids.series, + genreIds: criteria.ids.genres, + genreMode: criteria.mode.genres, + collectionIds: criteria.ids.collections, + collectionMode: criteria.mode.collections, + tagIds: criteria.ids.tags, + tagMode: criteria.mode.tags, + categoryIds: criteria.ids.categories, + categoryMode: criteria.mode.categories, + }); + + return ( +
+ + + + + +
+ +
+ i.id)} /> +
+ +
+
+ ); +} diff --git a/app/actress/page.tsx b/app/actress/page.tsx new file mode 100644 index 0000000..2c9b927 --- /dev/null +++ b/app/actress/page.tsx @@ -0,0 +1,31 @@ +import { listAllActresses, listActressCategories } from "@/lib/db/queries"; +import { ActressDirectory } from "@/components/actress/ActressDirectory"; +import { ActressCreateBar } from "@/components/actress/ActressCreateBar"; +import { Users } from "lucide-react"; + +export const dynamic = "force-dynamic"; + +export default function ActressesPage() { + const items = listAllActresses(); + const categories = listActressCategories(); + return ( +
+
+
+

Cast

+

{items.length} total

+
+ +
+ + {items.length === 0 ? ( +
+ +

No actresses yet. Create one above or add from any cover.

+
+ ) : ( + + )} +
+ ); +} diff --git a/app/api/actress-portrait/[id]/route.ts b/app/api/actress-portrait/[id]/route.ts new file mode 100644 index 0000000..19525dc --- /dev/null +++ b/app/api/actress-portrait/[id]/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import path from "node:path"; +import fs from "node:fs/promises"; +import crypto from "node:crypto"; +import { revalidatePath } from "next/cache"; +import { rawDb } from "@/lib/db/client"; +import { safeJoin } from "@/lib/safePath"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const PORTRAIT_ROOT = path.join(process.cwd(), "data", "portraits"); +const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]); + +const SLOT_COLS: Record = { + "1": { path: "portrait_path", zoom: "portrait_zoom", ox: "portrait_offset_x", oy: "portrait_offset_y" }, + "2": { path: "portrait2_path", zoom: "portrait2_zoom", ox: "portrait2_offset_x", oy: "portrait2_offset_y" }, + "3": { path: "portrait3_path", zoom: "portrait3_zoom", ox: "portrait3_offset_x", oy: "portrait3_offset_y" }, + "4": { path: "portrait4_path", zoom: "portrait4_zoom", ox: "portrait4_offset_x", oy: "portrait4_offset_y" }, + "h": { path: "portraith_path", zoom: "portraith_zoom", ox: "portraith_offset_x", oy: "portraith_offset_y" }, +}; + +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + const { id } = await ctx.params; + const numId = Number(id); + if (!Number.isFinite(numId)) return NextResponse.json({ error: "bad id" }, { status: 400 }); + + const url = new URL(req.url); + const slot = (url.searchParams.get("slot") ?? "1") as keyof typeof SLOT_COLS; + const cols = SLOT_COLS[slot]; + if (!cols) return NextResponse.json({ error: "bad slot" }, { status: 400 }); + + const actress = rawDb.prepare(`SELECT id, slug, ${cols.path} AS prevPath FROM actresses WHERE id = ?`).get(numId) as + | { id: number; slug: string; prevPath: string | null } + | undefined; + if (!actress) return NextResponse.json({ error: "actress not found" }, { status: 404 }); + + const form = await req.formData(); + const file = form.get("file"); + if (!(file instanceof File)) return NextResponse.json({ error: "missing file" }, { status: 400 }); + + const ext = path.extname(file.name).toLowerCase(); + if (!ALLOWED_EXT.has(ext)) return NextResponse.json({ error: "unsupported format" }, { status: 415 }); + + const buf = Buffer.from(await file.arrayBuffer()); + const sha = crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16); + const filename = `${actress.id}-${slot}-${sha}${ext}`; + await fs.mkdir(PORTRAIT_ROOT, { recursive: true }); + await fs.writeFile(path.join(PORTRAIT_ROOT, filename), buf); + + if (actress.prevPath && actress.prevPath !== filename) { + const prevAbs = safeJoin(PORTRAIT_ROOT, actress.prevPath); + if (prevAbs) await fs.rm(prevAbs, { force: true }).catch(() => {}); + } + + rawDb.prepare(` + UPDATE actresses + SET ${cols.path} = ?, ${cols.zoom} = 1, ${cols.ox} = 0, ${cols.oy} = 0 + WHERE id = ? + `).run(filename, actress.id); + + revalidatePath("/actress"); + revalidatePath(`/actress/${actress.slug}`); + return NextResponse.json({ portraitPath: filename }); +} diff --git a/app/api/backup/export/route.ts b/app/api/backup/export/route.ts new file mode 100644 index 0000000..189896b --- /dev/null +++ b/app/api/backup/export/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { rawDb } from "@/lib/db/client"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const TABLES = [ + "images", + "studios", + "labels", + "series", + "actresses", + "genres", + "tag_categories", + "tags", + "collections", + "image_actresses", + "image_genres", + "image_tags", + "collection_images", + "actress_categories", + "actress_categories_map", + "app_settings", +]; + +export async function GET(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + const data: Record = {}; + for (const t of TABLES) { + try { + data[t] = rawDb.prepare(`SELECT * FROM ${t}`).all(); + } catch { + data[t] = []; + } + } + + const payload = { + app: "Pinkudex", + version: 1, + exportedAt: new Date().toISOString(), + tables: data, + }; + + const json = JSON.stringify(payload, null, 2); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + return new NextResponse(json, { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="pinkudex-backup-${stamp}.json"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/app/api/backup/import/route.ts b/app/api/backup/import/route.ts new file mode 100644 index 0000000..4526d1a --- /dev/null +++ b/app/api/backup/import/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import path from "node:path"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { importDatabaseTables } from "@/lib/backup/importDb"; + +// Strip absolute-path noise — only the basename is useful to the client +// and absolute paths leak filesystem layout to anything that pings the +// API on the local network. +const baseOnly = (p: string | null | undefined): string | null => + p ? path.basename(p) : null; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + let payload: { tables?: Record; version?: number; app?: string }; + try { + payload = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const tables = payload.tables; + if (!tables || typeof tables !== "object") { + return NextResponse.json({ error: "Missing 'tables' object" }, { status: 400 }); + } + if (!Array.isArray(tables.actresses) && !Array.isArray(tables.images)) { + return NextResponse.json({ error: "Backup payload is missing core tables." }, { status: 400 }); + } + + const result = await importDatabaseTables(tables); + const snapshotName = baseOnly(result.snapshotPath); + if (!result.ok) { + return NextResponse.json( + { + error: result.error, + snapshotName, + hint: snapshotName + ? `Live DB rolled back. A pre-import snapshot was saved as ${snapshotName}.` + : undefined, + }, + { status: 500 }, + ); + } + + return NextResponse.json({ + ok: true, + counts: result.counts, + errors: result.errors, + snapshotName, + }); +} diff --git a/app/api/backup/library-export/route.ts b/app/api/backup/library-export/route.ts new file mode 100644 index 0000000..c3681df --- /dev/null +++ b/app/api/backup/library-export/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server"; +import path from "node:path"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import yazl from "yazl"; +import { rawDb } from "@/lib/db/client"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 3600; + +const ROOT = process.cwd(); + +const SOURCES: Array<{ absDir: string; zipPrefix: string; skipRel?: (rel: string) => boolean }> = [ + { + absDir: path.join(ROOT, "library"), + zipPrefix: "library", + skipRel: (rel) => rel === ".superseded" || rel.startsWith(".superseded/"), + }, + { absDir: path.join(ROOT, "data", "thumbs"), zipPrefix: "data/thumbs" }, + { absDir: path.join(ROOT, "data", "portraits"), zipPrefix: "data/portraits" }, + { absDir: path.join(ROOT, "data", "category-covers"), zipPrefix: "data/category-covers" }, + { absDir: path.join(ROOT, "data", "collection-covers"), zipPrefix: "data/collection-covers" }, +]; + +const DB_TABLES = [ + "images", "studios", "labels", "series", "actresses", "genres", + "tag_categories", "tags", "collections", "image_actresses", "image_genres", + "image_tags", "collection_images", "actress_categories", + "actress_categories_map", "app_settings", +]; + +type Entry = { abs: string; rel: string; size: number; mtime: Date }; + +async function walk(absDir: string, zipPrefix: string, skipRel?: (rel: string) => boolean): Promise { + const out: Entry[] = []; + const stack: Array<{ abs: string; rel: string }> = [{ abs: absDir, rel: "" }]; + while (stack.length) { + const { abs, rel } = stack.pop()!; + let dirents: fs.Dirent[]; + try { + dirents = await fsp.readdir(abs, { withFileTypes: true }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") continue; + throw e; + } + for (const d of dirents) { + const childRel = rel ? `${rel}/${d.name}` : d.name; + if (skipRel?.(childRel)) continue; + const childAbs = path.join(abs, d.name); + if (d.isDirectory()) { + stack.push({ abs: childAbs, rel: childRel }); + } else if (d.isFile()) { + const st = await fsp.stat(childAbs); + out.push({ abs: childAbs, rel: `${zipPrefix}/${childRel}`, size: st.size, mtime: st.mtime }); + } + } + } + return out; +} + +function buildDatabaseJson(): Buffer { + const data: Record = {}; + for (const t of DB_TABLES) { + try { data[t] = rawDb.prepare(`SELECT * FROM ${t}`).all(); } + catch { data[t] = []; } + } + const payload = { + app: "Pinkudex", + version: 1, + exportedAt: new Date().toISOString(), + tables: data, + }; + return Buffer.from(JSON.stringify(payload, null, 2), "utf8"); +} + +export async function GET(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + let entries: Entry[] = []; + for (const src of SOURCES) { + entries = entries.concat(await walk(src.absDir, src.zipPrefix, src.skipRel)); + } + + // Materialise the zip to a temp file first. Streaming yazl's outputStream + // straight through NextResponse hung mid-download (turbopack streaming / + // adapter chunking weirdness). A temp file guarantees: exact Content-Length + // from fs.stat, full backpressure handling by Node's createReadStream, and + // resumability if the browser retries. Costs one extra pass over disk — + // acceptable for an explicit user-triggered backup. + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pinkudex-export-")); + const tmpZip = path.join(tmpDir, "library.zip"); + + const zip = new yazl.ZipFile(); + zip.addBuffer(buildDatabaseJson(), "database.json", { compress: false }); + for (const e of entries) { + zip.addReadStream(fs.createReadStream(e.abs), e.rel, { + compress: false, + size: e.size, + mtime: e.mtime, + }); + } + zip.end(); + + await pipeline( + zip.outputStream as unknown as Readable, + fs.createWriteStream(tmpZip), + ); + + const { size } = await fsp.stat(tmpZip); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + + const fileStream = fs.createReadStream(tmpZip); + // Best-effort cleanup once the stream is fully consumed (success or abort). + fileStream.on("close", () => { + fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + }); + + const webStream = Readable.toWeb(fileStream) as unknown as ReadableStream; + return new NextResponse(webStream, { + headers: { + "Content-Type": "application/zip", + "Content-Length": String(size), + "Content-Disposition": `attachment; filename="pinkudex-library-${stamp}.zip"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/app/api/backup/library-import/route.ts b/app/api/backup/library-import/route.ts new file mode 100644 index 0000000..95115ce --- /dev/null +++ b/app/api/backup/library-import/route.ts @@ -0,0 +1,257 @@ +import { NextRequest, NextResponse } from "next/server"; +import path from "node:path"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import yauzl from "yauzl"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { importDatabaseTables, restoreDatabaseSnapshot } from "@/lib/backup/importDb"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 3600; + +const ROOT = process.cwd(); + +// Folders the export route ships and that we expect to restore. zipPrefix is +// the path inside the archive; absDir is where it lives on disk. +const TARGETS: Array<{ zipPrefix: string; absDir: string }> = [ + { zipPrefix: "library", absDir: path.join(ROOT, "library") }, + { zipPrefix: "data/thumbs", absDir: path.join(ROOT, "data", "thumbs") }, + { zipPrefix: "data/portraits", absDir: path.join(ROOT, "data", "portraits") }, + { zipPrefix: "data/category-covers", absDir: path.join(ROOT, "data", "category-covers") }, + { zipPrefix: "data/collection-covers", absDir: path.join(ROOT, "data", "collection-covers") }, +]; + +function openZip(zipPath: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true, autoClose: false }, (err, zf) => { + if (err || !zf) return reject(err ?? new Error("Failed to open zip")); + resolve(zf); + }); + }); +} + +function readEntryStream(zf: yauzl.ZipFile, entry: yauzl.Entry): Promise { + return new Promise((resolve, reject) => { + zf.openReadStream(entry, (err, stream) => { + if (err || !stream) return reject(err ?? new Error("Failed to open entry stream")); + resolve(stream); + }); + }); +} + +// Reject zip-slip / path-traversal entries: the resolved destination must +// stay strictly inside the staging root. Without this, a crafted entry +// named "../../etc/passwd" would write outside the staging folder. +function safeJoin(root: string, rel: string): string | null { + const resolved = path.resolve(root, rel); + const rel2 = path.relative(root, resolved); + if (rel2.startsWith("..") || path.isAbsolute(rel2)) return null; + return resolved; +} + +async function extractAll(zipPath: string, stagingRoot: string): Promise { + const zf = await openZip(zipPath); + try { + await new Promise((resolve, reject) => { + zf.on("error", reject); + zf.on("end", resolve); + zf.on("entry", async (entry: yauzl.Entry) => { + try { + const isDir = /\/$/.test(entry.fileName); + const dest = safeJoin(stagingRoot, entry.fileName); + if (!dest) { + // Skip suspicious entries silently and continue. + zf.readEntry(); + return; + } + if (isDir) { + await fsp.mkdir(dest, { recursive: true }); + zf.readEntry(); + return; + } + await fsp.mkdir(path.dirname(dest), { recursive: true }); + const rs = await readEntryStream(zf, entry); + await pipeline(rs, fs.createWriteStream(dest)); + zf.readEntry(); + } catch (e) { + reject(e); + } + }); + zf.readEntry(); + }); + } finally { + zf.close(); + } +} + +async function rollbackMediaSwap( + renamedBackups: Array<{ from: string; to: string }>, + movedTargets: string[], + ts: string, +): Promise { + const errors: string[] = []; + for (const tgt of [...TARGETS].reverse()) { + const backup = renamedBackups.find((r) => r.from === tgt.absDir); + const wasMoved = movedTargets.includes(tgt.zipPrefix); + if (!backup && !wasMoved) continue; + + if (wasMoved) { + try { + await fsp.access(tgt.absDir); + await fsp.rename(tgt.absDir, `${tgt.absDir}.failed-restore-${ts}`); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + errors.push(`Could not move failed ${tgt.zipPrefix}: ${(e as Error).message}`); + } + } + } + + if (backup) { + try { + await fsp.rename(backup.to, backup.from); + } catch (e) { + errors.push(`Could not restore backup ${backup.to}: ${(e as Error).message}`); + } + } + } + return errors; +} + +export async function POST(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + if (!req.body) { + return NextResponse.json({ error: "Missing request body" }, { status: 400 }); + } + + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pinkudex-import-")); + const tmpZip = path.join(tmpDir, "upload.zip"); + const staging = path.join(tmpDir, "staging"); + + try { + // 1) Stream upload to disk. Buffering a multi-GB upload in memory is a + // non-starter; piping the request body straight to a file is constant-RAM. + await pipeline( + Readable.fromWeb(req.body as unknown as import("node:stream/web").ReadableStream), + fs.createWriteStream(tmpZip), + ); + + // 2) Extract everything to staging. If anything throws here we abort + // before touching live state. + await fsp.mkdir(staging, { recursive: true }); + await extractAll(tmpZip, staging); + + // 3) Read database.json and validate. + const dbJsonPath = path.join(staging, "database.json"); + let dbJsonRaw: string; + try { + dbJsonRaw = await fsp.readFile(dbJsonPath, "utf8"); + } catch { + return NextResponse.json( + { error: "Archive is missing database.json — not a Pinkudex library export." }, + { status: 400 }, + ); + } + let parsed: { tables?: Record }; + try { + parsed = JSON.parse(dbJsonRaw); + } catch { + return NextResponse.json({ error: "database.json is not valid JSON." }, { status: 400 }); + } + const tables = parsed.tables; + if (!tables || typeof tables !== "object") { + return NextResponse.json({ error: "database.json missing 'tables' object." }, { status: 400 }); + } + if (!Array.isArray(tables.actresses) && !Array.isArray(tables.images)) { + return NextResponse.json( + { error: "database.json is missing core tables." }, + { status: 400 }, + ); + } + + // 4) Import DB. On failure the helper restores a pre-import .bak snapshot + // and we abort BEFORE swapping any folders so live media is untouched. + const dbResult = await importDatabaseTables(tables); + if (!dbResult.ok) { + return NextResponse.json( + { + error: `Database import failed: ${dbResult.error}`, + snapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null, + hint: "Media folders were not modified. The DB was rolled back from the pre-import snapshot.", + }, + { status: 500 }, + ); + } + + // 5) Swap media folders. Existing folders renamed to *.pre-restore- + // for manual rollback. New folders moved out of staging into place. + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const renamedBackups: Array<{ from: string; to: string }> = []; + const movedTargets: string[] = []; + + try { + for (const tgt of TARGETS) { + const stagedSrc = path.join(staging, tgt.zipPrefix); + let stagedExists = false; + try { + const st = await fsp.stat(stagedSrc); + stagedExists = st.isDirectory(); + } catch {} + if (!stagedExists) continue; + + // Rename existing folder if present. + try { + await fsp.access(tgt.absDir); + const backupPath = `${tgt.absDir}.pre-restore-${ts}`; + await fsp.rename(tgt.absDir, backupPath); + renamedBackups.push({ from: tgt.absDir, to: backupPath }); + } catch { + // Didn't exist — fine. + } + + await fsp.mkdir(path.dirname(tgt.absDir), { recursive: true }); + await fsp.rename(stagedSrc, tgt.absDir); + movedTargets.push(tgt.zipPrefix); + } + } catch (e) { + const rollbackErrors = await rollbackMediaSwap(renamedBackups, movedTargets, ts); + if (dbResult.snapshotPath) { + try { + await restoreDatabaseSnapshot(dbResult.snapshotPath); + } catch (restoreErr) { + rollbackErrors.push(`Could not restore DB snapshot: ${(restoreErr as Error).message}`); + } + } + return NextResponse.json( + { + error: `Library restore failed during media swap: ${(e as Error).message}`, + dbSnapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null, + mediaBackupNames: renamedBackups.map((r) => path.basename(r.to)), + rollbackErrors, + }, + { status: 500 }, + ); + } + + return NextResponse.json({ + ok: true, + counts: dbResult.counts, + errors: dbResult.errors, + dbSnapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null, + mediaRestored: movedTargets, + mediaBackupNames: renamedBackups.map((r) => path.basename(r.to)), + }); + } catch (e) { + return NextResponse.json( + { error: `Library restore failed: ${(e as Error).message}` }, + { status: 500 }, + ); + } finally { + fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } +} diff --git a/app/api/category-cover-file/[file]/route.ts b/app/api/category-cover-file/[file]/route.ts new file mode 100644 index 0000000..42c80e7 --- /dev/null +++ b/app/api/category-cover-file/[file]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest } from "next/server"; +import path from "node:path"; +import fs from "node:fs/promises"; +import crypto from "node:crypto"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const COVER_ROOT = path.join(process.cwd(), "data", "category-covers"); +const IMAGE_MIME: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", +}; + +export async function GET(_req: NextRequest, ctx: { params: Promise<{ file: string }> }) { + const { file } = await ctx.params; + const name = decodeURIComponent(file); + // Stored filenames are always `${id}-${slot}-${sha16}.${ext}`. Reject + // anything containing path separators or traversal segments before + // touching the disk. + if (name.includes("/") || name.includes("\\") || name.includes("..")) { + return new Response("not found", { status: 404 }); + } + const abs = path.join(COVER_ROOT, name); + try { + const buf = await fs.readFile(abs); + const ext = path.extname(abs).toLowerCase(); + const etag = `"${crypto.createHash("sha256").update(buf).digest("hex")}"`; + return new Response(new Uint8Array(buf), { + headers: { + "Content-Type": IMAGE_MIME[ext] ?? "application/octet-stream", + "Content-Disposition": rfc5987Disposition(name), + "Cache-Control": "public, max-age=0, must-revalidate", + ETag: etag, + }, + }); + } catch { + return new Response("not found", { status: 404 }); + } +} + +function rfc5987Disposition(filename: string): string { + // ASCII-safe fallback: strip non-ASCII + escape quotes/backslashes for + // the legacy `filename=` token. UTF-8 path uses RFC 5987 percent-encoding. + const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + const utf8 = encodeURIComponent(filename); + return `inline; filename="${ascii}"; filename*=UTF-8''${utf8}`; +} diff --git a/app/api/category-cover/[id]/route.ts b/app/api/category-cover/[id]/route.ts new file mode 100644 index 0000000..22f00ef --- /dev/null +++ b/app/api/category-cover/[id]/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import path from "node:path"; +import fs from "node:fs/promises"; +import crypto from "node:crypto"; +import { revalidatePath } from "next/cache"; +import { rawDb } from "@/lib/db/client"; +import { safeJoin } from "@/lib/safePath"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const COVER_ROOT = path.join(process.cwd(), "data", "category-covers"); +const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]); + +const SLOT_COLS: Record = { + portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" }, + landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" }, +}; + +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + const { id } = await ctx.params; + const numId = Number(id); + if (!Number.isFinite(numId)) return NextResponse.json({ error: "bad id" }, { status: 400 }); + + const url = new URL(req.url); + const slot = (url.searchParams.get("slot") ?? "portrait") as keyof typeof SLOT_COLS; + const cols = SLOT_COLS[slot]; + if (!cols) return NextResponse.json({ error: "bad slot" }, { status: 400 }); + + const cat = rawDb.prepare(`SELECT id, slug, ${cols.path} AS prevPath FROM tag_categories WHERE id = ?`).get(numId) as + | { id: number; slug: string; prevPath: string | null } + | undefined; + if (!cat) return NextResponse.json({ error: "category not found" }, { status: 404 }); + + const form = await req.formData(); + const file = form.get("file"); + if (!(file instanceof File)) return NextResponse.json({ error: "missing file" }, { status: 400 }); + + const ext = path.extname(file.name).toLowerCase(); + if (!ALLOWED_EXT.has(ext)) return NextResponse.json({ error: "unsupported format" }, { status: 415 }); + + const buf = Buffer.from(await file.arrayBuffer()); + const sha = crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16); + const filename = `${cat.id}-${slot}-${sha}${ext}`; + await fs.mkdir(COVER_ROOT, { recursive: true }); + await fs.writeFile(path.join(COVER_ROOT, filename), buf); + + // Replace any previous file in this slot, unless the bytes happened to + // hash to the same name (in which case we just kept it). + if (cat.prevPath && cat.prevPath !== filename) { + const prevAbs = safeJoin(COVER_ROOT, cat.prevPath); + if (prevAbs) await fs.rm(prevAbs, { force: true }).catch(() => {}); + } + + rawDb.prepare(` + UPDATE tag_categories + SET ${cols.path} = ?, ${cols.zoom} = 1, ${cols.ox} = 0, ${cols.oy} = 0 + WHERE id = ? + `).run(filename, cat.id); + + revalidatePath("/category"); + revalidatePath(`/category/${cat.slug}`); + return NextResponse.json({ coverPath: filename }); +} diff --git a/app/api/collection-cover-file/[file]/route.ts b/app/api/collection-cover-file/[file]/route.ts new file mode 100644 index 0000000..8beb878 --- /dev/null +++ b/app/api/collection-cover-file/[file]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest } from "next/server"; +import path from "node:path"; +import fs from "node:fs/promises"; +import crypto from "node:crypto"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const COVER_ROOT = path.join(process.cwd(), "data", "collection-covers"); +const IMAGE_MIME: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", +}; + +export async function GET(_req: NextRequest, ctx: { params: Promise<{ file: string }> }) { + const { file } = await ctx.params; + const name = decodeURIComponent(file); + if (name.includes("/") || name.includes("\\") || name.includes("..")) { + return new Response("not found", { status: 404 }); + } + const abs = path.join(COVER_ROOT, name); + try { + const buf = await fs.readFile(abs); + const ext = path.extname(abs).toLowerCase(); + const etag = `"${crypto.createHash("sha256").update(buf).digest("hex")}"`; + return new Response(new Uint8Array(buf), { + headers: { + "Content-Type": IMAGE_MIME[ext] ?? "application/octet-stream", + "Content-Disposition": rfc5987Disposition(name), + "Cache-Control": "public, max-age=0, must-revalidate", + ETag: etag, + }, + }); + } catch { + return new Response("not found", { status: 404 }); + } +} + +function rfc5987Disposition(filename: string): string { + const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + const utf8 = encodeURIComponent(filename); + return `inline; filename="${ascii}"; filename*=UTF-8''${utf8}`; +} diff --git a/app/api/collection-cover/[id]/route.ts b/app/api/collection-cover/[id]/route.ts new file mode 100644 index 0000000..a9b2176 --- /dev/null +++ b/app/api/collection-cover/[id]/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import path from "node:path"; +import fs from "node:fs/promises"; +import crypto from "node:crypto"; +import { revalidatePath } from "next/cache"; +import { rawDb } from "@/lib/db/client"; +import { safeJoin } from "@/lib/safePath"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const COVER_ROOT = path.join(process.cwd(), "data", "collection-covers"); +const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]); + +const SLOT_COLS: Record = { + portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" }, + landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" }, +}; + +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + const { id } = await ctx.params; + const numId = Number(id); + if (!Number.isFinite(numId)) return NextResponse.json({ error: "bad id" }, { status: 400 }); + + const url = new URL(req.url); + const slot = (url.searchParams.get("slot") ?? "portrait") as keyof typeof SLOT_COLS; + const cols = SLOT_COLS[slot]; + if (!cols) return NextResponse.json({ error: "bad slot" }, { status: 400 }); + + const coll = rawDb.prepare(`SELECT id, slug, ${cols.path} AS prevPath FROM collections WHERE id = ?`).get(numId) as + | { id: number; slug: string; prevPath: string | null } + | undefined; + if (!coll) return NextResponse.json({ error: "collection not found" }, { status: 404 }); + + const form = await req.formData(); + const file = form.get("file"); + if (!(file instanceof File)) return NextResponse.json({ error: "missing file" }, { status: 400 }); + + const ext = path.extname(file.name).toLowerCase(); + if (!ALLOWED_EXT.has(ext)) return NextResponse.json({ error: "unsupported format" }, { status: 415 }); + + const buf = Buffer.from(await file.arrayBuffer()); + const sha = crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16); + const filename = `${coll.id}-${slot}-${sha}${ext}`; + await fs.mkdir(COVER_ROOT, { recursive: true }); + await fs.writeFile(path.join(COVER_ROOT, filename), buf); + + if (coll.prevPath && coll.prevPath !== filename) { + const prevAbs = safeJoin(COVER_ROOT, coll.prevPath); + if (prevAbs) await fs.rm(prevAbs, { force: true }).catch(() => {}); + } + + rawDb.prepare(` + UPDATE collections + SET ${cols.path} = ?, ${cols.zoom} = 1, ${cols.ox} = 0, ${cols.oy} = 0 + WHERE id = ? + `).run(filename, coll.id); + + revalidatePath("/collection"); + revalidatePath(`/collection/${coll.slug}`); + return NextResponse.json({ coverPath: filename }); +} diff --git a/app/api/covers/route.ts b/app/api/covers/route.ts new file mode 100644 index 0000000..81e2f3b --- /dev/null +++ b/app/api/covers/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { listImages, countImages } from "@/lib/db/queries"; +import { resolveSort } from "@/lib/sortServer"; +import { parseFilterCriteria, statusToFlags } from "@/lib/filters"; +import { getAppSetting } from "@/lib/db/appSettings"; +import type { LibraryView } from "@/components/grid/ViewToggle"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Paginated covers feed for client-side infinite-scroll appends. + * Mirrors the SSR filter shape in app/page.tsx — every filter the + * grid supports (letter, search, sort, marks, multi-select tabs) + * resolves through the same listImages/countImages path. + */ +export async function GET(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const sp = req.nextUrl.searchParams; + // Ape Object.fromEntries for plain access matching page params. + const params: Record = {}; + for (const [k, v] of sp.entries()) { + const cur = params[k]; + if (cur == null) params[k] = v; + else if (Array.isArray(cur)) cur.push(v); + else params[k] = [cur, v]; + } + + const criteria = parseFilterCriteria(params); + const sort = await resolveSort(typeof params.sort === "string" ? params.sort : undefined); + const rawLetter = (typeof params.letter === "string" ? params.letter : "").toUpperCase(); + const letter = rawLetter === "#" ? "#" : (/^[A-Z]$/.test(rawLetter) ? rawLetter : null); + const search = (typeof params.q === "string" ? params.q.trim() : "") || undefined; + // view is purely a presentational hint; included for symmetry but + // doesn't affect query. + void (params.view === "portrait" ? "portrait" : "landscape" as LibraryView); + + const rawPage = typeof params.page === "string" ? Number(params.page) : NaN; + const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1; + + const filterOpts = { + sort, + letter: letter ?? undefined, + search, + ...statusToFlags(criteria.status), + marks: criteria.marks, + actressIds: criteria.ids.actresses, + actressMode: criteria.mode.actresses, + studioIds: criteria.ids.studios, + seriesIds: criteria.ids.series, + genreIds: criteria.ids.genres, + genreMode: criteria.mode.genres, + collectionIds: criteria.ids.collections, + collectionMode: criteria.mode.collections, + tagIds: criteria.ids.tags, + tagMode: criteria.mode.tags, + categoryIds: criteria.ids.categories, + categoryMode: criteria.mode.categories, + }; + + const PAGE_SIZE = Math.max(25, Math.min(500, getAppSetting("coverPageSize") ?? 100)); + const totalCount = countImages(filterOpts); + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const effectivePage = Math.min(page, totalPages); + const offset = (effectivePage - 1) * PAGE_SIZE; + const items = listImages({ ...filterOpts, limit: PAGE_SIZE, offset }); + + return NextResponse.json( + { items, page: effectivePage, totalPages, totalCount, hasMore: effectivePage < totalPages }, + { headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/app/api/image/[file]/route.ts b/app/api/image/[file]/route.ts new file mode 100644 index 0000000..fde5fb9 --- /dev/null +++ b/app/api/image/[file]/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; +import { serveImage } from "@/lib/api/serveAssets"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest, ctx: { params: Promise<{ file: string }> }) { + const { file } = await ctx.params; + const url = new URL(req.url); + const id = url.searchParams.get("id"); + const codeFromPath = decodeURIComponent(file).replace(/\.[^.]+$/, ""); + // Don't try to look up by code when the path is the "image-" fallback. + const code = codeFromPath.startsWith("image-") ? null : codeFromPath; + return serveImage(req, { id, code }); +} diff --git a/app/api/manual-subtitle/[code]/route.ts b/app/api/manual-subtitle/[code]/route.ts new file mode 100644 index 0000000..8381ee0 --- /dev/null +++ b/app/api/manual-subtitle/[code]/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { revalidatePath } from "next/cache"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { + attachManualSubtitle, + detachManualSubtitle, + listManualSubtitlesForVariant, +} from "@/lib/video/manualSubtitles"; +import { SUBTITLE_EXTS } from "@/lib/video/subtitles"; +import { isAllowedSubtitlePath } from "@/lib/video/subtitleAccess"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +interface AttachBody { + partIdx?: number; + abs?: string; +} + +export async function POST(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const body = (await req.json().catch(() => ({}))) as AttachBody; + const partIdx = typeof body.partIdx === "number" && Number.isFinite(body.partIdx) ? Math.max(0, body.partIdx) : 0; + const abs = typeof body.abs === "string" ? body.abs.trim() : ""; + if (!abs) return NextResponse.json({ error: "Missing abs" }, { status: 400 }); + + const ext = path.extname(abs).toLowerCase(); + if (!(SUBTITLE_EXTS as readonly string[]).includes(ext)) { + return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 }); + } + + // Containment: only attach paths that already pass the same allowlist + // the track endpoint enforces (configured roots / generated-subtitles / + // session-trusted via /api/pick-file). Without this check, any local + // POST could persist an arbitrary on-disk path into manual_subtitles + // and gain permanent read access through the track endpoint. + const absResolved = path.resolve(abs); + if (!isAllowedSubtitlePath(absResolved)) { + return NextResponse.json({ error: "Subtitle path not allowed" }, { status: 403 }); + } + + // Sanity-check the file is readable. Rejecting now beats silent + // failure later when the picker tries to fetch the track. + try { + await fs.access(absResolved); + } catch { + return NextResponse.json({ error: "File not accessible" }, { status: 404 }); + } + + attachManualSubtitle(decoded, partIdx, absResolved); + revalidatePath("/id/[code]", "page"); + return NextResponse.json({ ok: true }); +} + +export async function DELETE(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const partRaw = req.nextUrl.searchParams.get("part"); + const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0); + const abs = req.nextUrl.searchParams.get("abs") ?? ""; + if (!abs) return NextResponse.json({ error: "Missing abs" }, { status: 400 }); + + detachManualSubtitle(decoded, partIdx, abs); + return NextResponse.json({ ok: true }); +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const partRaw = req.nextUrl.searchParams.get("part"); + const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0); + return NextResponse.json({ entries: listManualSubtitlesForVariant(decoded, partIdx) }); +} diff --git a/app/api/pick-file/route.ts b/app/api/pick-file/route.ts new file mode 100644 index 0000000..2b1e160 --- /dev/null +++ b/app/api/pick-file/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from "next/server"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { trustSubtitlePath } from "@/lib/video/subtitleAccess"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Open a native OS file-picker dialog and return the absolute path of + * the selected file. Mirrors /api/pick-folder. Currently scoped to + * subtitle files — when a subtitle is picked, the path is added to the + * session-trusted set so the subtitle track endpoint will serve it + * even if it lives outside any indexed video root. + */ +export async function POST(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const body = await req.json().catch(() => ({})); + const startPath = typeof body.start === "string" ? body.start : ""; + const purpose = typeof body.purpose === "string" ? body.purpose : "subtitle"; + + try { + const picked = await runPicker(startPath, purpose); + if (!picked) return NextResponse.json({ path: null, cancelled: true }); + const abs = path.resolve(picked); + if (purpose === "subtitle") { + trustSubtitlePath(abs); + } + return NextResponse.json({ path: abs }); + } catch (e) { + return NextResponse.json({ error: (e as Error).message, path: null }, { status: 500 }); + } +} + +function runPicker(startPath: string, purpose: string): Promise { + if (process.platform === "win32") return pickerWindows(startPath, purpose); + if (process.platform === "darwin") return pickerMacOS(startPath, purpose); + return pickerLinux(startPath, purpose); +} + +function pickerWindows(startPath: string, purpose: string): Promise { + // User-controlled values (startPath, filter) are passed via env vars so + // PowerShell never parses them as code. The script body itself contains + // no interpolation — only literal references to $env:PINKUDEX_PICK_*. + const filter = purpose === "subtitle" + ? "Subtitle files (*.srt;*.vtt;*.ass;*.ssa)|*.srt;*.vtt;*.ass;*.ssa|All files (*.*)|*.*" + : "All files (*.*)|*.*"; + const script = ` + Add-Type -AssemblyName System.Windows.Forms | Out-Null + $dlg = New-Object System.Windows.Forms.OpenFileDialog + $dlg.Title = 'Pinkudex — pick a file' + $dlg.Filter = $env:PINKUDEX_PICK_FILTER + $dlg.Multiselect = $false + if ($env:PINKUDEX_PICK_START) { try { $dlg.InitialDirectory = $env:PINKUDEX_PICK_START } catch {} } + $owner = New-Object System.Windows.Forms.Form + $owner.TopMost = $true + $owner.Opacity = 0 + $owner.ShowInTaskbar = $false + $result = $dlg.ShowDialog($owner) + if ($result -eq [System.Windows.Forms.DialogResult]::OK) { + Write-Output $dlg.FileName + } + `.trim(); + return runProcess("powershell.exe", ["-NoProfile", "-Sta", "-Command", script], { + PINKUDEX_PICK_START: startPath, + PINKUDEX_PICK_FILTER: filter, + }); +} + +function pickerMacOS(startPath: string, purpose: string): Promise { + const startClause = startPath + ? ` default location (POSIX file "${startPath.replace(/"/g, '\\"')}")` + : ""; + const typeClause = purpose === "subtitle" + ? ` of type {"srt", "vtt", "ass", "ssa"}` + : ""; + const script = `try + set f to choose file with prompt "Pinkudex — pick a file"${typeClause}${startClause} + return POSIX path of f + on error number -128 + return "" + end try`; + return runProcess("osascript", ["-e", script]); +} + +function pickerLinux(startPath: string, purpose: string): Promise { + const args = ["--file-selection", "--title=Pinkudex — pick a file"]; + if (purpose === "subtitle") { + args.push("--file-filter=Subtitles | *.srt *.vtt *.ass *.ssa"); + args.push("--file-filter=All files | *"); + } + if (startPath) args.push(`--filename=${startPath}`); + return runProcess("zenity", args).catch((e) => { + throw new Error(`Linux file pickers require zenity to be installed (${(e as Error).message})`); + }); +} + +function runProcess( + cmd: string, + args: string[], + extraEnv?: Record, +): Promise { + return new Promise((resolve, reject) => { + const env = extraEnv ? { ...process.env, ...extraEnv } : process.env; + const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env }); + let out = ""; + let err = ""; + child.stdout.on("data", (b) => { out += b.toString(); }); + child.stderr.on("data", (b) => { err += b.toString(); }); + child.on("error", (e) => reject(e)); + child.on("close", (code) => { + if (code !== 0 && code !== 1 && code !== null) { + reject(new Error(err.trim() || `picker exited with code ${code}`)); + return; + } + const trimmed = out.trim().replace(/\r/g, ""); + resolve(trimmed || null); + }); + }); +} diff --git a/app/api/pick-folder/route.ts b/app/api/pick-folder/route.ts new file mode 100644 index 0000000..1793f77 --- /dev/null +++ b/app/api/pick-folder/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { spawn } from "node:child_process"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Open a native OS folder-picker dialog and return the absolute path the + * user selected. Works because Pinkudex is local-only — the Next.js + * server has the same desktop session as the browser. Returns + * `{ path: null }` if the user cancels. + * + * Windows: PowerShell + WinForms FolderBrowserDialog. + * macOS: osascript "choose folder". + * Linux: zenity (must be installed). + */ +export async function POST(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const body = await req.json().catch(() => ({})); + const startPath = typeof body.start === "string" ? body.start : ""; + + try { + const path = await runPicker(startPath); + return NextResponse.json({ path }); + } catch (e) { + return NextResponse.json({ error: (e as Error).message, path: null }, { status: 500 }); + } +} + +function runPicker(startPath: string): Promise { + if (process.platform === "win32") return pickerWindows(startPath); + if (process.platform === "darwin") return pickerMacOS(startPath); + return pickerLinux(startPath); +} + +function pickerWindows(startPath: string): Promise { + // STA threading is required for WinForms dialogs in PowerShell. + // -Sta keeps it; -NoProfile avoids whatever the user's profile prints. + // startPath is passed via env var so PowerShell never parses it as code. + const script = ` + Add-Type -AssemblyName System.Windows.Forms | Out-Null + $dlg = New-Object System.Windows.Forms.FolderBrowserDialog + $dlg.Description = 'Pinkudex — pick a folder' + $dlg.ShowNewFolderButton = $false + if ($env:PINKUDEX_PICK_START) { try { $dlg.SelectedPath = $env:PINKUDEX_PICK_START } catch {} } + $owner = New-Object System.Windows.Forms.Form + $owner.TopMost = $true + $owner.Opacity = 0 + $owner.ShowInTaskbar = $false + $result = $dlg.ShowDialog($owner) + if ($result -eq [System.Windows.Forms.DialogResult]::OK) { + Write-Output $dlg.SelectedPath + } + `.trim(); + return runProcess("powershell.exe", ["-NoProfile", "-Sta", "-Command", script], { + PINKUDEX_PICK_START: startPath, + }); +} + +function pickerMacOS(startPath: string): Promise { + const startClause = startPath + ? ` default location (POSIX file "${startPath.replace(/"/g, '\\"')}")` + : ""; + const script = `try + set f to choose folder with prompt "Pinkudex — pick a folder"${startClause} + return POSIX path of f + on error number -128 + return "" + end try`; + return runProcess("osascript", ["-e", script]); +} + +function pickerLinux(startPath: string): Promise { + const args = ["--file-selection", "--directory", "--title=Pinkudex — pick a folder"]; + if (startPath) args.push(`--filename=${startPath.endsWith("/") ? startPath : startPath + "/"}`); + return runProcess("zenity", args).catch((e) => { + throw new Error(`Linux folder pickers require zenity to be installed (${(e as Error).message})`); + }); +} + +function runProcess( + cmd: string, + args: string[], + extraEnv?: Record, +): Promise { + return new Promise((resolve, reject) => { + const env = extraEnv ? { ...process.env, ...extraEnv } : process.env; + const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env }); + let out = ""; + let err = ""; + child.stdout.on("data", (b) => { out += b.toString(); }); + child.stderr.on("data", (b) => { err += b.toString(); }); + child.on("error", (e) => reject(e)); + child.on("close", (code) => { + // Cancel paths return non-zero (zenity) or empty stdout — treat as null. + if (code !== 0 && code !== 1 && code !== null) { + reject(new Error(err.trim() || `picker exited with code ${code}`)); + return; + } + const trimmed = out.trim().replace(/\r/g, ""); + resolve(trimmed || null); + }); + }); +} diff --git a/app/api/portrait/[file]/route.ts b/app/api/portrait/[file]/route.ts new file mode 100644 index 0000000..54a9149 --- /dev/null +++ b/app/api/portrait/[file]/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { servePortrait } from "@/lib/api/serveAssets"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + const p = new URL(req.url).searchParams.get("p"); + if (!p) return new Response("not found", { status: 404 }); + return servePortrait(p); +} diff --git a/app/api/thumb/[file]/route.ts b/app/api/thumb/[file]/route.ts new file mode 100644 index 0000000..29d43f3 --- /dev/null +++ b/app/api/thumb/[file]/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { serveThumb } from "@/lib/api/serveAssets"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + const p = new URL(req.url).searchParams.get("p"); + if (!p) return new Response("not found", { status: 404 }); + return serveThumb(p); +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..d8d493e --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ingestFile } from "@/lib/ingest/ingest"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { rawDb } from "@/lib/db/client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Hard cap on a single uploaded file. Pinkudex stores images and short +// covers; anything beyond this is almost certainly a mistake (or an +// attack). Without the cap, `await file.arrayBuffer()` happily buffers +// multi-GB POSTs and OOMs the Node process. +const MAX_UPLOAD_BYTES = 512 * 1024 * 1024; + +export async function POST(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + const contentLength = Number(req.headers.get("content-length") ?? ""); + if (Number.isFinite(contentLength) && contentLength > MAX_UPLOAD_BYTES) { + return NextResponse.json({ error: "Upload too large" }, { status: 413 }); + } + const form = await req.formData(); + const file = form.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "missing file" }, { status: 400 }); + } + if (file.size > MAX_UPLOAD_BYTES) { + return NextResponse.json({ error: "Upload too large" }, { status: 413 }); + } + const buf = Buffer.from(await file.arrayBuffer()); + + const nfoFile = form.get("nfo"); + const nfoXml = nfoFile instanceof File ? await nfoFile.text() : undefined; + + const autoTag = form.get("autoTag"); + const autoCollection = form.get("autoCollection"); + let autoCollectionId: number | undefined; + if (typeof autoCollection === "string" && autoCollection.trim()) { + const parsed = Number(autoCollection); + if (!Number.isInteger(parsed) || parsed <= 0) { + return NextResponse.json({ error: "invalid collection" }, { status: 400 }); + } + const exists = rawDb.prepare(`SELECT id FROM collections WHERE id = ?`).get(parsed) as { id: number } | undefined; + if (!exists) { + return NextResponse.json({ error: "collection not found" }, { status: 400 }); + } + autoCollectionId = parsed; + } + const autoAssign = (typeof autoTag === "string" && autoTag.trim()) || autoCollectionId != null + ? { + tagName: typeof autoTag === "string" ? autoTag : undefined, + collectionId: autoCollectionId, + } + : undefined; + + const parentImageIdRaw = form.get("parentImageId"); + const parentImageId = typeof parentImageIdRaw === "string" && parentImageIdRaw ? Number(parentImageIdRaw) : undefined; + + const targetFilenameRaw = form.get("targetFilename"); + const targetFilename = typeof targetFilenameRaw === "string" && targetFilenameRaw.trim() ? targetFilenameRaw.trim() : undefined; + + const actressNamesRaw = form.get("actressNames"); + let actressNames: string[] | undefined; + if (typeof actressNamesRaw === "string" && actressNamesRaw.trim()) { + try { + const parsed = JSON.parse(actressNamesRaw); + if (Array.isArray(parsed)) actressNames = parsed.filter((s): s is string => typeof s === "string"); + } catch { + // ignore + } + } + + const onCollisionRaw = form.get("onCollision"); + const onCollision = onCollisionRaw === "replace" || onCollisionRaw === "skip" ? onCollisionRaw : "detect"; + + try { + const result = await ingestFile(buf, file.name, { + nfoXml, + autoAssign, + parentImageId, + targetFilename, + actressNames, + onCollision, + }); + return NextResponse.json(result); + } catch (err) { + console.error("ingest failed", err); + return NextResponse.json({ error: (err as Error).message }, { status: 500 }); + } +} diff --git a/app/api/video-files/[code]/route.ts b/app/api/video-files/[code]/route.ts new file mode 100644 index 0000000..cfc80eb --- /dev/null +++ b/app/api/video-files/[code]/route.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from "next/server"; +import path from "node:path"; +import { findVideosForCode } from "@/lib/video"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { getStoredVideoMetadata, serializeVideoMetadata } from "@/lib/video/metadata"; +import { variantLabel } from "@/lib/video/partClassify"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +interface VariantOut { + /** Absolute 0-based index into the original findVideosForCode result. + * Used as the `?part=` query value for stream/HLS endpoints. */ + partIdx: number; + abs: string; + rel: string; + filename: string; + size: number; + label: string; + metadata: ReturnType; +} + +interface PartOut { + /** 1-based display index for the parts strip. */ + partIndex: number; + /** Index into `variants[]` to use when no user pick has been made. */ + defaultIdx: number; + variants: VariantOut[]; +} + +function stemOf(filename: string): string { + const ext = path.extname(filename); + return ext ? filename.slice(0, -ext.length) : filename; +} + +/** + * Group raw video files into parts (sequential CDs/discs) with + * variants (alt encodes of the same part). Uses classification from + * the metadata table; falls back to "every file is its own part" when + * classification hasn't run yet. + */ +export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const files = findVideosForCode(decoded); + + // Build per-part groups. + const partMap = new Map(); + const orderedKeys: string[] = []; + + files.forEach((f, i) => { + const meta = getStoredVideoMetadata(f.abs); + const stem = stemOf(f.filename); + const kind = meta?.partKind; + const idx = meta?.partIndex ?? null; + const group = meta?.variantGroup ?? null; + + // Group key strategy: + // - "part" → group by the part's variantGroup (variants attach via dot-prefix) + // - "variant" → group by their attached variantGroup + // - "single" / unclassified → a singleton group keyed by abs path + let key: string; + if ((kind === "part" || kind === "variant") && group != null) { + key = `g:${group}`; + } else { + key = `s:${f.abs}`; + } + + const variant: VariantOut = { + partIdx: i, + abs: f.abs, + rel: f.rel, + filename: f.filename, + size: f.size, + label: group ? variantLabel(stem, group) : "original", + metadata: serializeVideoMetadata(meta), + }; + // Stash the underlying part index for sorting; non-parts get +Infinity. + (variant as VariantOut & { __sort: number }).__sort = idx ?? (kind === "variant" ? -1 : Number.MAX_SAFE_INTEGER); + + let arr = partMap.get(key); + if (!arr) { + arr = []; + partMap.set(key, arr); + orderedKeys.push(key); + } + arr.push(variant); + }); + + // Build the ordered parts list. Sort parts by their lowest known + // partIndex (singles fall to the end), preserving insertion order + // as a tiebreak. + const partEntries = orderedKeys.map((k) => { + const variants = partMap.get(k)!; + const minSort = Math.min(...variants.map((v) => (v as VariantOut & { __sort: number }).__sort)); + return { key: k, variants, sort: minSort }; + }); + partEntries.sort((a, b) => { + if (a.sort !== b.sort) return a.sort - b.sort; + return a.variants[0]!.partIdx - b.variants[0]!.partIdx; + }); + + const parts: PartOut[] = partEntries.map((entry, i) => { + const variants = entry.variants; + // Strip the sort helper field. + for (const v of variants) delete (v as Partial).__sort; + // Default = the variant whose stem == group (the "base" file). If + // none, alphabetically first by filename. + const groupKey = entry.key.startsWith("g:") ? entry.key.slice(2) : null; + let defaultIdx = 0; + if (groupKey != null) { + const exact = variants.findIndex((v) => stemOf(v.filename) === groupKey); + if (exact >= 0) defaultIdx = exact; + else { + const sortedAlpha = [...variants].sort((a, b) => a.filename.localeCompare(b.filename)); + defaultIdx = variants.indexOf(sortedAlpha[0]!); + } + } + return { + partIndex: i + 1, + defaultIdx, + variants, + }; + }); + + // Backwards-compatible flat list — the default variant of each part + // in display order. Existing consumers that only need one entry per + // part keep working without changes. + const flat = parts.map((p) => p.variants[p.defaultIdx]!); + + return NextResponse.json({ + parts, + files: flat.map((v) => ({ + abs: v.abs, + rel: v.rel, + filename: v.filename, + size: v.size, + metadata: v.metadata, + })), + }); +} diff --git a/app/api/video-hls/[code]/playlist/route.ts b/app/api/video-hls/[code]/playlist/route.ts new file mode 100644 index 0000000..5859629 --- /dev/null +++ b/app/api/video-hls/[code]/playlist/route.ts @@ -0,0 +1,83 @@ +import { NextRequest } from "next/server"; +import fsp from "node:fs/promises"; +import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video"; +import { getAppSetting } from "@/lib/db/appSettings"; +import { probeDuration } from "@/lib/video/duration"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * HLS playlist generator. Returns an m3u8 with N segment URLs covering + * the full video duration. Segments are produced on demand by the + * sibling /segment endpoint (each one is a fresh NVENC transcode of a + * fixed time window). Player (hls.js) requests segments as needed for + * playback and seeking. + */ +const SEGMENT_SECONDS = 6; + +export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const url = new URL(req.url); + const partRaw = url.searchParams.get("part"); + const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0); + + let files = findVideosForCode(decoded); + if (files.length === 0) { + const main = (getAppSetting("videoLibraryPath") || "").trim(); + const extras = getAppSetting("videoExtraPaths") ?? []; + const expected = [main, ...extras].filter(Boolean); + const idx = getVideoIndex(); + const haveAll = expected.length === idx.rootsScanned.length + && expected.every((r, i) => r === idx.rootsScanned[i]); + if (expected.length > 0 && !haveAll) { + await rescanVideoIndex(); + files = findVideosForCode(decoded); + } + } + if (files.length === 0) return new Response("not found", { status: 404 }); + const file = files[Math.min(part, files.length - 1)]; + + try { + await fsp.stat(file.abs); + } catch { + return new Response("not found", { status: 404 }); + } + + const duration = await probeDuration(file.abs, req.signal); + if (duration == null) { + return new Response("ffprobe failed", { status: 500 }); + } + + const segCount = Math.ceil(duration / SEGMENT_SECONDS); + const lines: string[] = [ + "#EXTM3U", + "#EXT-X-VERSION:3", + `#EXT-X-TARGETDURATION:${SEGMENT_SECONDS}`, + "#EXT-X-MEDIA-SEQUENCE:0", + "#EXT-X-PLAYLIST-TYPE:VOD", + ]; + for (let i = 0; i < segCount; i++) { + const remaining = duration - i * SEGMENT_SECONDS; + const segDur = Math.min(SEGMENT_SECONDS, remaining); + lines.push(`#EXTINF:${segDur.toFixed(3)},`); + // Relative URL — resolves against the playlist URL's directory. + // Playlist is at /api/video-hls/[code]/playlist, so its directory is + // /api/video-hls/[code]/ and `segment?...` resolves to the sibling. + lines.push(`segment?part=${part}&i=${i}`); + } + lines.push("#EXT-X-ENDLIST"); + + return new Response(lines.join("\n"), { + status: 200, + headers: { + "Content-Type": "application/vnd.apple.mpegurl", + "Cache-Control": "no-store", + }, + }); +} diff --git a/app/api/video-hls/[code]/segment/route.ts b/app/api/video-hls/[code]/segment/route.ts new file mode 100644 index 0000000..00ebad5 --- /dev/null +++ b/app/api/video-hls/[code]/segment/route.ts @@ -0,0 +1,160 @@ +import { NextRequest } from "next/server"; +import { spawn, type ChildProcess } from "node:child_process"; +import fsp from "node:fs/promises"; +import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video"; +import { getAppSetting } from "@/lib/db/appSettings"; +import { probeDuration } from "@/lib/video/duration"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * HLS segment endpoint. Each request transcodes a single 6-second + * window of the source via NVENC into MPEG-TS and pipes to the + * response. -bf 0 keeps Chromium's H.264 sink happy. -force_key_frames + * 0 (and NVENC's -forced-idr) ensure the segment opens with an IDR so + * it's independently decodable — required by HLS. + */ +const SEGMENT_SECONDS = 6; + +export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const url = new URL(req.url); + const partRaw = url.searchParams.get("part"); + const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0); + const iRaw = url.searchParams.get("i"); + const segmentIndex = iRaw == null ? 0 : Math.max(0, parseInt(iRaw, 10) || 0); + + let files = findVideosForCode(decoded); + if (files.length === 0) { + const main = (getAppSetting("videoLibraryPath") || "").trim(); + const extras = getAppSetting("videoExtraPaths") ?? []; + const expected = [main, ...extras].filter(Boolean); + const idx = getVideoIndex(); + const haveAll = expected.length === idx.rootsScanned.length + && expected.every((r, i) => r === idx.rootsScanned[i]); + if (expected.length > 0 && !haveAll) { + await rescanVideoIndex(); + files = findVideosForCode(decoded); + } + } + if (files.length === 0) return new Response("not found", { status: 404 }); + const file = files[Math.min(part, files.length - 1)]; + + try { + await fsp.stat(file.abs); + } catch { + return new Response("not found", { status: 404 }); + } + + const duration = await probeDuration(file.abs, req.signal); + if (duration == null) { + return new Response("ffprobe failed", { status: 500 }); + } + + const startTime = segmentIndex * SEGMENT_SECONDS; + if (startTime >= duration) { + return new Response("segment out of range", { status: 416 }); + } + const segDur = Math.min(SEGMENT_SECONDS, duration - startTime); + + const ffmpegArgs: string[] = [ + "-hide_banner", "-loglevel", "error", + // -ss before -i = fast container-level seek (lands on the prior key + // frame, NVENC's first emitted frame is an IDR by spec). + "-ss", startTime.toFixed(3), + "-t", segDur.toFixed(3), + "-i", file.abs, + "-map", "0:v:0", + "-map", "0:a:0?", + "-c:v", "h264_nvenc", + "-preset", "p4", + "-tune", "ll", + "-profile:v", "high", + "-bf", "0", + "-forced-idr", "1", + "-rc", "cbr", + "-b:v", "8M", + "-maxrate", "8M", + "-bufsize", "16M", + "-pix_fmt", "yuv420p", + "-c:a", "aac", + "-b:a", "192k", + "-ac", "2", + "-f", "mpegts", + "-mpegts_flags", "+resend_headers", + // Shift output timestamps so segment N's PTS starts at N*SEGMENT_SECONDS. + // Without this, every segment would emit at PTS≈0 and hls.js / MSE + // can't lay them out on a continuous timeline (would need + // #EXT-X-DISCONTINUITY markers for that). Continuous PTS = clean + // append, smooth playback across segment boundaries. + "-output_ts_offset", startTime.toFixed(3), + "pipe:1", + ]; + + let ffmpeg: ChildProcess; + try { + ffmpeg = spawn("ffmpeg", ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] }); + } catch (e) { + return new Response(`ffmpeg spawn failed: ${(e as Error).message}`, { status: 500 }); + } + ffmpeg.stderr?.on("data", (chunk: Buffer) => { + const text = chunk.toString(); + if (text.trim()) console.error(`[hls ${decoded} seg=${segmentIndex}] ${text.trim()}`); + }); + + return new Response(streamFromFfmpeg(ffmpeg, req.signal), { + status: 200, + headers: { + "Content-Type": "video/mp2t", + // Allow short-term caching — within a single playback session hls.js + // may re-request a segment if its buffer was evicted, and a cache + // hit avoids re-spawning ffmpeg. + "Cache-Control": "private, max-age=300", + }, + }); +} + +function streamFromFfmpeg(proc: ChildProcess, signal: AbortSignal): ReadableStream { + return new ReadableStream({ + start(controller) { + let closed = false; + const finish = () => { + if (closed) return; + closed = true; + try { controller.close(); } catch { /* already closed */ } + }; + const fail = (err: Error) => { + if (closed) return; + closed = true; + try { controller.error(err); } catch { /* already closed */ } + }; + proc.stdout?.on("data", (chunk: Buffer) => { + if (closed) return; + try { + controller.enqueue(new Uint8Array(chunk)); + } catch { + closed = true; + try { proc.kill("SIGKILL"); } catch { /* ignore */ } + } + }); + proc.stdout?.on("end", finish); + proc.on("error", (e) => fail(e)); + proc.on("exit", finish); + + const onAbort = () => { + try { proc.kill("SIGKILL"); } catch { /* ignore */ } + }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + }, + cancel() { + try { proc.kill("SIGKILL"); } catch { /* ignore */ } + }, + }); +} diff --git a/app/api/video-probe/[code]/route.ts b/app/api/video-probe/[code]/route.ts new file mode 100644 index 0000000..f12fd8e --- /dev/null +++ b/app/api/video-probe/[code]/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video"; +import { getAppSetting } from "@/lib/db/appSettings"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { probeVideoMetadata, serializeVideoMetadata, setVideoPlaybackMode } from "@/lib/video/metadata"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +interface ProbeResponse { + codec: string | null; + bFrames: number | null; + cachedMode: string | null; + metadata: ReturnType; +} + +async function resolveFile(decoded: string, partIdx: number) { + let files = findVideosForCode(decoded); + if (files.length === 0) { + const main = (getAppSetting("videoLibraryPath") || "").trim(); + const extras = getAppSetting("videoExtraPaths") ?? []; + const expected = [main, ...extras].filter(Boolean); + const idx = getVideoIndex(); + const haveAll = expected.length === idx.rootsScanned.length + && expected.every((r, i) => r === idx.rootsScanned[i]); + if (expected.length > 0 && !haveAll) { + await rescanVideoIndex(); + files = findVideosForCode(decoded); + } + } + if (files.length === 0) return null; + return files[Math.min(Math.max(0, partIdx), files.length - 1)]; +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const url = new URL(req.url); + const partRaw = url.searchParams.get("part"); + const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0); + + const file = await resolveFile(decoded, partIdx); + if (!file) { + return NextResponse.json({ codec: null, bFrames: null, cachedMode: null, metadata: null }); + } + + try { + const meta = await probeVideoMetadata(file, req.signal); + return NextResponse.json({ + codec: meta.videoCodec, + bFrames: meta.videoBFrames, + cachedMode: meta.playbackMode, + metadata: serializeVideoMetadata(meta), + }); + } catch (e) { + console.error("[video-probe] failed:", e); + return NextResponse.json( + { codec: null, bFrames: null, cachedMode: null, metadata: null }, + { status: 200 }, + ); + } +} + +export async function POST(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const url = new URL(req.url); + const partRaw = url.searchParams.get("part"); + const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0); + const body = await req.json().catch(() => ({})) as { mode?: string | null }; + const mode = body.mode; + if (mode !== "direct" && mode !== "transcode" && mode !== null && mode !== undefined) { + return NextResponse.json({ error: "invalid mode" }, { status: 400 }); + } + + const file = await resolveFile(decoded, partIdx); + if (!file) return NextResponse.json({ updated: 0 }); + + setVideoPlaybackMode(file, mode ?? null); + return NextResponse.json({ updated: 1 }); +} diff --git a/app/api/video-rescan/route.ts b/app/api/video-rescan/route.ts new file mode 100644 index 0000000..2620511 --- /dev/null +++ b/app/api/video-rescan/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; +import { rescanVideoIndex } from "@/lib/video"; +import { rawDb } from "@/lib/db/client"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const t0 = Date.now(); + const force = req.nextUrl.searchParams.get("force") === "1"; + const idx = await rescanVideoIndex({ force }); + // Bust the RSC cache for detail pages so file-size / duration + // refresh without a navigation. Skip the layout invalidation — + // it triggers a full-app re-render and isn't needed for the + // metadata badges we actually changed. + revalidatePath("/id/[code]", "page"); + // codes count comes from the DB now, not an in-memory Map. Cheap. + const distinctCodesRow = rawDb + .prepare(`SELECT COUNT(DISTINCT upper(code)) AS n FROM video_metadata`) + .get() as { n: number }; + return NextResponse.json({ + ok: true, + count: idx.count, + codes: distinctCodesRow.n, + rootsScanned: idx.rootsScanned, + elapsedMs: Date.now() - t0, + }); +} diff --git a/app/api/video-reveal/[code]/route.ts b/app/api/video-reveal/[code]/route.ts new file mode 100644 index 0000000..9bd4c3c --- /dev/null +++ b/app/api/video-reveal/[code]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { spawn } from "node:child_process"; +import { findVideosForCode } from "@/lib/video"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Open the OS file manager pre-selected on the cover's video file. + * Local-only — explicitly gated by assertLocalRequest. + */ +export async function POST(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const url = new URL(req.url); + const partRaw = url.searchParams.get("part"); + const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0); + + const files = findVideosForCode(decoded); + if (files.length === 0) return NextResponse.json({ error: "not found" }, { status: 404 }); + const file = files[Math.min(part, files.length - 1)]; + + try { + if (process.platform === "win32") { + // explorer doesn't return zero-exit even on success; detach and don't await. + spawn("explorer", ["/select,", file.abs], { detached: true, stdio: "ignore" }).unref(); + } else if (process.platform === "darwin") { + spawn("open", ["-R", file.abs], { detached: true, stdio: "ignore" }).unref(); + } else { + // Linux: open the parent dir; most file managers don't have a select API. + const parent = file.abs.replace(/[/\\][^/\\]*$/, ""); + spawn("xdg-open", [parent], { detached: true, stdio: "ignore" }).unref(); + } + return NextResponse.json({ ok: true, path: file.abs }); + } catch (e) { + return NextResponse.json({ error: (e as Error).message }, { status: 500 }); + } +} diff --git a/app/api/video-status/route.ts b/app/api/video-status/route.ts new file mode 100644 index 0000000..b0a7e15 --- /dev/null +++ b/app/api/video-status/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getVideoIndex, rescanVideoIndex, getCodesWithVideos, getCodesWithSubtitles } from "@/lib/video"; +import { getAppSetting } from "@/lib/db/appSettings"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Lightweight enumeration of every JAV code that has at least one + * playable file in the index. The client uses this to show "has video" + * badges on cover cards. Returned as a plain array for JSON portability. + * + * Auto-builds the index on first hit if a video folder is configured but + * the index is empty — avoids requiring a manual rescan on a fresh + * server boot. + */ +export async function GET(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + let idx = getVideoIndex(); + const main = (getAppSetting("videoLibraryPath") || "").trim(); + const extras = getAppSetting("videoExtraPaths") ?? []; + const expected = [main, ...extras].filter(Boolean); + const haveAll = expected.length === idx.rootsScanned.length + && expected.every((r, i) => r === idx.rootsScanned[i]); + if (expected.length > 0 && !haveAll) { + idx = await rescanVideoIndex(); + } + + return NextResponse.json({ + codes: Array.from(getCodesWithVideos()), + subtitleCodes: Array.from(getCodesWithSubtitles()), + count: idx.count, + lastScannedAt: idx.lastScannedAt, + rootsScanned: idx.rootsScanned, + }); +} diff --git a/app/api/video-stream/[code]/route.ts b/app/api/video-stream/[code]/route.ts new file mode 100644 index 0000000..ac7f65d --- /dev/null +++ b/app/api/video-stream/[code]/route.ts @@ -0,0 +1,164 @@ +import { NextRequest } from "next/server"; +import path from "node:path"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import { findVideosForCode } from "@/lib/video"; +import { assertLocalRequest } from "@/lib/api/localOnly"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MIME_BY_EXT: Record = { + ".mp4": "video/mp4", + ".m4v": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", + ".wmv": "video/x-ms-wmv", + ".ts": "video/mp2t", + ".mpg": "video/mpeg", + ".mpeg": "video/mpeg", + ".flv": "video/x-flv", +}; + +export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const url = new URL(req.url); + const partRaw = url.searchParams.get("part"); + const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0); + + const files = findVideosForCode(decoded); + if (files.length === 0) return new Response("not found", { status: 404 }); + const file = files[Math.min(part, files.length - 1)]; + + let stat: import("node:fs").Stats; + try { + stat = await fsp.stat(file.abs); + } catch { + return new Response("not found", { status: 404 }); + } + + const total = stat.size; + const ext = path.extname(file.abs).toLowerCase(); + const mime = MIME_BY_EXT[ext] ?? "application/octet-stream"; + // Stable identity for the byte stream — lets the browser's HTTP cache + // hold onto previously fetched ranges (the moov tail in particular) + // instead of re-hitting our endpoint on every seek / buffer-ahead. + const etag = `"${stat.size.toString(36)}-${Math.floor(stat.mtimeMs).toString(36)}"`; + const lastModified = new Date(stat.mtimeMs).toUTCString(); + + const range = req.headers.get("range"); + const baseHeaders: Record = { + "Content-Type": mime, + "Accept-Ranges": "bytes", + "Cache-Control": "private, max-age=3600", + "ETag": etag, + "Last-Modified": lastModified, + "Content-Disposition": `inline; filename="${encodeURIComponent(file.filename)}"`, + }; + + if (!range) { + return new Response(streamFile(file.abs, undefined, undefined, req.signal), { + status: 200, + headers: { ...baseHeaders, "Content-Length": String(total) }, + }); + } + + // Parse "bytes=START-END"; END may be empty for "until end", and + // START may be empty for HTTP suffix ranges ("last N bytes"). + const m = /^bytes=(\d*)-(\d*)$/.exec(range); + if (!m) return new Response("bad range", { status: 416 }); + let start: number; + let end: number; + if (m[1] === "") { + const suffixLen = Number(m[2]); + if (!Number.isFinite(suffixLen) || suffixLen <= 0) { + return new Response("bad range", { status: 416, headers: { "Content-Range": `bytes */${total}` } }); + } + start = Math.max(total - suffixLen, 0); + end = total - 1; + } else { + start = Number(m[1]); + end = m[2] === "" ? total - 1 : Number(m[2]); + } + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || end >= total) { + return new Response("bad range", { status: 416, headers: { "Content-Range": `bytes */${total}` } }); + } + const len = end - start + 1; + return new Response(streamFile(file.abs, start, end, req.signal), { + status: 206, + headers: { + ...baseHeaders, + "Content-Range": `bytes ${start}-${end}/${total}`, + "Content-Length": String(len), + }, + }); +} + +/** + * Pipe a file slice into a Web ReadableStream that the runtime can hand + * to fetch's Response. Tying the read stream to the request's AbortSignal + * is the bit that fixes "Invalid state: Controller is already closed": + * when the browser cancels (modal close, seek, network blip) the Node + * stream is destroyed before it can push more bytes into a stream the + * runtime has already closed. + */ +function streamFile( + abs: string, + start: number | undefined, + end: number | undefined, + signal: AbortSignal, +): ReadableStream { + let node: fs.ReadStream | null = null; + let closed = false; + return new ReadableStream({ + start(controller) { + node = fs.createReadStream(abs, { start, end }); + const finish = () => { + if (closed) return; + closed = true; + try { controller.close(); } catch { /* already closed */ } + }; + const fail = (err: Error) => { + if (closed) return; + closed = true; + try { controller.error(err); } catch { /* already closed */ } + }; + + node.on("data", (chunk: unknown) => { + if (closed) return; + try { + const u8 = chunk instanceof Uint8Array + ? chunk + : new Uint8Array(chunk as ArrayBufferLike); + controller.enqueue(u8); + } catch { + closed = true; + node?.destroy(); + } + }); + node.on("end", finish); + node.on("error", (err) => fail(err as Error)); + + const onAbort = () => { + closed = true; + node?.destroy(); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + }, + cancel() { + // ReadableStream.cancel() fires when the consumer is done before + // req.signal aborts (e.g. browser closes the response body cleanly + // after a Range fulfill). Without destroying the node stream here, + // the open file handle leaks until GC. + closed = true; + node?.destroy(); + }, + }); +} diff --git a/app/api/video-subtitles/[code]/route.ts b/app/api/video-subtitles/[code]/route.ts new file mode 100644 index 0000000..699c2c7 --- /dev/null +++ b/app/api/video-subtitles/[code]/route.ts @@ -0,0 +1,221 @@ +import { NextRequest, NextResponse } from "next/server"; +import path from "node:path"; +import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { + walkSubtitles, + detectLanguageFromName, + normalizeLanguageTag, + languageDisplay, + stemOf, + type LangIso, +} from "@/lib/video/subtitles"; +import { runFfprobeSubtitles } from "@/lib/video/metadata"; +import { getAppSetting } from "@/lib/db/appSettings"; +import { listManualSubtitlesForVariant } from "@/lib/video/manualSubtitles"; +import fs from "node:fs"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** Sidecar (external file) subtitle source. */ +interface SidecarOut { + /** Stable client-side id; encodes the abs path so the track endpoint + * can resolve it. */ + id: string; + abs: string; + filename: string; + ext: string; // ".srt" | ".vtt" | ".ass" | ".ssa" + language: LangIso | null; + label: string; + origin: "same-folder" | "library" | "manual"; +} + +/** Embedded-stream subtitle source (filled in once ffprobe is wired up + * in phase 2). */ +interface EmbeddedOut { + id: string; + streamIndex: number; + codec: string; + language: LangIso | null; + label: string; + renderable: boolean; +} + +function formatCodecLabel(codec: string): string | null { + switch (codec) { + case "subrip": return "SRT"; + case "ass": return "ASS"; + case "ssa": return "SSA"; + case "mov_text": return "mov_text"; + case "webvtt": return "VTT"; + case "hdmv_pgs_subtitle": return "PGS"; + case "dvd_subtitle": return "DVDSub"; + case "dvb_subtitle": return "DVBSub"; + default: return codec ? codec.toUpperCase() : null; + } +} + +function encodeSideId(abs: string): string { + return `side:${Buffer.from(abs, "utf8").toString("base64url")}`; +} + +/** Filter walkSubtitles results to entries that look like they belong + * to this specific video — stem prefix is the strong signal; code + * substring is the fallback. Both case-insensitive. */ +function matchesVideo(filename: string, stem: string, code: string): boolean { + const lowerName = filename.toLowerCase(); + const lowerStem = stem.toLowerCase(); + const lowerCode = code.toLowerCase(); + if (lowerName.startsWith(lowerStem + ".")) return true; + if (lowerName === lowerStem) return true; + return lowerName.includes(lowerCode); +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const partParam = req.nextUrl.searchParams.get("part"); + const partIdx = partParam == null ? 0 : Number.parseInt(partParam, 10); + if (!Number.isFinite(partIdx) || partIdx < 0) { + return NextResponse.json({ error: "Invalid part index" }, { status: 400 }); + } + + let files = findVideosForCode(decoded); + if (files.length === 0) { + // Cold-boot path: VideoIndexProvider may not have triggered the + // initial scan yet. Build it once so the picker doesn't appear + // empty on first modal open after server start. + const main = (getAppSetting("videoLibraryPath") || "").trim(); + const extras = getAppSetting("videoExtraPaths") ?? []; + const expected = [main, ...extras].filter(Boolean); + const idx = getVideoIndex(); + const haveAll = expected.length === idx.rootsScanned.length + && expected.every((r, i) => r === idx.rootsScanned[i]); + if (expected.length > 0 && !haveAll) { + await rescanVideoIndex(); + files = findVideosForCode(decoded); + } + } + const variant = files[partIdx]; + if (!variant) { + return NextResponse.json({ embedded: [], sidecar: [] }); + } + + const variantStem = stemOf(variant.filename); + const dir = path.dirname(variant.abs); + + // Phase 1: same-folder sidecars only. Embedded streams + library scan + // are added in later phases via additive concat into these arrays. + const sidecar: SidecarOut[] = []; + + const seen = new Set(); + const pushEntry = ( + entry: { abs: string; filename: string }, + origin: "same-folder" | "library", + ) => { + if (seen.has(entry.abs)) return; + if (!matchesVideo(entry.filename, variantStem, decoded)) return; + seen.add(entry.abs); + const detected = detectLanguageFromName(entry.filename); + const ext = path.extname(entry.filename).toLowerCase(); + sidecar.push({ + id: encodeSideId(entry.abs), + abs: entry.abs, + filename: entry.filename, + ext, + language: detected.lang, + label: detected.label, + origin, + }); + }; + + try { + for (const entry of await walkSubtitles(dir, 1)) pushEntry(entry, "same-folder"); + } catch { /* ignore */ } + + // Library scan: persistent extra paths from settings. Slightly deeper + // walk because users typically point these at organized hierarchies. + const extraPaths = (getAppSetting("subtitleExtraPaths") ?? []).filter(Boolean); + for (const root of extraPaths) { + try { + for (const entry of await walkSubtitles(root, 3)) pushEntry(entry, "library"); + } catch { /* missing or unreadable root */ } + } + + // Implicit always-on root: data/generated-subtitles// catches + // WhisperJAV-produced .srt when the video folder isn't writable. + const generatedDir = path.join(process.cwd(), "data", "generated-subtitles", decoded); + try { + for (const entry of await walkSubtitles(generatedDir, 1)) pushEntry(entry, "library"); + } catch { /* nothing generated yet */ } + + // Manually attached files via Browse... in the player. Persisted + // across sessions; only included when the file still exists on disk. + for (const m of listManualSubtitlesForVariant(decoded, partIdx)) { + if (seen.has(m.absPath)) continue; + if (!fs.existsSync(m.absPath)) continue; + const filename = path.basename(m.absPath); + if (!filename) continue; + const detected = detectLanguageFromName(filename); + const ext = path.extname(filename).toLowerCase(); + seen.add(m.absPath); + sidecar.push({ + id: encodeSideId(m.absPath), + abs: m.absPath, + filename, + ext, + language: detected.lang, + label: detected.label, + origin: "manual", + }); + } + + // Stable order: same-folder before library, then by language priority + // (EN, CN, JP, Unknown), then by filename. + const langRank: Record = { eng: 0, zho: 1, jpn: 2 }; + sidecar.sort((a, b) => { + if (a.origin !== b.origin) return a.origin === "same-folder" ? -1 : 1; + const ra = a.language ? (langRank[a.language] ?? 9) : 9; + const rb = b.language ? (langRank[b.language] ?? 9) : 9; + if (ra !== rb) return ra - rb; + return a.filename.localeCompare(b.filename); + }); + + const embedded: EmbeddedOut[] = []; + let streams: Awaited> = []; + try { + streams = await runFfprobeSubtitles(variant.abs); + } catch { + streams = []; + } + for (const s of streams) { + const iso = normalizeLanguageTag(s.language); + const codecLabel = formatCodecLabel(s.codec); + const trailing: string[] = []; + if (s.title) trailing.push(s.title); + if (codecLabel) trailing.push(codecLabel); + const base = iso ? languageDisplay(iso) : (s.title ?? "Unknown"); + const label = trailing.length > 0 && !iso + ? `${base}${codecLabel ? ` (${codecLabel})` : ""}` + : codecLabel + ? `${base} (${codecLabel})` + : base; + embedded.push({ + id: `emb:${s.index}`, + streamIndex: s.index, + codec: s.codec, + language: iso, + label, + renderable: s.isTextBased, + }); + } + + return NextResponse.json( + { embedded, sidecar }, + { headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/app/api/video-subtitles/[code]/track/route.ts b/app/api/video-subtitles/[code]/track/route.ts new file mode 100644 index 0000000..4562155 --- /dev/null +++ b/app/api/video-subtitles/[code]/track/route.ts @@ -0,0 +1,242 @@ +import { NextRequest, NextResponse } from "next/server"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { srtToVtt, SUBTITLE_EXTS, decodeSubtitleBuffer } from "@/lib/video/subtitles"; +import { isAllowedSubtitlePath } from "@/lib/video/subtitleAccess"; +import { cachePath, readCache, writeCache } from "@/lib/video/subtitleCache"; +import { findVideosForCode } from "@/lib/video"; +import { runFfprobeSubtitles } from "@/lib/video/metadata"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const VTT_HEADERS = { + "Content-Type": "text/vtt; charset=utf-8", + "Cache-Control": "no-store", +} as const; + +function decodeSide(src: string): string | null { + if (!src.startsWith("side:")) return null; + const b64 = src.slice("side:".length); + try { + const decoded = Buffer.from(b64, "base64url").toString("utf8"); + if (!decoded) return null; + return path.resolve(decoded); + } catch { + return null; + } +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const src = req.nextUrl.searchParams.get("src") ?? ""; + if (!src) { + return NextResponse.json({ error: "Missing src" }, { status: 400 }); + } + + if (src.startsWith("emb:")) { + return handleEmbedded(req, ctx, src); + } + + const abs = decodeSide(src); + if (!abs) { + return NextResponse.json({ error: "Invalid src" }, { status: 400 }); + } + if (!isAllowedSubtitlePath(abs)) { + return NextResponse.json({ error: "Subtitle path not allowed" }, { status: 403 }); + } + + const ext = path.extname(abs).toLowerCase(); + if (!(SUBTITLE_EXTS as readonly string[]).includes(ext)) { + return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 }); + } + + let stat; + try { + stat = await fs.stat(abs); + } catch { + return NextResponse.json({ error: "Subtitle file not found" }, { status: 404 }); + } + + if (ext === ".vtt") { + // VTT spec mandates UTF-8 but real-world files occasionally ship + // as UTF-16 BOM or a legacy Asian encoding. Run through the same + // decoder as .srt so the output is consistent UTF-8. + let buf: Buffer; + try { + buf = await fs.readFile(abs); + } catch { + return NextResponse.json({ error: "Read failed" }, { status: 500 }); + } + const text = decodeSubtitleBuffer(buf); + return new NextResponse(text, { headers: VTT_HEADERS }); + } + + if (ext === ".srt") { + const file = cachePath({ + abs, + size: stat.size, + mtimeMs: stat.mtimeMs, + kind: "srt", + streamOrExt: "srt", + }); + const cached = await readCache(file); + if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS }); + + let buf: Buffer; + try { + buf = await fs.readFile(abs); + } catch { + return NextResponse.json({ error: "Read failed" }, { status: 500 }); + } + // decodeSubtitleBuffer auto-detects UTF-8 / UTF-16 / shift_jis / + // gb18030 / big5 — a bare `toString("utf8")` mojibakes legacy CN + // and JP fansub SRTs. + const raw = decodeSubtitleBuffer(buf); + const vtt = srtToVtt(raw); + try { + await writeCache(file, vtt); + } catch { + // Cache miss + failed write isn't fatal; still serve the conversion. + } + return new NextResponse(vtt, { headers: VTT_HEADERS }); + } + + if (ext === ".ass" || ext === ".ssa") { + const file = cachePath({ + abs, + size: stat.size, + mtimeMs: stat.mtimeMs, + kind: ext === ".ass" ? "ass" : "ssa", + streamOrExt: ext.slice(1), + }); + const cached = await readCache(file); + if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS }); + let buf; + try { + buf = await ffmpegToVtt(["-i", abs, "-map", "0:s:0", "-c:s", "webvtt", "-f", "webvtt", "pipe:1"], req.signal); + } catch { + return NextResponse.json({ error: "Subtitle conversion failed" }, { status: 500 }); + } + if (buf.length === 0) return new NextResponse(null, { status: 204 }); + try { await writeCache(file, buf); } catch { /* ignore */ } + return new NextResponse(new Uint8Array(buf), { headers: VTT_HEADERS }); + } + + return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 }); +} + +async function handleEmbedded( + req: NextRequest, + ctx: { params: Promise<{ code: string }> }, + src: string, +): Promise { + const streamIdx = Number.parseInt(src.slice("emb:".length), 10); + if (!Number.isFinite(streamIdx) || streamIdx < 0) { + return NextResponse.json({ error: "Invalid stream index" }, { status: 400 }); + } + const partParam = req.nextUrl.searchParams.get("part"); + const partIdx = partParam == null ? 0 : Number.parseInt(partParam, 10); + if (!Number.isFinite(partIdx) || partIdx < 0) { + return NextResponse.json({ error: "Invalid part index" }, { status: 400 }); + } + const { code } = await ctx.params; + const decoded = decodeURIComponent(code); + const variant = findVideosForCode(decoded)[partIdx]; + if (!variant) { + return NextResponse.json({ error: "Video not found" }, { status: 404 }); + } + // Re-probe to validate the requested stream is real and text-based. + // Cheap (sub-100ms) and avoids serving image-based subtitles that + // would render as garbled text or hang ffmpeg. + const streams = await runFfprobeSubtitles(variant.abs); + const target = streams.find((s) => s.index === streamIdx); + if (!target) { + return NextResponse.json({ error: "Stream not found" }, { status: 404 }); + } + if (target.isImageBased) { + return NextResponse.json({ error: "Image-based subtitles not supported" }, { status: 415 }); + } + if (!target.isTextBased) { + return NextResponse.json({ error: "Subtitle codec not supported" }, { status: 415 }); + } + + let stat; + try { + stat = await fs.stat(variant.abs); + } catch { + return NextResponse.json({ error: "Video not readable" }, { status: 404 }); + } + const file = cachePath({ + abs: variant.abs, + size: stat.size, + mtimeMs: stat.mtimeMs, + kind: "embedded", + streamOrExt: streamIdx, + }); + const cached = await readCache(file); + if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS }); + + let buf: Buffer; + try { + buf = await ffmpegToVtt([ + "-i", variant.abs, + "-map", `0:s:${streamIdx}`, + "-c:s", "webvtt", + "-f", "webvtt", + "pipe:1", + ], req.signal); + } catch { + return NextResponse.json({ error: "Subtitle extraction failed" }, { status: 500 }); + } + if (buf.length === 0) return new NextResponse(null, { status: 204 }); + try { await writeCache(file, buf); } catch { /* ignore */ } + return new NextResponse(new Uint8Array(buf), { headers: VTT_HEADERS }); +} + +const FFMPEG_TIMEOUT_MS = 15_000; + +function ffmpegToVtt(args: string[], signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const proc = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", ...args]); + const chunks: Buffer[] = []; + let err = ""; + let settled = false; + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(t); + if (signal && onAbort) signal.removeEventListener("abort", onAbort); + fn(); + }; + const t = setTimeout(() => { + try { proc.kill("SIGKILL"); } catch {} + settle(() => reject(new Error("ffmpeg timed out"))); + }, FFMPEG_TIMEOUT_MS); + // Tear down the subprocess on client disconnect so a 15-second + // ghost ffmpeg doesn't keep CPU after the user closes the modal. + const onAbort = signal + ? () => { + try { proc.kill("SIGKILL"); } catch {} + settle(() => reject(new Error("client aborted"))); + } + : null; + if (signal && onAbort) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + proc.stdout?.on("data", (d: Buffer) => { chunks.push(d); }); + proc.stderr?.on("data", (d) => { err += d.toString(); }); + proc.on("error", (e) => settle(() => reject(e))); + proc.on("close", (code) => { + settle(() => { + if (code !== 0) { reject(new Error(err.trim() || `ffmpeg exited ${code}`)); return; } + resolve(Buffer.concat(chunks)); + }); + }); + }); +} diff --git a/app/api/whisperjav-candidates/route.ts b/app/api/whisperjav-candidates/route.ts new file mode 100644 index 0000000..bec970b --- /dev/null +++ b/app/api/whisperjav-candidates/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { rawDb } from "@/lib/db/client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +interface CandidateRow { + id: number; + code: string; + title: string | null; + thumb_path: string; +} + +/** + * Codes with a playable video but no discoverable subtitle. The user + * picks from this list when running batch WhisperJAV generation. + * + * has_subtitle is the cheap signal — populated by the video index + * scan (sidecar files / generated subs / library roots). + */ +export async function GET(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const limit = Math.min(500, Math.max(1, Number(req.nextUrl.searchParams.get("limit") ?? "200"))); + const offset = Math.max(0, Number(req.nextUrl.searchParams.get("offset") ?? "0")); + const includeAlreadyHasSubs = req.nextUrl.searchParams.get("all") === "1"; + + const where = includeAlreadyHasSubs + ? `i.has_video = 1 AND i.code IS NOT NULL AND i.deleted_at IS NULL AND i.parent_image_id IS NULL` + : `i.has_video = 1 AND i.has_subtitle = 0 AND i.code IS NOT NULL AND i.deleted_at IS NULL AND i.parent_image_id IS NULL`; + + const rows = rawDb.prepare(` + SELECT i.id, i.code, i.title, i.thumb_path + FROM images i + WHERE ${where} + ORDER BY UPPER(i.code) ASC + LIMIT ? OFFSET ? + `).all(limit, offset) as CandidateRow[]; + + const totalRow = rawDb.prepare(` + SELECT COUNT(*) AS n FROM images i WHERE ${where} + `).get() as { n: number }; + + return NextResponse.json({ + candidates: rows.map((r) => ({ + id: r.id, + code: r.code, + title: r.title, + thumbPath: r.thumb_path, + })), + total: totalRow.n, + }); +} diff --git a/app/api/whisperjav-jobs/[id]/cancel/route.ts b/app/api/whisperjav-jobs/[id]/cancel/route.ts new file mode 100644 index 0000000..3b219cb --- /dev/null +++ b/app/api/whisperjav-jobs/[id]/cancel/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { cancelJob } from "@/lib/whisperjav/queue"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const { id } = await ctx.params; + const ok = cancelJob(id); + if (!ok) return NextResponse.json({ error: "Not found or not cancellable" }, { status: 404 }); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/whisperjav-jobs/[id]/route.ts b/app/api/whisperjav-jobs/[id]/route.ts new file mode 100644 index 0000000..8347b17 --- /dev/null +++ b/app/api/whisperjav-jobs/[id]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "node:fs/promises"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { getJob, estimateRealtimeMultiplier } from "@/lib/whisperjav/db"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const LOG_TAIL_LINES = 50; + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const { id } = await ctx.params; + const job = getJob(id); + if (!job) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + let logTail: string[] = []; + try { + const raw = await fs.readFile(job.logPath, "utf8"); + const lines = raw.split(/\r?\n/); + logTail = lines.slice(-LOG_TAIL_LINES - 1).filter(Boolean); + } catch { /* log may not exist yet */ } + + // ETA: per-mode multiplier from history × video duration − elapsed. + // Returns null when we can't compute (no duration / not running yet). + let etaSec: number | null = null; + if ( + (job.status === "queued" || job.status === "running") && + job.videoDurationSec && job.videoDurationSec > 0 && + job.mode + ) { + const multiplier = estimateRealtimeMultiplier(job.mode); + const totalProjected = job.videoDurationSec * multiplier; + const start = job.startedAt ?? job.enqueuedAt; + const elapsedSec = (Date.now() - start) / 1000; + etaSec = Math.max(0, totalProjected - elapsedSec); + } + + return NextResponse.json({ ...job, logTail, etaSec }); +} diff --git a/app/api/whisperjav-jobs/batch/route.ts b/app/api/whisperjav-jobs/batch/route.ts new file mode 100644 index 0000000..5bc1264 --- /dev/null +++ b/app/api/whisperjav-jobs/batch/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { enqueueJob, cancelAllQueued } from "@/lib/whisperjav/queue"; +import { rawDb } from "@/lib/db/client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** Enqueue WhisperJAV for many codes at once. Each code becomes a + * separate row in whisperjav_jobs; the single-worker loop processes + * them sequentially. Codes that already have a generated subtitle + * are skipped (alreadyExists), not failed. */ +export async function POST(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const body = await req.json().catch(() => ({})); + const rawCodes = Array.isArray(body.codes) ? body.codes : []; + const codes = rawCodes + .filter((c: unknown): c is string => typeof c === "string" && c.trim().length > 0) + .map((c: string) => c.trim()); + if (codes.length === 0) { + return NextResponse.json({ enqueued: 0, skipped: 0, errors: [] }); + } + + let enqueued = 0; + let skipped = 0; + const errors: Array<{ code: string; error: string }> = []; + + for (const code of codes) { + try { + // Always part 0 for batch — multi-part videos are uncommon and + // the user can hit individual codes via the player picker for + // those edge cases. + const result = await enqueueJob({ code, partIdx: 0, overwrite: false }); + if ("alreadyExists" in result) skipped++; + else enqueued++; + } catch (e) { + errors.push({ code, error: (e as Error).message }); + } + } + + return NextResponse.json({ enqueued, skipped, errors }); +} + +/** Cancel every queued (not-yet-running) job. Useful when the user + * wants to stop a batch mid-flight. The currently-running job is + * left alone — kill it via the per-job cancel endpoint. */ +export async function DELETE(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + const cancelled = cancelAllQueued(); + return NextResponse.json({ cancelled }); +} + +/** Lightweight queue-state probe used by the batch UI: how many jobs + * are queued/running right now, plus the active row's id. */ +export async function GET(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + const queued = (rawDb + .prepare(`SELECT COUNT(*) AS n FROM whisperjav_jobs WHERE status = 'queued'`) + .get() as { n: number }).n; + const running = rawDb + .prepare(`SELECT id, code, started_at, stage, stage_index, stage_total FROM whisperjav_jobs WHERE status = 'running' ORDER BY started_at DESC LIMIT 1`) + .get() as { id: string; code: string; started_at: number | null; stage: string | null; stage_index: number | null; stage_total: number | null } | undefined; + return NextResponse.json({ queued, running: running ?? null }); +} diff --git a/app/api/whisperjav-jobs/route.ts b/app/api/whisperjav-jobs/route.ts new file mode 100644 index 0000000..86850fc --- /dev/null +++ b/app/api/whisperjav-jobs/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { enqueueJob, clearAllJobHistory, runRetentionSweep } from "@/lib/whisperjav/queue"; +import { listJobsForCode } from "@/lib/whisperjav/db"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const body = await req.json().catch(() => ({})); + const code = typeof body.code === "string" ? body.code.trim() : ""; + const rawPartIdx = typeof body.partIdx === "number" && Number.isFinite(body.partIdx) ? body.partIdx : 0; + const partIdx = Math.max(0, Math.floor(rawPartIdx)); + const overwrite = body.overwrite === true; + if (!code) return NextResponse.json({ error: "Missing code" }, { status: 400 }); + + try { + const result = await enqueueJob({ code, partIdx, overwrite }); + if ("alreadyExists" in result) { + return NextResponse.json(result, { status: 409 }); + } + return NextResponse.json(result, { status: 202 }); + } catch (e) { + return NextResponse.json({ error: (e as Error).message }, { status: 500 }); + } +} + +export async function GET(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const code = req.nextUrl.searchParams.get("code") ?? ""; + if (!code) return NextResponse.json({ jobs: [] }); + const jobs = listJobsForCode(code, 5); + return NextResponse.json({ jobs }); +} + +/** Clear-all-history. Wipes every non-running row + every temp dir. */ +export async function DELETE(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + const result = await clearAllJobHistory(); + return NextResponse.json(result); +} + +/** Manual retention sweep trigger. */ +export async function PATCH(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + const result = await runRetentionSweep(); + return NextResponse.json(result); +} diff --git a/app/api/whisperjav-verify/route.ts b/app/api/whisperjav-verify/route.ts new file mode 100644 index 0000000..e4d0d36 --- /dev/null +++ b/app/api/whisperjav-verify/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertLocalRequest } from "@/lib/api/localOnly"; +import { verifyCli, autoDetectCli } from "@/lib/whisperjav/spawn"; +import { getAppSetting } from "@/lib/db/appSettings"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const blocked = assertLocalRequest(req); + if (blocked) return blocked; + + const body = await req.json().catch(() => ({})); + const explicit = typeof body.path === "string" ? body.path.trim() : ""; + const autodetect = body.autodetect === true; + + let cliPath = explicit; + if (!cliPath && !autodetect) { + cliPath = (getAppSetting("whisperjav").cliPath ?? "").trim(); + } + if (!cliPath) { + const detected = await autoDetectCli(); + if (!detected) { + return NextResponse.json({ ok: false, error: "whisperjav not found on PATH" }); + } + cliPath = detected; + } + + const result = await verifyCli(cliPath); + return NextResponse.json(result); +} diff --git a/app/category/[slug]/page.tsx b/app/category/[slug]/page.tsx new file mode 100644 index 0000000..1cf6cf3 --- /dev/null +++ b/app/category/[slug]/page.tsx @@ -0,0 +1,119 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, Trash2 } from "lucide-react"; +import { getTagCategoryBySlug, listTagsInCategory, listAllTags } from "@/lib/db/queries"; +import { deleteTagCategory, renameTagCategory } from "@/app/actions/tagCategories"; +import { EntityRenameInline } from "@/components/entities/EntityRenameInline"; +import { CategoryTagAssigner } from "@/components/categories/CategoryTagAssigner"; +import { CategoryCoverPanel } from "@/components/categories/CategoryCoverPanel"; + +export const dynamic = "force-dynamic"; + +export default async function CategoryDetailPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const cat = getTagCategoryBySlug(decodeURIComponent(slug)); + if (!cat) notFound(); + const tags = listTagsInCategory(cat.id); + // All tags, with their current category, so the assigner can let the + // user reassign uncategorised or differently-categorised tags into + // this one. + const allTags = listAllTags("az"); + + const remove = async () => { + "use server"; + await deleteTagCategory(cat.id); + }; + const rename = async (name: string) => { + "use server"; + return await renameTagCategory(cat.id, name); + }; + + return ( +
+ + All categories + +
+
+
+ +

{cat.name}

+ +
+ +
+
+ {cat.description &&

{cat.description}

} +

{tags.length} tag{tags.length === 1 ? "" : "s"} in this category

+
+
+ +
+

Cover art

+ +
+ +
+

Member tags

+ {tags.length === 0 ? ( +
+ No tags assigned yet. Use the picker below to add some. +
+ ) : ( +
+ {tags.map((t) => ( + + {t.name} + {t.count} + + ))} +
+ )} +
+ +
+

Assign tags

+ ({ + id: t.id, + name: t.name, + count: t.count, + currentCategoryId: t.categoryId, + currentCategoryName: t.categoryName, + }))} + /> +
+
+ ); +} diff --git a/app/category/page.tsx b/app/category/page.tsx new file mode 100644 index 0000000..5a69bfe --- /dev/null +++ b/app/category/page.tsx @@ -0,0 +1,158 @@ +import Link from "next/link"; +import { Layers, Plus, ArrowDownAZ, Hash, RectangleVertical, RectangleHorizontal } from "lucide-react"; +import { listAllTagCategories, type CategorySort } from "@/lib/db/queries"; +import { createTagCategoryAction } from "@/app/actions/tagCategories"; +import { CategoryGridCard } from "@/components/categories/CategoryGridCard"; +import { cn } from "@/lib/utils"; + +export const dynamic = "force-dynamic"; + +type View = "portrait" | "landscape"; + +export default async function CategoriesPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const sp = await searchParams; + const sort: CategorySort = sp.sort === "count" ? "count" : "az"; + const view: View = sp.view === "landscape" ? "landscape" : "portrait"; + const qs = (overrides: Partial<{ sort: CategorySort; view: View }>) => { + const params = new URLSearchParams(); + const finalSort = overrides.sort ?? sort; + const finalView = overrides.view ?? view; + if (finalSort === "count") params.set("sort", "count"); + if (finalView === "landscape") params.set("view", "landscape"); + const s = params.toString(); + return s ? `/category?${s}` : "/category"; + }; + const cats = listAllTagCategories(sort); + return ( +
+
+
+

Categories

+

+ Umbrellas that group related tags. A category like BDSM can collect{" "} + bondage,{" "} + shibari,{" "} + cuffs, etc. + Each tag belongs to at most one category. +

+

{cats.length} categor{cats.length === 1 ? "y" : "ies"}

+
+
+
+ + A-Z + + + Count + +
+
+ + +
+
+ + P + + + L + +
+
+
+ + {cats.length === 0 ? ( +
+ +

No categories yet. Create one above to start grouping tags.

+
+ ) : view === "portrait" ? ( + // Target 7 per row at full desktop width; scale down responsively. +
+ {cats.map((c) => ( + + ))} +
+ ) : ( + // Landscape: 3 per row at desktop; 1-2 on smaller screens. +
+ {cats.map((c) => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/collection/[slug]/page.tsx b/app/collection/[slug]/page.tsx new file mode 100644 index 0000000..b1c1d57 --- /dev/null +++ b/app/collection/[slug]/page.tsx @@ -0,0 +1,113 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, Trash2 } from "lucide-react"; +import { getCollectionBySlug, listImages } from "@/lib/db/queries"; +import { MasonryGrid } from "@/components/grid/MasonryGrid"; +import { RegisterVisible } from "@/components/select/RegisterVisible"; +import { FilterBar } from "@/components/grid/FilterBar"; +import { UploadCard } from "@/components/ingest/UploadCard"; +import { deleteCollection, renameCollection } from "@/app/actions/collections"; +import { EntityRenameInline } from "@/components/entities/EntityRenameInline"; +import { ReorderableCollectionGrid } from "@/components/collections/ReorderableCollectionGrid"; +import { resolveSort } from "@/lib/sortServer"; +import { parseFilterCriteria, statusToFlags } from "@/lib/filters"; + +export const dynamic = "force-dynamic"; + +export default async function CollectionPage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise>; +}) { + const { slug } = await params; + const sp = await searchParams; + const c = getCollectionBySlug(decodeURIComponent(slug)); + if (!c) notFound(); + const urlSort = typeof sp.sort === "string" ? sp.sort : undefined; + // Resolve only when user explicitly chose; otherwise keep manual position default. + const sort = urlSort ? await resolveSort(urlSort) : undefined; + const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined; + const criteria = parseFilterCriteria(sp); + const items = listImages({ + collectionId: c.id, + sort, + search, + ...statusToFlags(criteria.status), + marks: criteria.marks, + actressIds: criteria.ids.actresses, + actressMode: criteria.mode.actresses, + studioIds: criteria.ids.studios, + seriesIds: criteria.ids.series, + genreIds: criteria.ids.genres, + genreMode: criteria.mode.genres, + collectionIds: criteria.ids.collections, + collectionMode: criteria.mode.collections, + tagIds: criteria.ids.tags, + tagMode: criteria.mode.tags, + categoryIds: criteria.ids.categories, + categoryMode: criteria.mode.categories, + }); + + const remove = async () => { + "use server"; + await deleteCollection(c.id); + }; + + const rename = async (name: string) => { + "use server"; + return await renameCollection(c.id, name); + }; + + return ( +
+ + Collection + +
+
+
+

{c.name}

+ +
+ +
+
+ {c.description &&

{c.description}

} +

{items.length} Item{items.length === 1 ? "" : "s"}

+
+
+ +
+
+ + + + {items.length === 0 ? ( +
+ Empty. Add images from any image detail page. +
+ ) : ( + <> + i.id)} /> + {urlSort ? ( + // User overrode the default order via the sort menu — disable + // drag-reorder since drag-position vs sorted-position would + // contradict each other. + + ) : ( + + )} + + )} +
+ ); +} diff --git a/app/collection/page.tsx b/app/collection/page.tsx new file mode 100644 index 0000000..aa031d5 --- /dev/null +++ b/app/collection/page.tsx @@ -0,0 +1,78 @@ +import Link from "next/link"; +import { listAllCollections } from "@/lib/db/queries"; +import { createCollectionAction } from "@/app/actions/collections"; +import { FolderHeart, Plus, RectangleVertical, RectangleHorizontal } from "lucide-react"; +import { ReorderableCollectionsIndex } from "@/components/collections/ReorderableCollectionsIndex"; +import { cn } from "@/lib/utils"; + +export const dynamic = "force-dynamic"; + +type View = "portrait" | "landscape"; + +export default async function CollectionsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const sp = await searchParams; + const view: View = sp.view === "portrait" ? "portrait" : "landscape"; + const items = listAllCollections(); + return ( +
+
+
+

Collection

+

{items.length} total

+
+
+
+ + +
+
+ + L + + + P + +
+
+
+ + {items.length === 0 ? ( +
+ +

No collections yet.

+
+ ) : ( + + )} +
+ ); +} diff --git a/app/genres/[slug]/page.tsx b/app/genres/[slug]/page.tsx new file mode 100644 index 0000000..53d77b9 --- /dev/null +++ b/app/genres/[slug]/page.tsx @@ -0,0 +1,62 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { rawDb } from "@/lib/db/client"; +import { listImages } from "@/lib/db/queries"; +import { MasonryGrid } from "@/components/grid/MasonryGrid"; +import { RegisterVisible } from "@/components/select/RegisterVisible"; +import { FilterBar } from "@/components/grid/FilterBar"; +import { resolveSort } from "@/lib/sortServer"; +import { parseFilterCriteria, statusToFlags } from "@/lib/filters"; + +export const dynamic = "force-dynamic"; + +export default async function GenrePage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise>; +}) { + const { slug } = await params; + const sp = await searchParams; + const sort = await resolveSort(typeof sp.sort === "string" ? sp.sort : undefined); + const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined; + const criteria = parseFilterCriteria(sp); + const g = rawDb.prepare(`SELECT id, name, slug FROM genres WHERE slug = ?`).get(decodeURIComponent(slug)) as { id: number; name: string; slug: string } | undefined; + if (!g) notFound(); + const items = listImages({ + genreId: g.id, + sort, + search, + ...statusToFlags(criteria.status), + marks: criteria.marks, + actressIds: criteria.ids.actresses, + actressMode: criteria.mode.actresses, + studioIds: criteria.ids.studios, + seriesIds: criteria.ids.series, + genreIds: criteria.ids.genres, + genreMode: criteria.mode.genres, + collectionIds: criteria.ids.collections, + collectionMode: criteria.mode.collections, + tagIds: criteria.ids.tags, + tagMode: criteria.mode.tags, + categoryIds: criteria.ids.categories, + categoryMode: criteria.mode.categories, + }); + + return ( +
+ + All genres + +
+

#{g.name}

+

{items.length} cover{items.length === 1 ? "" : "s"}

+
+ + i.id)} /> + +
+ ); +} diff --git a/app/genres/page.tsx b/app/genres/page.tsx new file mode 100644 index 0000000..0337eda --- /dev/null +++ b/app/genres/page.tsx @@ -0,0 +1,33 @@ +import Link from "next/link"; +import { listAllGenres } from "@/lib/db/queries"; +import { Hash } from "lucide-react"; + +export const dynamic = "force-dynamic"; + +export default function GenresPage() { + const items = listAllGenres(); + return ( +
+
+

Genres

+

{items.length} total

+
+ + {items.length === 0 ? ( +
+ +

No genres yet.

+
+ ) : ( +
+ {items.map((g) => ( + + {g.name} + {g.count} + + ))} +
+ )} +
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..eaddf7b --- /dev/null +++ b/app/globals.css @@ -0,0 +1,199 @@ +@import "tailwindcss" source(none); + +/* Tailwind v4's auto-content-discovery picks up files in .next/types, + * which Next regenerates on every dev rebuild. That triggered a CSS + * regen → HMR rebuild → routes regen → loop. Explicit @source decls + * (with the `source(none)` modifier above to disable auto-discovery) + * scope scanning to just our project source. */ +@source "../app/**/*.{ts,tsx,mdx}"; +@source "../components/**/*.{ts,tsx,mdx}"; +@source "../lib/**/*.{ts,tsx}"; + +@theme { + --color-bg-0: oklch(0.13 0.025 280); + --color-bg-1: oklch(0.17 0.04 285); + --color-bg-2: oklch(0.22 0.05 290); + --color-glass: color-mix(in oklch, white 6%, transparent); + --color-glass-strong: color-mix(in oklch, white 10%, transparent); + --color-glass-border: color-mix(in oklch, white 14%, transparent); + --color-glass-border-strong: color-mix(in oklch, white 22%, transparent); + --color-fg: oklch(0.97 0.01 280); + --color-fg-dim: oklch(0.72 0.025 280); + --color-fg-muted: oklch(0.55 0.02 280); + --color-cyan: oklch(0.82 0.16 200); + --color-cyan-glow: oklch(0.78 0.18 200); + --color-accent-primary: var(--color-cyan); + --color-accent-primary-glow: var(--color-cyan-glow); + --color-violet: oklch(0.72 0.22 305); + --color-violet-glow: oklch(0.68 0.24 305); + --color-mint: oklch(0.80 0.16 155); + --color-coral: oklch(0.72 0.20 25); + + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, "SF Mono", Menlo, monospace; + + --shadow-glow-cyan: 0 0 32px color-mix(in oklch, var(--color-cyan-glow) 35%, transparent); + --shadow-glow-violet: 0 0 32px color-mix(in oklch, var(--color-violet-glow) 35%, transparent); + + /* --------------------------------------------------------------- + Detail / panel rhythm tokens — the "snug+1" system. + Apply via Tailwind utilities: e.g. p-card, gap-card, gap-section, + gap-chip, mb-label, gap-stat. Do NOT use raw px values for these + concepts in app code — keep the source of truth here. + --------------------------------------------------------------- */ + --spacing-card: 15px; /* card interior padding */ + --spacing-card-gap: 9px; /* gap between sibling cards */ + --spacing-section: 15px; /* gap between sections inside a card */ + --spacing-chip: 7px; /* gap inside chip clusters / pill grids / button bars */ + --spacing-label: 7px; /* gap from a label-mono header to its content */ + --spacing-stat: 5px; /* gap from a hero-stat label to its big number */ + --spacing-stat-gap: 13px; /* horizontal gap between hero-stat columns */ +} + +@layer base { + html, body { height: 100%; } + html { scrollbar-gutter: stable; } + body { + background: + radial-gradient(ellipse 80% 60% at 20% 0%, color-mix(in oklch, var(--color-violet) 18%, transparent) 0%, transparent 60%), + radial-gradient(ellipse 70% 60% at 100% 30%, color-mix(in oklch, var(--color-cyan) 14%, transparent) 0%, transparent 55%), + linear-gradient(180deg, var(--color-bg-0) 0%, var(--color-bg-1) 100%); + background-attachment: fixed; + color: var(--color-fg); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + } + + ::selection { + background: color-mix(in oklch, var(--color-cyan) 40%, transparent); + color: white; + } + + * { border-color: var(--color-glass-border); } + + /* scrollbar — always visible thumb */ + html { + scrollbar-color: color-mix(in oklch, var(--color-fg-dim) 70%, transparent) color-mix(in oklch, var(--color-fg-dim) 12%, transparent); + } + ::-webkit-scrollbar { width: 12px; height: 12px; background: transparent; } + ::-webkit-scrollbar-track { + background: color-mix(in oklch, var(--color-fg-dim) 12%, transparent); + } + ::-webkit-scrollbar-thumb { + background: color-mix(in oklch, var(--color-fg-dim) 70%, transparent); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; + transition: background-color 200ms ease; + } + ::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklch, var(--color-fg-dim) 90%, transparent); + background-clip: padding-box; + } +} + +@keyframes fade-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes play-pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--color-cyan) 50%, transparent); } + 100% { box-shadow: 0 0 0 14px color-mix(in oklch, var(--color-cyan) 0%, transparent); } +} + +/* While the video player modal is open, kill native HTML5 drag site-wide. + The native