import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { eq, sql } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; import { db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; import { getCloneList } from "@/lib/services/clone-import"; import { getSource } from "@/lib/services/clone-sources"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { appendFurniEntriesBulk, rebuildCatalogOfferIds, reconcileFurniDataWithItemsBase, verifyAndFixSpriteIds, } from "@/lib/services/furni-data"; import { buildLocalizedFurniDataFiles, patchLocalizedFurniDataEntries, } from "@/lib/services/furni-data-i18n"; import { cleanupConvertedSwfs, deleteImportedItem, downloadFile, ensureDirectories, ensureFurniOwnership, fixDatabaseConsistencyAfterImport, importSingleFurni, reconcileImportedOfferIds, syncAssetsToGamedataBundle, verifyAndFixInteractionModesCount, } from "@/lib/services/furni-import"; import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache"; import { listFurniture } from "@/lib/services/habbofurni"; import { rcon } from "@/lib/services/rcon"; import { convertSwfToNitro } from "@/lib/services/swf-to-nitro"; // Re-export for batch route backward compatibility export { ensureDirectories, importSingleFurni, } from "@/lib/services/furni-import"; export type { ImportSingleResult } from "@/types/furni"; import type { ImportSingleResult } from "@/types/furni"; // How many items to import concurrently in a batch. The per-item work is // network-bound (asset downloads) and DB-bound, so a modest pool gives a large // speedup over the old fully sequential loop without overwhelming the DB. const IMPORT_BATCH_CONCURRENCY = 12; async function runWithConcurrency( items: T[], limit: number, fn: (item: T) => Promise, ): Promise { const out = new Array(items.length); let cursor = 0; const workers = Array.from( { length: Math.min(limit, items.length) }, async () => { for (;;) { const i = cursor++; if (i >= items.length) break; out[i] = await fn(items[i]); } }, ); await Promise.all(workers); return out; } // ── Delete imported item or clean up SWF files ────────────────────── export const DELETE = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { const classname = request.nextUrl.searchParams.get("classname"); // ── Delete specific imported item ───────────────────────────────── if (classname) { try { const { spriteId, deletedFiles, errors } = await deleteImportedItem(classname); // Refresh game server caches try { await rcon.updateCatalog(); await rcon.updateItems(); } catch (err) { errors.push(`RCON: ${(err as Error).message}`); } logAudit({ userId: ctx.session.user.id, action: "furni_delete", target: "ItemsBase", targetId: spriteId, after: { classname, deletedFiles, errors: errors.length > 0 ? errors : undefined, }, }); return apiOk({ classname, deletedFiles, errors: errors.length > 0 ? errors : undefined, }); } catch (err) { return apiError((err as Error).message, 404); } } // ── Clean up SWF files that have been converted to Nitro ────────── const result = await cleanupConvertedSwfs(); if (result.total === 0 && result.deleted === 0) { return apiOk({ deleted: 0, message: "SWF directory does not exist" }); } return apiOk({ deleted: result.deleted, total: result.total, remaining: result.remaining, errors: result.errors.length > 0 ? result.errors : undefined, }); }, ); // ── Search furni via official Habbo furnidata ─────────────────────── export const GET = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request) => { const { nitroDir } = await getFurniAssetDirs(); // Stats endpoint const action = request.nextUrl.searchParams.get("action"); if (action === "stats") { const [totalResult, catalogResult, allIdsResult, referencedResult] = await Promise.all([ db.execute(sql`SELECT COUNT(*) as cnt FROM items_base`), db.execute( sql`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`, ), db.execute(sql`SELECT id FROM items_base`), db.execute(sql`SELECT item_ids FROM catalog_items`), ]); const totalInDb = Number( (totalResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt, ); const inCatalog = Number( (catalogResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt, ); // An item is "in the catalog" when its items_base id is referenced by // any catalog_items.item_ids (the canonical link — catalog_name can be // a translated display name, so matching on it is unreliable). const allIds = ( allIdsResult as unknown as [Array<{ id: number }>, unknown] )[0].map((r) => Number(r.id)); const referenced = new Set(); for (const row of ( referencedResult as unknown as [ Array<{ item_ids: string | null }>, unknown, ] )[0]) { for (const part of String(row.item_ids ?? "").split(/[;,]/)) { const n = Number(part.trim()); if (Number.isFinite(n) && n > 0) referenced.add(n); } } const notInCatalog = allIds.filter((id) => !referenced.has(id)).length; // Count missing .nitro files by checking filesystem const [importedItems] = (await db.execute(sql` SELECT ib.item_name FROM items_base ib WHERE EXISTS ( SELECT 1 FROM catalog_items ci WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 ) LIMIT 10000 `)) as unknown as [Array<{ item_name: string }>, unknown]; let missingNitro = 0; for (const item of importedItems) { const starIdx = item.item_name.indexOf("*"); const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name; const nitroPath = path.join( /*turbopackIgnore: true*/ nitroDir, `${base}.nitro`, ); if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) missingNitro++; } return apiOk({ totalInDb, inCatalog, notInCatalog, missingNitro }); } // Live furnidata reconcile (without hotel RCON reload) if (action === "live-reconcile") { const furniReconcile = await reconcileFurniDataWithItemsBase(); const offerRebuild = await rebuildCatalogOfferIds(); const spriteVerify = await verifyAndFixSpriteIds(); return apiOk({ furniDataFixedIds: furniReconcile.fixedIds, furniDataFixedOfferIds: furniReconcile.fixedOfferIds, furniDataMissing: furniReconcile.missing, furniDataConflicts: furniReconcile.conflicts, offerRebuildChecked: offerRebuild.checked, offerRebuildFixed: offerRebuild.fixed, spriteChecked: spriteVerify.checked, spriteFixed: spriteVerify.fixed, }); } if (action === "build-languages") { const languages = await buildLocalizedFurniDataFiles(); return apiOk({ languages }); } // Missing nitro endpoint: returns only imported items missing .nitro files if (action === "missing-nitro") { const pageNum = parseInt( request.nextUrl.searchParams.get("page") || "1", 10, ); const perPage = 500; const [importedItems] = (await db.execute(sql` SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib WHERE EXISTS ( SELECT 1 FROM catalog_items ci WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 ) `)) as unknown as [ Array<{ item_name: string; public_name: string; sprite_id: number; type: string; }>, unknown, ]; // Filter to only those missing .nitro on filesystem const missing = importedItems.filter((item) => { const starIdx = item.item_name.indexOf("*"); const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name; const p = path.join( /*turbopackIgnore: true*/ nitroDir, `${base}.nitro`, ); return !existsSync(/*turbopackIgnore: true*/ p); }); const total = missing.length; const lastPage = Math.max(Math.ceil(total / perPage), 1); const start = (pageNum - 1) * perPage; const pageItems = missing.slice(start, start + perPage); // Enrich with revision from official furnidata const furnidata = await getOfficialHabboFurnidata(); const items = pageItems.map((item) => { const fd = furnidata.get(item.item_name) || furnidata.get(item.item_name.split("*")[0]) || furnidata.get(`${item.item_name}*0`); return { id: item.sprite_id, classname: item.item_name, name: item.public_name || item.item_name, description: fd?.description || "", type: item.type === "i" ? "wallitem" : "flooritem", revision: fd?.revision || 0, category: fd?.category || fd?.furniline || "", alreadyImported: true, nitroExists: false, }; }); return apiOk({ items, meta: { currentPage: pageNum, lastPage, total, perPage }, }); } const search = request.nextUrl.searchParams.get("search") || ""; const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10); // ── Browse a custom clone source (retro hotel furnidata) ──────── const sourceId = request.nextUrl.searchParams.get("source"); if (sourceId) { const source = await getSource(sourceId); if (!source) return apiError("Source not found", 404); const { items, meta } = await getCloneList({ source, search, page, perPage: 500, filter: "all", }); const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, ""); const enriched = items.map((item) => { const imported = item.present; let nitroExists = false; if (imported) { const starIdx = item.classname.indexOf("*"); const base = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname; const p = path.join( /*turbopackIgnore: true*/ nitroDir, `${base}.nitro`, ); nitroExists = existsSync(/*turbopackIgnore: true*/ p); } return { id: item.id, classname: item.classname, type: item.itemType === "i" ? "wallitem" : "flooritem", name: item.name, description: item.description, revision: 0, category: String(item.category ?? ""), alreadyImported: imported, nitroExists, iconUrl: sourceIconBase ? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png` : undefined, }; }); return apiOk({ items: enriched, source: { id: source.id, name: source.name }, meta: { currentPage: meta.page, lastPage: Math.max(Math.ceil(meta.total / meta.perPage), 1), total: meta.total, perPage: meta.perPage, }, }); } // Pull every imported classname once so we can both (a) sort non-imported // items to the top *before* pagination and (b) skip the per-page lookup // below. Single-column indexed query — fast even at 10k+ rows. const allImported = await db .select({ itemName: ItemsBase.itemName }) .from(ItemsBase); const importedSet = new Set( allImported.map((e) => e.itemName).filter((n): n is string => !!n), ); const result = await listFurniture({ search: search || undefined, page, perPage: 500, importedSet, }); const enriched = result.items.map((item) => { const imported = importedSet.has(item.classname); let nitroExists = false; if (imported) { const starIdx = item.classname.indexOf("*"); const base = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname; const p = path.join( /*turbopackIgnore: true*/ nitroDir, `${base}.nitro`, ); nitroExists = existsSync(/*turbopackIgnore: true*/ p); } return { ...item, alreadyImported: imported, nitroExists }; }); return apiOk({ items: enriched, meta: result.meta }); }, ); // ── Import furni (single or batch) ────────────────────────────────── export const POST = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { const body = await request.json(); await ensureDirectories(); // ── Batch import ───────────────────────────────────────────────── if (Array.isArray(body.items)) { const { sourceId } = body as { sourceId?: string }; let source: Awaited> | null = null; if (sourceId) source = await getSource(sourceId); type BatchResultItem = ImportSingleResult & { classname: string }; const runOne = async ( item: (typeof body.items)[number], ): Promise => { if (!item.classname || !item.name) { return { classname: item.classname || "unknown", ok: false, warnings: [], error: "Missing classname or name", }; } try { const result = await importSingleFurni({ id: item.id ?? 0, classname: item.classname, name: item.name, description: item.description ?? "", type: item.type ?? "flooritem", revision: item.revision ?? 0, category: item.category ?? "unknown", updateExisting: body.updateExisting === true, sourceSwfBaseUrl: source?.sourceSwfBaseUrl, nitroBaseUrl: source?.nitroBaseUrl || undefined, iconBaseUrl: source?.iconBaseUrl || undefined, skipFurniDataWrite: true, }); if (result.ok) { logAudit({ userId: ctx.session.user.id, action: "furni_import", target: "ItemsBase", targetId: result.itemId ?? 0, after: { classname: item.classname, name: item.name, type: item.type === "wallitem" ? "i" : "s", catalogItemId: result.catalogItemId, dimensions: result.dimensions, batch: true, warnings: result.warnings.length > 0 ? result.warnings : undefined, }, }); } return { classname: item.classname, ...result }; } catch (err) { return { classname: item.classname, ok: false, warnings: [], error: (err as Error).message, }; } }; const results = await runWithConcurrency< (typeof body.items)[number], BatchResultItem >(body.items, IMPORT_BATCH_CONCURRENCY, runOne); // Collect FurnitureData entries and write the whole file once for the // batch instead of once per item (each per-item write rewrites it fully). const collectedFurniEntries: Array<{ entry: Record; itemType: string; }> = []; for (const r of results) { if (r.furniDataEntry) collectedFurniEntries.push(r.furniDataEntry); } if (collectedFurniEntries.length > 0) { try { await appendFurniEntriesBulk(collectedFurniEntries); } catch (err) { console.warn( "[import-furni] Bulk FurnitureData.json write failed:", (err as Error).message, ); } } // Refresh caches once after batch try { await rcon.updateCatalog(); await rcon.updateItems(); } catch (err) { console.warn( "[import-furni] RCON update failed after batch:", (err as Error).message, ); } // Reconcile offer_id with the furnidata sprite id across the tree const reconciled = await reconcileImportedOfferIds(); // Keep asset + furnidata folders readable by the web server const ownership = await ensureFurniOwnership(); // Backfill any missing nitros into the emulator's Gamedata bundle const assetsSynced = await syncAssetsToGamedataBundle(); // Verify the furnidata matches the DB ids const furniReconcile = await reconcileFurniDataWithItemsBase(); // Rebuild every catalog offer_id from the furnidata const offerRebuild = await rebuildCatalogOfferIds(); // Repair any sprite_id drift so the client renders the right item const spriteVerify = await verifyAndFixSpriteIds(); // Verify + fix interaction_modes_count once for the whole batch const verification = await verifyAndFixInteractionModesCount(); // Fix database consistency (catalog_name + have_offer + cost_credits) after import const { catalogNameFixed, haveOfferFixed, costCreditsFixed } = await fixDatabaseConsistencyAfterImport(); // Rebuild all localized furnidata files (best effort) let languages: Awaited> = []; try { languages = await buildLocalizedFurniDataFiles(); } catch (err) { console.warn( "[import-furni] Localized furnidata build failed:", (err as Error).message, ); } const succeeded = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok).length; return apiOk({ batch: true, succeeded, failed, results, offerIdsFixed: reconciled.fixed, ownershipFixed: ownership.fixed, nitrosSynced: assetsSynced.copiedNitros, iconsSynced: assetsSynced.copiedIcons, furniDataFixedIds: furniReconcile.fixedIds, furniDataFixedOfferIds: furniReconcile.fixedOfferIds, furniDataMissing: furniReconcile.missing, furniDataConflicts: furniReconcile.conflicts, offerRebuildChecked: offerRebuild.checked, offerRebuildFixed: offerRebuild.fixed, spriteChecked: spriteVerify.checked, spriteFixed: spriteVerify.fixed, interactionFixed: verification.fixed, catalogNameFixed, haveOfferFixed, costCreditsFixed, languages, }); } // ── Single import ──────────────────────────────────────────────── const { id, classname, name, description, type, revision, category, sourceId, updateExisting, } = body; if (!classname || !name) return apiError("Missing classname or name", 400); let source: Awaited> | null = null; if (sourceId) source = await getSource(sourceId); const result = await importSingleFurni({ id: id ?? 0, classname, name, description: description ?? "", type: type ?? "flooritem", revision: revision ?? 0, category: category ?? "unknown", updateExisting: updateExisting === true, sourceSwfBaseUrl: source?.sourceSwfBaseUrl, nitroBaseUrl: source?.nitroBaseUrl || undefined, iconBaseUrl: source?.iconBaseUrl || undefined, }); if (!result.ok) return apiError(result.error ?? "Import failed", 409); // Refresh game server caches try { await rcon.updateCatalog(); await rcon.updateItems(); } catch (err) { console.warn( "[import-furni] RCON update failed:", (err as Error).message, ); result.warnings.push("RCON cache refresh failed"); } // Reconcile offer_id with the furnidata sprite id across the tree const reconciled = await reconcileImportedOfferIds(); // Keep asset + furnidata folders readable by the web server const ownership = await ensureFurniOwnership(); // Backfill any missing nitros and icons into the emulator's Gamedata bundle const assetsSynced = await syncAssetsToGamedataBundle(); // Verify the furnidata matches the DB ids const furniReconcile = await reconcileFurniDataWithItemsBase(); // Fix database consistency (catalog_name + have_offer + cost_credits) after import const { catalogNameFixed, haveOfferFixed, costCreditsFixed } = await fixDatabaseConsistencyAfterImport(); // Rebuild every catalog offer_id from the furnidata const offerRebuild = await rebuildCatalogOfferIds(); // Repair any sprite_id drift so the client renders the right item const spriteVerify = await verifyAndFixSpriteIds(); // Verify + fix interaction_modes_count (single consolidated pass) const verification = await verifyAndFixInteractionModesCount(); // Patch localized furnidata files for this single furni (best effort) let languagesPatch: Awaited< ReturnType > = []; try { languagesPatch = await patchLocalizedFurniDataEntries([ { classname, name, description: description ?? "" }, ]); } catch (err) { console.warn( "[import-furni] Localized patch failed:", (err as Error).message, ); } logAudit({ userId: ctx.session.user.id, action: "furni_import", target: "ItemsBase", targetId: result.itemId ?? 0, after: { classname, name, spriteId: id ?? 0, type: type === "wallitem" ? "i" : "s", catalogItemId: result.catalogItemId, dimensions: result.dimensions, spriteCount: result.spriteCount, warnings: result.warnings.length > 0 ? result.warnings : undefined, }, }); return apiOk({ id: result.itemId, catalogItemId: result.catalogItemId, offerIdsFixed: reconciled.fixed, ownershipFixed: ownership.fixed, nitrosSynced: assetsSynced.copiedNitros, iconsSynced: assetsSynced.copiedIcons, furniDataFixedIds: furniReconcile.fixedIds, furniDataFixedOfferIds: furniReconcile.fixedOfferIds, furniDataMissing: furniReconcile.missing, furniDataConflicts: furniReconcile.conflicts, offerRebuildChecked: offerRebuild.checked, offerRebuildFixed: offerRebuild.fixed, spriteChecked: spriteVerify.checked, spriteFixed: spriteVerify.fixed, interactionFixed: verification.fixed, catalogNameFixed, haveOfferFixed, costCreditsFixed, languages: languagesPatch, spriteCount: result.spriteCount, warnings: result.warnings, }); }, ); // ── Re-generate missing .nitro files (PATCH) ───────────────────────── export const PATCH = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (request) => { const body = await request.json(); const items: Array<{ classname: string; revision: number }> = body.items || []; if (items.length === 0) return apiError("No items provided", 400); await ensureDirectories(); const { swfDir, nitroDir } = await getFurniAssetDirs(); const results: Array<{ classname: string; ok: boolean; warning?: string }> = []; for (const item of items) { 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`, ); // Skip if .nitro already exists if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { results.push({ classname: item.classname, ok: true, warning: "Already exists", }); continue; } // Resolve revision: try furnidata first, then spriteId as fallback let rev = item.revision || 0; if (rev === 0) { const furnidata = await getOfficialHabboFurnidata(); const fd = furnidata.get(item.classname) || furnidata.get(baseClassname) || furnidata.get(`${baseClassname}*0`); if (fd) rev = fd.revision; } const swfPath = path.join(swfDir, `${baseClassname}.swf`); // Download SWF if not present — try multiple revision candidates if (!existsSync(swfPath)) { // Build list of revisions to try: known rev, spriteId, common fallbacks const [spriteId] = await db .select({ spriteId: ItemsBase.spriteId }) .from(ItemsBase) .where(eq(ItemsBase.itemName, item.classname)) .limit(1); const revsToTry = [ ...new Set([rev, spriteId?.spriteId || 0].filter((r) => r > 0)), ]; 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) { results.push({ classname: item.classname, ok: false, warning: `SWF not found on Habbo CDN (tried revisions: ${revsToTry.join(", ") || "none"})`, }); continue; } } // Convert SWF → Nitro + update FurnitureData.json try { const swfBuffer = await fs.readFile(swfPath); const conversion = convertSwfToNitro(swfBuffer, baseClassname); await fs.writeFile(nitroPath, conversion.bundle); // Add to FurnitureData.json if not already present try { const { buildFurniEntry, appendFurniEntry, readFurniData } = await import("@/lib/services/furni-data"); const furniData = (await readFurniData()) as { roomitemtypes?: { furnitype?: Array<{ classname?: string }> }; wallitemtypes?: { furnitype?: Array<{ classname?: string }> }; }; // Check if already in FurnitureData const allEntries = [ ...(furniData.roomitemtypes?.furnitype || []), ...(furniData.wallitemtypes?.furnitype || []), ]; const alreadyInFD = allEntries.some( (e) => e.classname === baseClassname || e.classname === item.classname, ); if (!alreadyInFD) { const [dbItem] = await db .select({ id: ItemsBase.id, spriteId: ItemsBase.spriteId, publicName: ItemsBase.publicName, type: ItemsBase.type, }) .from(ItemsBase) .where(eq(ItemsBase.itemName, item.classname)) .limit(1); const furnidata = await getOfficialHabboFurnidata(); 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, officialHabbo: fd || null, }); await appendFurniEntry(entry, itemType); } } catch (fdErr) { console.warn( `[regen] FurnitureData update skipped for ${item.classname}:`, (fdErr as Error).message, ); } results.push({ classname: item.classname, ok: true }); } catch (err) { results.push({ classname: item.classname, ok: false, warning: (err as Error).message, }); } } const succeeded = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok).length; return apiOk({ succeeded, failed, results }); }, );