import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { prisma } from "@/lib/prisma"; import { extractFurniIconPng } from "@/lib/services/clone-icon"; import { listSources } from "@/lib/services/clone-sources"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { downloadFile } from "@/lib/services/import/core/download"; import { ensureDirectories } from "./furni-import"; export interface RepairEvent { type: "started" | "progress" | "done" | "batch_complete" | "error"; classname?: string; status?: string; index?: number; total?: number; message?: string; warnings?: string[]; succeeded?: number; failed?: number; skipped?: number; duration?: number; } export async function repairMissingIcons( onEvent?: (evt: RepairEvent) => void, ): Promise<{ succeeded: number; failed: number; skipped: number }> { let succeeded = 0; let failed = 0; let skipped = 0; try { await ensureDirectories(); } catch (err) { onEvent?.({ type: "error", message: `ensureDirectories failed: ${(err as Error).message}`, }); return { succeeded: 0, failed: 0, skipped: 0 }; } let iconDir: string; let nitroDir: string; try { const dirs = await getFurniAssetDirs(); iconDir = dirs.iconDir; nitroDir = dirs.nitroDir; } catch (err) { onEvent?.({ type: "error", message: `getFurniAssetDirs failed: ${(err as Error).message}`, }); return { succeeded: 0, failed: 0, skipped: 0 }; } let items: Array<{ id: number; item_name: string }>; try { items = await prisma.$queryRaw< Array<{ id: number; item_name: string }> >`SELECT id, item_name FROM items_base ORDER BY id`; } catch (err) { onEvent?.({ type: "error", message: `Database query failed: ${(err as Error).message}`, }); return { succeeded: 0, failed: 0, skipped: 0 }; } const total = items.length; onEvent?.({ type: "started", total }); for (let i = 0; i < items.length; i++) { const { item_name: classname } = items[i]; const iconPath = path.join(iconDir, `${classname}_icon.png`); if (existsSync(iconPath)) { skipped++; onEvent?.({ type: "progress", classname, status: "skipped", index: i, total, }); continue; } let ok = false; const warnings: string[] = []; const nitroPath = path.join(nitroDir, `${classname}.nitro`); if (existsSync(nitroPath)) { try { const nitroBuf = await fs.readFile(nitroPath); const icon = extractFurniIconPng(nitroBuf); if (icon) { await fs.writeFile(iconPath, icon); ok = true; } else { warnings.push("no icon asset in .nitro bundle"); } } catch (err) { warnings.push(`extract from .nitro failed: ${(err as Error).message}`); } } else { warnings.push(".nitro file not found on disk"); } if (!ok) { try { const sources = await listSources(); for (const source of sources) { try { const dl = await downloadFile( `${source.iconBaseUrl}/${classname}_icon.png`, iconPath, { maxRetries: 1, validate: "png" }, ); if (dl.ok) { ok = true; break; } } catch { warnings.push(`download from ${source.name} failed`); } } } catch (err) { warnings.push(`listSources failed: ${(err as Error).message}`); } } if (ok) { succeeded++; onEvent?.({ type: "progress", classname, status: "done", index: i, total, warnings: warnings.length ? warnings : undefined, }); } else { failed++; onEvent?.({ type: "progress", classname, status: "failed", index: i, total, message: warnings[0] || "could not obtain icon", }); } } onEvent?.({ type: "batch_complete", succeeded, failed, skipped, }); return { succeeded, failed, skipped }; }