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', }, }) })