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