Remove import/repair feature and related API routes.
Local Build and Deploy / deploy (push) Successful in 1m41s
Local Build and Deploy / deploy (push) Successful in 1m41s
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
bb7d581084
commit
49d204e85c
9 files changed
+2
-1468
No files matched your search
@@ -21,8 +21,6 @@ const ROUTES = [
|
||||
"pets",
|
||||
"pets/batch",
|
||||
"pets/icon",
|
||||
"repair",
|
||||
"repair/audit",
|
||||
];
|
||||
|
||||
describe("admin import backend contract", () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache";
|
||||
|
||||
// Re-export the download helpers now owned by the shared import core.
|
||||
// Existing furni callers (and furni-repair) keep importing them from here.
|
||||
// Existing furni callers keep importing them from here.
|
||||
export {
|
||||
downloadFile,
|
||||
validatePngBytes,
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
/**
|
||||
* Furni Repair Service
|
||||
*
|
||||
* Audits and repairs missing assets (.nitro bundles + icon PNGs) for items
|
||||
* that were imported but whose files were not downloaded successfully
|
||||
* (network errors, habbo.com 404s, classname URL-encoding issues, etc.).
|
||||
*
|
||||
* Reuses the download/convert helpers from furni-import.ts.
|
||||
*/
|
||||
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
|
||||
import { downloadFile, ensureDirectories } from "@/lib/services/furni-import";
|
||||
import {
|
||||
getHabboItFurnidata,
|
||||
lookupHabboIt,
|
||||
} from "@/lib/services/habbo-furnidata-cache";
|
||||
import {
|
||||
convertSwfToNitro,
|
||||
extractIconFromSwf,
|
||||
} from "@/lib/services/swf-to-nitro";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type MissingKind = "nitro" | "icon" | "both";
|
||||
|
||||
export interface MissingItem {
|
||||
id: number;
|
||||
classname: string;
|
||||
baseClassname: string;
|
||||
revision: number;
|
||||
type: "s" | "i";
|
||||
publicName: string;
|
||||
hasSwf: boolean;
|
||||
hasIcon: boolean;
|
||||
hasNitro: boolean;
|
||||
missing: MissingKind;
|
||||
/** Pre-flagged as impossible to repair from habbo.com (whitespace, invalid chars). */
|
||||
unrepairable: boolean;
|
||||
}
|
||||
|
||||
export interface AuditSummary {
|
||||
total: number;
|
||||
missingBoth: number;
|
||||
missingNitro: number;
|
||||
missingIcon: number;
|
||||
unrepairable: number;
|
||||
items: MissingItem[];
|
||||
}
|
||||
|
||||
export interface RepairResult {
|
||||
classname: string;
|
||||
ok: boolean;
|
||||
iconRepaired: boolean;
|
||||
nitroRepaired: boolean;
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
/** True when habbo.com will never serve this asset (bad classname or 404 across all known revisions). */
|
||||
unrepairable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a classname has a shape habbo.com can never serve.
|
||||
* Currently: whitespace or control chars (habbo.com only uses [A-Za-z0-9_*.-]).
|
||||
*/
|
||||
export function isStructurallyImpossible(classname: string): boolean {
|
||||
// Whitespace or ASCII control chars — habbo.com URL would 404.
|
||||
if (/\s/.test(classname)) return true;
|
||||
for (let i = 0; i < classname.length; i++) {
|
||||
const code = classname.charCodeAt(i);
|
||||
if (code < 0x20) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Audit ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scans every row in `items_base` and reports which ones are missing
|
||||
* their `.nitro` bundle and/or icon PNG on disk.
|
||||
*/
|
||||
export async function auditImportedAssets(): Promise<AuditSummary> {
|
||||
const { swfDir, iconDir, nitroDir } = await getFurniAssetDirs();
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
itemName: true,
|
||||
publicName: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
const items: MissingItem[] = [];
|
||||
let missingBoth = 0;
|
||||
let missingNitro = 0;
|
||||
let missingIcon = 0;
|
||||
let unrepairable = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const classname = row.itemName;
|
||||
if (!classname) continue;
|
||||
|
||||
const starIdx = classname.indexOf("*");
|
||||
const baseClassname =
|
||||
starIdx !== -1 ? classname.substring(0, starIdx) : classname;
|
||||
const iconFileName = classname.replace(/\*/g, "_");
|
||||
|
||||
const nitroPath = path.join(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${baseClassname}.nitro`,
|
||||
);
|
||||
const swfPath = path.join(
|
||||
/*turbopackIgnore: true*/ swfDir,
|
||||
`${baseClassname}.swf`,
|
||||
);
|
||||
const iconPath = path.join(
|
||||
/*turbopackIgnore: true*/ iconDir,
|
||||
`${iconFileName}_icon.png`,
|
||||
);
|
||||
|
||||
const hasNitro = existsSync(/*turbopackIgnore: true*/ nitroPath);
|
||||
const hasIcon = existsSync(/*turbopackIgnore: true*/ iconPath);
|
||||
const hasSwf = existsSync(/*turbopackIgnore: true*/ swfPath);
|
||||
|
||||
if (hasNitro && hasIcon) continue;
|
||||
|
||||
let missing: MissingKind;
|
||||
if (!hasNitro && !hasIcon) {
|
||||
missing = "both";
|
||||
missingBoth++;
|
||||
} else if (!hasNitro) {
|
||||
missing = "nitro";
|
||||
missingNitro++;
|
||||
} else {
|
||||
missing = "icon";
|
||||
missingIcon++;
|
||||
}
|
||||
|
||||
const unrep = isStructurallyImpossible(classname);
|
||||
if (unrep) unrepairable++;
|
||||
|
||||
items.push({
|
||||
id: row.id,
|
||||
classname,
|
||||
baseClassname,
|
||||
revision: 0,
|
||||
type: (row.type === "i" ? "i" : "s") as "s" | "i",
|
||||
publicName: row.publicName ?? classname,
|
||||
hasSwf,
|
||||
hasIcon,
|
||||
hasNitro,
|
||||
missing,
|
||||
unrepairable: unrep,
|
||||
});
|
||||
}
|
||||
|
||||
// Stable sort: missing both first, then nitro, then icon; alphabetical within group.
|
||||
// Unrepairable items sink to the bottom of their group so fixable ones surface first.
|
||||
const order: Record<MissingKind, number> = { both: 0, nitro: 1, icon: 2 };
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
order[a.missing] - order[b.missing] ||
|
||||
Number(a.unrepairable) - Number(b.unrepairable) ||
|
||||
a.classname.localeCompare(b.classname),
|
||||
);
|
||||
|
||||
return {
|
||||
total: rows.length,
|
||||
missingBoth,
|
||||
missingNitro,
|
||||
missingIcon,
|
||||
unrepairable,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Repair ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attempts to restore missing assets for a single item.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Resolve candidate revisions: habbo.it furnidata, DB.revision, DB.spriteId.
|
||||
* 2. If `.nitro` is missing and SWF is missing: try each revision → download SWF.
|
||||
* 3. If SWF now exists: convert → write `.nitro`.
|
||||
* 4. If icon PNG is missing: download from habbo.com; on failure, extract from SWF.
|
||||
* 5. If `.nitro` was (re)generated, update items_base dims / interaction flags.
|
||||
*/
|
||||
export async function repairItem(classname: string): Promise<RepairResult> {
|
||||
const warnings: string[] = [];
|
||||
await ensureDirectories();
|
||||
const { swfDir, iconDir, nitroDir } = await getFurniAssetDirs();
|
||||
|
||||
const row = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: classname },
|
||||
select: {
|
||||
id: true,
|
||||
spriteId: true,
|
||||
type: true,
|
||||
publicName: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
classname,
|
||||
ok: false,
|
||||
iconRepaired: false,
|
||||
nitroRepaired: false,
|
||||
warnings,
|
||||
error: "Item not found in DB",
|
||||
};
|
||||
}
|
||||
|
||||
// Fast path: shapes habbo.com can never serve (whitespace, control chars).
|
||||
// Skip every network call — safe us ~60s of useless 15s-timeout retries.
|
||||
if (isStructurallyImpossible(classname)) {
|
||||
return {
|
||||
classname,
|
||||
ok: false,
|
||||
iconRepaired: false,
|
||||
nitroRepaired: false,
|
||||
warnings: [
|
||||
"Classname contains whitespace / control chars — habbo.com URL would 404",
|
||||
],
|
||||
error: "Unrepairable classname",
|
||||
unrepairable: true,
|
||||
};
|
||||
}
|
||||
|
||||
const starIdx = classname.indexOf("*");
|
||||
const baseClassname =
|
||||
starIdx !== -1 ? classname.substring(0, starIdx) : classname;
|
||||
const iconFileName = classname.replace(/\*/g, "_");
|
||||
|
||||
const nitroPath = path.join(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${baseClassname}.nitro`,
|
||||
);
|
||||
const swfPath = path.join(
|
||||
/*turbopackIgnore: true*/ swfDir,
|
||||
`${baseClassname}.swf`,
|
||||
);
|
||||
const iconPath = path.join(
|
||||
/*turbopackIgnore: true*/ iconDir,
|
||||
`${iconFileName}_icon.png`,
|
||||
);
|
||||
|
||||
const needsNitro = !existsSync(/*turbopackIgnore: true*/ nitroPath);
|
||||
const needsIcon = !existsSync(/*turbopackIgnore: true*/ iconPath);
|
||||
|
||||
if (!needsNitro && !needsIcon) {
|
||||
return {
|
||||
classname,
|
||||
ok: true,
|
||||
iconRepaired: false,
|
||||
nitroRepaired: false,
|
||||
warnings: ["Nothing to repair"],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Resolve candidate revisions ─────────────────────────────────────
|
||||
// Priority order:
|
||||
// 1. habbo.it furnidata — authoritative for current live assets
|
||||
// 2. items_base.revision (what we originally recorded at import)
|
||||
// 3. items_base.spriteId (some emulators reuse sprite id as revision)
|
||||
const revsToTry: number[] = [];
|
||||
const pushRev = (r?: number | null) => {
|
||||
if (r && r > 0 && !revsToTry.includes(r)) revsToTry.push(r);
|
||||
};
|
||||
|
||||
try {
|
||||
const furnidata = await getHabboItFurnidata();
|
||||
const fd = furnidata.get(classname) || furnidata.get(baseClassname);
|
||||
if (fd) pushRev(fd.revision);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
pushRev(row.spriteId);
|
||||
|
||||
// Fallback: revision 0 is valid for many habbo.com paths
|
||||
if (revsToTry.length === 0) revsToTry.push(0);
|
||||
|
||||
// ── Ensure SWF exists ───────────────────────────────────────────────
|
||||
// We also download the SWF when only the icon is missing, so we can
|
||||
// fall back to SWF-embedded icon extraction when habbo.com 404s the PNG.
|
||||
let swfOk = existsSync(swfPath);
|
||||
const wantSwf = needsNitro || needsIcon;
|
||||
if (wantSwf && !swfOk) {
|
||||
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) {
|
||||
swfOk = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!swfOk && needsNitro) {
|
||||
warnings.push(
|
||||
`SWF not found on habbo.com (tried revisions: ${revsToTry.join(", ")})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Regenerate .nitro from SWF ──────────────────────────────────────
|
||||
let nitroRepaired = false;
|
||||
if (needsNitro && swfOk) {
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(swfPath);
|
||||
const conversion = convertSwfToNitro(swfBuffer, baseClassname);
|
||||
await fs.writeFile(nitroPath, conversion.bundle);
|
||||
nitroRepaired = true;
|
||||
|
||||
// Sync dims + interaction flags back to items_base (best effort)
|
||||
const dims = conversion.dimensions || { x: 1, y: 1, z: 0 };
|
||||
const meta = conversion.metadata || null;
|
||||
const cansiton = meta?.cansiton ?? false;
|
||||
const canlayon = meta?.canlayon ?? false;
|
||||
const canstandon = meta?.canstandon ?? false;
|
||||
const allowStack = (dims.z ?? 0) > 0 ? "1" : "0";
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE items_base
|
||||
SET width = ${dims.x}, length = ${dims.y}, stack_height = ${dims.z ?? 0},
|
||||
allow_sit = ${cansiton ? "1" : "0"},
|
||||
allow_lay = ${canlayon ? "1" : "0"},
|
||||
allow_walk = ${canstandon ? "1" : "0"},
|
||||
allow_stack = ${allowStack},
|
||||
canlayon = ${canlayon ? "1" : "0"}
|
||||
WHERE id = ${row.id}
|
||||
`;
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`items_base dim update failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Keep FurnitureData.json in sync with the regenerated metadata so the
|
||||
// Nitro client sees up-to-date dims/flags without a full re-import.
|
||||
try {
|
||||
let habboIt = null;
|
||||
try {
|
||||
habboIt = await lookupHabboIt(classname);
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
const entry = buildFurniEntry({
|
||||
id: row.id,
|
||||
classname,
|
||||
revision: habboIt?.revision ?? 0,
|
||||
category: habboIt?.category || "unknown",
|
||||
name: row.publicName || habboIt?.name || classname,
|
||||
description: habboIt?.description || "",
|
||||
spriteId: row.spriteId,
|
||||
dims: { x: dims.x, y: dims.y, z: dims.z ?? 0 },
|
||||
metadata: meta,
|
||||
habboIt,
|
||||
});
|
||||
await appendFurniEntry(entry, row.type);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`FurnitureData.json sync failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`SWF → Nitro conversion failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Repair icon (Nitro-style cascade) ───────────────────────────────
|
||||
//
|
||||
// Nitro client icon URL template: `${hof.furni.url}/icons/%libname%%param%_icon.png`
|
||||
// - %libname% = base classname (before `*`)
|
||||
// - %param% = `_N` for color variants, empty otherwise
|
||||
//
|
||||
// So for `bonusrare18_1*0` habbo.com can host either:
|
||||
// - bonusrare18_1_0_icon.png (color-specific) ← try first
|
||||
// - bonusrare18_1_icon.png (base fallback, Nitro reuses for all colors)
|
||||
//
|
||||
// Cascade per revision:
|
||||
// 1. color-specific URL (only if classname has `*`)
|
||||
// 2. base URL (always)
|
||||
// After exhausting the network: extract from SWF (embedded icon).
|
||||
let iconRepaired = false;
|
||||
let iconSource: "habbo-color" | "habbo-base" | "swf" | null = null;
|
||||
if (needsIcon) {
|
||||
const hasColor = starIdx !== -1;
|
||||
const colorAwareName = classname.replace(/\*/g, "_");
|
||||
|
||||
for (const tryRev of revsToTry) {
|
||||
// 1. color-specific icon (authoritative for color variants)
|
||||
if (hasColor) {
|
||||
const dl = await downloadFile(
|
||||
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(colorAwareName)}_icon.png`,
|
||||
iconPath,
|
||||
{ validate: "png" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
iconRepaired = true;
|
||||
iconSource = "habbo-color";
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 2. base icon (also used by Nitro when no color-specific one exists)
|
||||
const dlBase = await downloadFile(
|
||||
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}_icon.png`,
|
||||
iconPath,
|
||||
{ validate: "png" },
|
||||
);
|
||||
if (dlBase.ok) {
|
||||
iconRepaired = true;
|
||||
iconSource = "habbo-base";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: extract from SWF (works offline + covers items with no public PNG)
|
||||
if (!iconRepaired && existsSync(swfPath)) {
|
||||
try {
|
||||
const swfBuf = await fs.readFile(swfPath);
|
||||
const iconPng = extractIconFromSwf(swfBuf, baseClassname);
|
||||
if (iconPng) {
|
||||
await fs.writeFile(iconPath, iconPng);
|
||||
iconRepaired = true;
|
||||
iconSource = "swf";
|
||||
warnings.push("Icon extracted from SWF (no PNG on habbo.com)");
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(`SWF icon extraction failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!iconRepaired) {
|
||||
warnings.push(
|
||||
existsSync(swfPath)
|
||||
? "Icon not on habbo.com and SWF has no embedded icon"
|
||||
: `Icon not found on habbo.com (tried revisions: ${revsToTry.join(", ")})`,
|
||||
);
|
||||
} else if (iconSource === "habbo-base" && hasColor) {
|
||||
warnings.push("Used base-classname icon (color-specific not published)");
|
||||
}
|
||||
}
|
||||
|
||||
const ok = (!needsNitro || nitroRepaired) && (!needsIcon || iconRepaired);
|
||||
|
||||
// Flag items for which every external revision returned 404 → no point retrying later.
|
||||
// Criteria: we tried to download something and nothing (not even the SWF extraction)
|
||||
// produced a result.
|
||||
const unrepairable =
|
||||
!ok &&
|
||||
!iconRepaired &&
|
||||
!nitroRepaired &&
|
||||
// At least one outbound call was attempted and failed with 404-style errors
|
||||
(warnings.some((w) => w.startsWith("SWF not found on habbo.com")) ||
|
||||
warnings.some((w) => w.startsWith("Icon not found on habbo.com")));
|
||||
|
||||
return { classname, ok, iconRepaired, nitroRepaired, warnings, unrepairable };
|
||||
}
|
||||
Reference in new issue
Block a user