import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { withAdmin } from "@/lib/api-handler"; import { apiError } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { appendFurniEntriesBatch, buildFurniEntry, readFurniData } from "@/lib/services/furni-data"; import { downloadFile, ensureDirectories } from "@/lib/services/furni-import"; import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache"; import { rcon } from "@/lib/services/rcon"; import { convertSwfToNitro } from "@/lib/services/swf-to-nitro"; interface RegenItem { classname: string; revision: number; } /** * SSE batch regenerate .nitro files. * Streams progress events for each item. */ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => { const body = await request.json(); const items: RegenItem[] = body.items || []; const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5); if (items.length === 0) return apiError("No items to regenerate", 400); await ensureDirectories(); const { swfDir, nitroDir } = await getFurniAssetDirs(); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const send = (data: unknown) => { try { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); } catch { /* stream closed */ } }; const startTime = Date.now(); send({ type: "regen_start", total: items.length }); let succeeded = 0; let failed = 0; let skipped = 0; const furniDataEntries: Array<{ entry: Record; itemType: string }> = []; // Load furnidata once for all items const furnidata = await getHabboItFurnidata(); // Load existing FurnitureData.json classnames for dedup const existingFD = (await readFurniData()) as { roomitemtypes?: { furnitype?: Array<{ classname?: string }> }; wallitemtypes?: { furnitype?: Array<{ classname?: string }> }; }; const fdClassnames = new Set([ ...(existingFD.roomitemtypes?.furnitype || []).map((e) => e.classname), ...(existingFD.wallitemtypes?.furnitype || []).map((e) => e.classname), ]); for (let i = 0; i < items.length; i += concurrency) { const chunk = items.slice(i, i + concurrency); const promises = chunk.map(async (item) => { const starIdx = item.classname.indexOf("*"); const baseClassname = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname; const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`); if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { skipped++; send({ type: "regen_progress", classname: item.classname, status: "skipped" }); return; } send({ type: "regen_progress", classname: item.classname, status: "downloading" }); // Resolve revision let rev = item.revision || 0; if (rev === 0) { const fd = furnidata.get(item.classname) || furnidata.get(baseClassname) || furnidata.get(`${baseClassname}*0`); if (fd) rev = fd.revision; } // Try spriteId as fallback revision const revsToTry: number[] = []; if (rev > 0) revsToTry.push(rev); const dbItem = await prisma.itemsBase.findFirst({ where: { itemName: item.classname }, select: { id: true, spriteId: true, publicName: true, type: true }, }); if (dbItem?.spriteId && !revsToTry.includes(dbItem.spriteId)) { revsToTry.push(dbItem.spriteId); } if (revsToTry.length === 0) { failed++; send({ type: "regen_progress", classname: item.classname, status: "failed", message: "No revision found", }); return; } const swfPath = path.join(swfDir, `${baseClassname}.swf`); // Download SWF if needed if (!existsSync(swfPath)) { let downloaded = false; for (const tryRev of revsToTry) { const dl = await downloadFile( `https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`, swfPath, { validate: "swf" }, ); if (dl.ok) { rev = tryRev; downloaded = true; break; } } if (!downloaded) { failed++; send({ type: "regen_progress", classname: item.classname, status: "failed", message: `SWF not found (revs: ${revsToTry.join(", ")})`, }); return; } } // Convert send({ type: "regen_progress", classname: item.classname, status: "converting" }); try { const swfBuffer = await fs.readFile(swfPath); const conversion = convertSwfToNitro(swfBuffer, baseClassname); await fs.writeFile(nitroPath, conversion.bundle); // Build FurnitureData entry if missing if (!fdClassnames.has(baseClassname) && !fdClassnames.has(item.classname)) { const fd = furnidata.get(item.classname) || furnidata.get(baseClassname); const itemType = dbItem?.type === "i" ? "i" : "s"; const entry = buildFurniEntry({ id: dbItem?.spriteId || 0, classname: baseClassname, revision: rev, category: fd?.category || "", name: dbItem?.publicName || baseClassname, description: fd?.description || "", spriteId: dbItem?.spriteId || 0, dims: conversion.dimensions || { x: 1, y: 1, z: 1 }, metadata: conversion.metadata || null, habboIt: fd || null, }); furniDataEntries.push({ entry, itemType }); fdClassnames.add(baseClassname); } succeeded++; send({ type: "regen_progress", classname: item.classname, status: "done" }); } catch (err) { failed++; send({ type: "regen_progress", classname: item.classname, status: "failed", message: (err as Error).message, }); } }); await Promise.allSettled(promises); } // Batch write FurnitureData.json if (furniDataEntries.length > 0) { try { await appendFurniEntriesBatch(furniDataEntries); send({ type: "regen_info", message: `Added ${furniDataEntries.length} entries to FurnitureData.json`, }); } catch (err) { send({ type: "regen_info", message: `FurnitureData.json write failed: ${(err as Error).message}`, }); } } // RCON refresh try { await rcon.updateItems(); await rcon.updateCatalog(); } catch { /* non-fatal */ } send({ type: "regen_complete", succeeded, failed, skipped, furniDataAdded: furniDataEntries.length, duration: Date.now() - startTime, }); controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); });