import { existsSync, promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { extractFurniIconPng } from "@/lib/services/clone-icon"; import { type CloneSource, DEFAULT_ICON_SOURCES, listSources, } from "@/lib/services/clone-sources"; import { type FurniAssetWriteTargets, getFurniAssetWriteTargets, } from "@/lib/services/furni-asset-dirs"; import { downloadFile } from "@/lib/services/import/core/download"; import { ensureDirectories } from "./furni-import"; const REPAIR_CONCURRENCY = 6; /** Sliding-window worker pool: keeps up to `concurrency` tasks in flight. */ async function runPool( items: T[], concurrency: number, worker: (item: T, index: number) => Promise, ): Promise { const limit = Math.min(Math.max(concurrency, 1), items.length); if (limit === 0) return; let next = 0; await Promise.allSettled( Array.from({ length: limit }, async () => { while (true) { const i = next++; if (i >= items.length) break; await worker(items[i], i); } }), ); } function uniqueDirs(dirs: string[]): string[] { const seen = new Set(); const out: string[] = []; for (const dir of dirs) { const key = process.platform === "win32" ? dir.toLowerCase() : dir; if (seen.has(key)) continue; seen.add(key); out.push(dir); } return out; } async function writeIconToDirs( icon: Buffer, dirs: string[], fileName: string, ): Promise { const errors: string[] = []; for (const dir of dirs) { try { await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, fileName), icon); } catch (err) { errors.push(`write icon to ${dir} failed: ${(err as Error).message}`); } } return errors; } async function copyIconAcrossDirs( srcDir: string, destDirs: string[], fileName: string, ): Promise { const errors: string[] = []; const src = path.join(srcDir, fileName); for (const dir of destDirs) { if (dir === srcDir) continue; try { await fs.mkdir(dir, { recursive: true }); await fs.copyFile(src, path.join(dir, fileName)); } catch (err) { errors.push(`copy icon to ${dir} failed: ${(err as Error).message}`); } } return errors; } 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 targets: FurniAssetWriteTargets; try { targets = await getFurniAssetWriteTargets(); } catch (err) { onEvent?.({ type: "error", message: `getFurniAssetWriteTargets failed: ${(err as Error).message}`, }); return { succeeded: 0, failed: 0, skipped: 0 }; } // Every location the app mirrors furni assets into — the webroot (public/) // plus the live gamedata (e.g. /var/www/Gamedata). Repair writes to all of // them so both the CMS and the client see the icon. const iconDirs = uniqueDirs([ targets.iconDir, ...targets.mirrorDirs.map((d) => d.iconDir), ]); const nitroDirs = uniqueDirs([ targets.nitroDir, ...targets.mirrorDirs.map((d) => d.nitroDir), ]); let items: Array<{ id: number; item_name: string }>; try { const [rows] = (await db.execute(sql` SELECT id, item_name FROM items_base ORDER BY id `)) as unknown as [Array<{ id: number; item_name: string }>, unknown]; items = rows; } 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 }); const processItem = async (classname: string) => { const fileName = `${classname}_icon.png`; // Badge items store icons as badge__icon.png (the classname already // includes the `badge_` prefix, so fileName matches correctly). const presentDirs = iconDirs.filter((dir) => existsSync(path.join(dir, fileName)), ); const missingDirs = iconDirs.filter((dir) => !presentDirs.includes(dir)); if (missingDirs.length === 0) { return { status: "skipped" } as const; } let ok = false; const warnings: string[] = []; // 1) Copy across mirrors — the icon already exists in one target (e.g. // gamedata), so just fill the ones that are missing it. if (presentDirs.length > 0) { try { const copyErrors = await copyIconAcrossDirs( presentDirs[0], missingDirs, fileName, ); warnings.push(...copyErrors); ok = copyErrors.length === 0; } catch (err) { warnings.push( `copy across icon dirs failed: ${(err as Error).message}`, ); } } // 2) Extract from a local .nitro bundle (checked across every target). if (!ok) { let foundNitro = false; for (const dir of nitroDirs) { const nitroPath = path.join(dir, `${classname}.nitro`); if (!existsSync(nitroPath)) continue; foundNitro = true; try { const nitroBuf = await fs.readFile(nitroPath); const icon = extractFurniIconPng(nitroBuf); if (icon) { const writeErrors = await writeIconToDirs( icon, missingDirs, fileName, ); warnings.push(...writeErrors); ok = writeErrors.length === 0; break; } warnings.push("no icon asset in .nitro bundle"); } catch (err) { warnings.push( `extract from .nitro failed: ${(err as Error).message}`, ); } } if (!foundNitro) warnings.push(".nitro file not found on disk"); } // 3) Download from configured or default public sources. if (!ok) { let sources: CloneSource[] = []; try { sources = (await listSources()).filter( (s) => s.iconBaseUrl || s.nitroBaseUrl, ); } catch (err) { warnings.push(`listSources failed: ${(err as Error).message}`); } if (sources.length === 0) sources = DEFAULT_ICON_SOURCES; for (const source of sources) { const iconCandidates = [ `${classname}_icon.png`, // Variant classnames (e.g. `table_silo_med*10`) often only exist // on sources under their base name (e.g. `table_silo_med_icon.png`) // or with the `*` replaced by `_` (e.g. `table_silo_med_10_icon.png`). ...(classname.includes("*") ? [ `${classname.split("*")[0]}_icon.png`, `${classname.replace(/\*/g, "_")}_icon.png`, ] : []), ]; for (const iconName of iconCandidates) { try { const dl = await downloadFile( `${source.iconBaseUrl}/${iconName}`, path.join(targets.iconDir, fileName), { maxRetries: 1, validate: "png" }, ); if (dl.ok) { const icon = await fs.readFile( path.join(targets.iconDir, fileName), ); const writeErrors = await writeIconToDirs( icon, missingDirs, fileName, ); warnings.push(...writeErrors); ok = writeErrors.length === 0; break; } warnings.push(`${iconName} not found on ${source.name}`); } catch (err) { warnings.push( `download from ${source.name} failed: ${(err as Error).message}`, ); } } if (ok) break; // Some sources only ship icons embedded in their `.nitro` bundles — // grab the bundle and extract the catalog icon from it. if (source.nitroBaseUrl) { const nitroTmp = path.join(os.tmpdir(), `.repair_${classname}.nitro`); try { const dl = await downloadFile( `${source.nitroBaseUrl}/${classname}.nitro`, nitroTmp, { maxRetries: 1 }, ); if (dl.ok) { const icon = extractFurniIconPng(await fs.readFile(nitroTmp)); if (icon) { const writeErrors = await writeIconToDirs( icon, missingDirs, fileName, ); warnings.push(...writeErrors); ok = writeErrors.length === 0; } else { warnings.push(`no icon asset in ${source.name} .nitro bundle`); } } else { warnings.push(`.nitro not found on ${source.name}`); } } catch (err) { warnings.push( `extract from ${source.name} .nitro failed: ${(err as Error).message}`, ); } finally { await fs.unlink(nitroTmp).catch(() => {}); } if (ok) break; } } } if (ok) { return { status: "done", warnings } as const; } return { status: "failed", message: warnings[0] || "could not obtain icon", } as const; }; await runPool(items, REPAIR_CONCURRENCY, async (item, i) => { const classname = item.item_name; const result = await processItem(classname); if (result.status === "skipped") { skipped++; onEvent?.({ type: "progress", classname, status: "skipped", index: i, total, }); return; } if (result.status === "done") { succeeded++; onEvent?.({ type: "progress", classname, status: "done", index: i, total, warnings: result.warnings.length ? result.warnings : undefined, }); return; } failed++; onEvent?.({ type: "progress", classname, status: "failed", index: i, total, message: result.message, }); }); onEvent?.({ type: "batch_complete", succeeded, failed, skipped, }); return { succeeded, failed, skipped }; }