import { existsSync, promises as fs } from "node:fs"; import os from "node:os"; import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { type CloneSource, DEFAULT_NITRO_SOURCES, listSources, } from "@/lib/services/clone-sources"; import { type FurniAssetWriteTargets, getFurniAssetWriteTargets, getGamedataRoot, } from "@/lib/services/furni-asset-dirs"; import { downloadFile } from "@/lib/services/import/core/download"; import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind"; import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; import { getRuntimePath } from "@/lib/utils/runtime-path"; import { ensureDirectories } from "./furni-import"; import { addSize32ToBundle } from "./swf/add-size32"; 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 writeNitroToDirs( nitro: Buffer, dirs: string[], fileName: string, ): Promise { const errors: string[] = []; for (const dir of dirs) { try { await fs.mkdir(dir, { recursive: true }); await fs.writeFile(getRuntimePath(dir, fileName), nitro); } catch (err) { errors.push(`write .nitro to ${dir} failed: ${(err as Error).message}`); } } return errors; } async function copyNitroAcrossDirs( srcDir: string, destDirs: string[], fileName: string, ): Promise { const errors: string[] = []; const src = getRuntimePath(srcDir, fileName); for (const dir of destDirs) { if (dir === srcDir) continue; try { await fs.mkdir(dir, { recursive: true }); await fs.copyFile(src, getRuntimePath(dir, fileName)); } catch (err) { errors.push(`copy .nitro to ${dir} failed: ${(err as Error).message}`); } } return errors; } export interface RepairNitroEvent { 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; existing?: number; duration?: number; } export async function repairMissingNitros( onEvent?: (evt: RepairNitroEvent) => 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 .nitro. const nitroDirs = uniqueDirs([ targets.nitroDir, ...targets.mirrorDirs.map((d) => d.nitroDir), ]); // Badges and other special items (pets, effects, bots, sticky notes) never // ship furni `.nitro` bundles — resolve the skip set from the DB so badge // detection is precise (`items_base.type = 'b'`) instead of relying only on // which .gif files happen to exist in the album directory. let albumBadgeSet: Set | null = null; try { const gamedataRoot = await getGamedataRoot(); if (gamedataRoot) { const badgeFiles = await fs .readdir(getRuntimePath(gamedataRoot, "album1584")) .catch(() => [] as string[]); albumBadgeSet = new Set( badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)), ); } } catch { albumBadgeSet = null; } let items: Array<{ id: number; item_name: string; type: string; interaction_type: string; }>; try { const [rows] = (await db.execute(sql` SELECT id, item_name, type, interaction_type FROM items_base ORDER BY id `)) as unknown as [ Array<{ id: number; item_name: string; type: string; interaction_type: string; }>, unknown, ]; items = rows; } catch (err) { onEvent?.({ type: "error", message: `Database query failed: ${(err as Error).message}`, }); return { succeeded: 0, failed: 0, skipped: 0 }; } // Build the authoritative skip set from the DB, supplemented by album .gifs. const skipSet = new Set(); for (const item of items) { if (NO_ASSET_KINDS.has(classifyCatalogItem(item))) { skipSet.add(item.item_name); } } for (const code of albumBadgeSet ?? []) { skipSet.add(code); } const total = items.length; onEvent?.({ type: "started", total }); const processItem = async (classname: string) => { // Special items (badges, pets, effects, bots, sticky notes) don't have // furni .nitro bundles to repair. Skip them. if (skipSet.has(classname)) { return { status: "skipped" } as const; } const fileName = `${classname}.nitro`; const presentDirs = nitroDirs.filter((dir) => existsSync(getRuntimePath(dir, fileName)), ); const missingDirs = nitroDirs.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 .nitro 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 copyNitroAcrossDirs( presentDirs[0], missingDirs, fileName, ); warnings.push(...copyErrors); ok = copyErrors.length === 0; } catch (err) { warnings.push( `copy across .nitro dirs failed: ${(err as Error).message}`, ); } } // 2) Download from configured or default nitro sources. if (!ok) { let sources: CloneSource[] = []; try { sources = (await listSources()).filter((s) => s.nitroBaseUrl); } catch (err) { warnings.push(`listSources failed: ${(err as Error).message}`); } if (sources.length === 0) sources = DEFAULT_NITRO_SOURCES; const nitroCandidates = [ `${classname}.nitro`, // Variant classnames (e.g. `table_silo_med*10`) often only exist // on sources under their base name (e.g. `table_silo_med.nitro`) // or with the `*` replaced by `_` (e.g. `table_silo_med_10.nitro`). ...(classname.includes("*") ? [ `${classname.split("*")[0]}.nitro`, `${classname.replace(/\*/g, "_")}.nitro`, ] : []), // Classnames with dots (`post.it`) are stored on disk with // underscores (`post_it.nitro`). ...(classname.includes(".") ? [`${classname.replace(/\./g, "_")}.nitro`] : []), ]; for (const source of sources) { for (const nitroName of nitroCandidates) { const tmpPath = getRuntimePath( os.tmpdir(), `.repair_nitro_${classname}_${Math.random().toString(36).slice(2, 8)}.nitro`, ); try { const dl = await downloadFile( `${source.nitroBaseUrl}/${nitroName}`, tmpPath, { maxRetries: 1 }, ); if (dl.ok) { // Validate it is a real Nitro bundle before writing it // anywhere (mirrors clone-import.ts). const buffer = await fs.readFile(tmpPath); parseNitroBundle(buffer); const writeErrors = await writeNitroToDirs( buffer, missingDirs, fileName, ); warnings.push(...writeErrors); ok = writeErrors.length === 0; break; } warnings.push(`${nitroName} not found on ${source.name}`); } catch (err) { warnings.push( `download from ${source.name} failed: ${(err as Error).message}`, ); } finally { await fs.unlink(tmpPath).catch(() => {}); } } if (ok) break; } } if (ok) { return { status: "done", warnings } as const; } return { status: "failed", message: warnings[0] || "could not obtain .nitro", } 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 }; } /** * Re-convert every furniture `.nitro` we produced from a local SWF so the * bundles include size 32 (and every other visualization size the converter * now emits). Used to backfill size 32 support on already-imported furniture * after the converter was changed. * * Only items that have a local SWF are re-converted: those are the bundles our * own converter built (and that may be missing size 32). Source-provided * `.nitro` bundles already carry every size, so they are left untouched. The * regenerated bundle is validated before it overwrites anything in the nitro * directories. */ /** * Synthesize a size-32 visualization on every existing furniture `.nitro` that * does not already have one. The 32 frame is generated by downscaling the * largest existing sprite (usually size 64) — no SWF is required, so this works * for source-provided bundles too. Each rewritten bundle is validated and * written back to every nitro directory (including the live * `/var/www/Gamedata/bundled/furniture`). Bundles that already carry a 32 frame * (or cannot derive one) are left untouched. */ export async function addSize32AllNitros( onEvent?: (evt: RepairNitroEvent) => void, opts?: { concurrency?: number }, ): Promise<{ succeeded: number; failed: number; skipped: number; existing: 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, failed, skipped, existing: 0 }; } let targets: FurniAssetWriteTargets; try { targets = await getFurniAssetWriteTargets(); } catch (err) { onEvent?.({ type: "error", message: `getFurniAssetWriteTargets failed: ${(err as Error).message}`, }); return { succeeded, failed, skipped, existing: 0 }; } const nitroDirs = uniqueDirs([ targets.nitroDir, ...targets.mirrorDirs.map((d) => d.nitroDir), ]); // Collect every .nitro we have (across primary + mirrored gamedata dirs), // keyed by file name so we process each bundle exactly once. const fileNames = new Set(); for (const dir of nitroDirs) { try { const names = await fs.readdir(dir); for (const n of names) { if (n.toLowerCase().endsWith(".nitro")) fileNames.add(n); } } catch { /* ignore unreadable dir */ } } const existing = fileNames.size; const total = existing; onEvent?.({ type: "started", total }); await runPool( [...fileNames], opts?.concurrency ?? 12, async (fileName, i) => { let buf: Buffer | null = null; for (const dir of nitroDirs) { try { buf = await fs.readFile(getRuntimePath(dir, fileName)); break; } catch { /* try next dir */ } } if (!buf) { skipped++; onEvent?.({ type: "progress", classname: fileName, status: "missing", index: i, total, }); return; } let out: Buffer | null; try { out = await addSize32ToBundle(buf); } catch (err) { failed++; onEvent?.({ type: "progress", classname: fileName, status: "convert-failed", index: i, total, message: (err as Error).message, }); return; } if (!out) { skipped++; return; } try { const errors = await writeNitroToDirs(out, nitroDirs, fileName); if (errors.length > 0) { failed++; onEvent?.({ type: "progress", classname: fileName, status: "write-failed", index: i, total, warnings: errors, }); return; } succeeded++; onEvent?.({ type: "progress", classname: fileName, status: "done", index: i, total, }); } catch (err) { failed++; onEvent?.({ type: "progress", classname: fileName, status: "write-failed", index: i, total, message: (err as Error).message, }); } }, ); onEvent?.({ type: "batch_complete", succeeded, failed, skipped, existing }); return { succeeded, failed, skipped, existing }; }