perf: enable source-specific asset downloads and make nitro/icon URLs optional
CI / check (push) Successful in 26s
CI / release (push) Skipped
CI / deploy (push) Successful in 48s

Import improvements:
- importSingleFurni now accepts sourceSwfBaseUrl, nitroBaseUrl, iconBaseUrl
  params to try source-specific asset downloads before falling back to
  the official Habbo CDN (images.habbo.com)
- Source-specific URL cascade:
  1. Try sourceSwfBaseUrl/hof_furni/{rev}/{name}.swf
  2. Try sourceNitroBaseUrl/{name}.nitro (pre-made nitro bundles)
  3. Fallback to images.habbo.com/dcr/hof_furni/{rev}/{name}.swf
- Same fallback chain for icon downloads
- Clone sources can now have empty nitroBaseUrl/iconBaseUrl (retro
  hotels without nitro support)
- Added VirtualCity source (verified SWF downloads via virtualc.nl/dcr)
- Added sourceSwfBaseUrl field to CloneSource interface
- Both batch and single import routes pass source params through
- Clone POST route accepts sourceSwfBaseUrl, makes nitro/icon optional
- Nitro fallback download tries .nitro files before SWF conversion
This commit is contained in:
openhands committed 2026-08-06 20:19:54 +02:00
1 parent 4c93dc38c6
commit a1775fbf1e
5 files changed
+156 -40

No files matched your search

+78 -27
View File
@@ -383,6 +383,11 @@ export async function importSingleFurni(params: {
category: string;
skipFurniDataWrite?: boolean;
onProgress?: (status: string) => void;
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
sourceSwfBaseUrl?: string;
/** Per-source nitro bundle + icon base URL. */
nitroBaseUrl?: string;
iconBaseUrl?: string;
}): Promise<ImportSingleResult> {
const {
id: originalId,
@@ -394,6 +399,9 @@ export async function importSingleFurni(params: {
category,
skipFurniDataWrite,
onProgress,
sourceSwfBaseUrl,
nitroBaseUrl: sourceNitroBaseUrl,
iconBaseUrl: sourceIconBaseUrl,
} = params;
const warnings: string[] = [];
@@ -495,33 +503,71 @@ export async function importSingleFurni(params: {
// fall back to base-classname icon (Nitro reuses base for all colors
// when no color-specific icon exists).
onProgress?.("downloading");
const iconColorAware = classname.replace(/\*/g, "_");
const iconUrl = hasColor
? `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(iconColorAware)}_icon.png`
: `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`;
const downloadResults = await Promise.allSettled([
downloadFile(iconUrl, iconPath, { validate: "png" }),
downloadFile(
`https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(safeSwfName)}.swf`,
swfPath,
{ validate: "swf" },
),
]);
// Build candidate URL lists — source-specific URLs first, then Habbo CDN fallback
const iconUrls: string[] = [];
const swfUrls: string[] = [];
let iconOk =
downloadResults[0].status === "fulfilled" && downloadResults[0].value.ok;
const swfOk =
downloadResults[1].status === "fulfilled" && downloadResults[1].value.ok;
// If color-specific icon 404'd, try the base-classname icon.
if (!iconOk && hasColor) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`,
iconPath,
{ validate: "png" },
// Source-specific icon URLs
if (sourceIconBaseUrl) {
// e.g. https://habblet.city/nitro/icons/{classname}_icon.png
iconUrls.push(
`${sourceIconBaseUrl}/${encodeURIComponent(iconColorAware)}_icon.png`,
`${sourceIconBaseUrl}/${encodeURIComponent(baseClassname)}_icon.png`,
);
if (dl.ok) iconOk = true;
}
// Source-specific SWF URLs
if (sourceSwfBaseUrl) {
// e.g. https://virtualc.nl/dcr/hof_furni/{rev}/{classname}.swf
swfUrls.push(
`${sourceSwfBaseUrl}/hof_furni/${rev}/${encodeURIComponent(safeSwfName)}.swf`,
);
}
// Official Habbo CDN fallbacks
iconUrls.push(
`https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(iconColorAware)}_icon.png`,
`https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`,
);
swfUrls.push(
`https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(safeSwfName)}.swf`,
);
// Download icon (try all candidate URLs)
let iconOk = false;
for (const iconUrl of iconUrls) {
const dl = await downloadFile(iconUrl, iconPath, { validate: "png" });
if (dl.ok) {
iconOk = true;
break;
}
}
// Download SWF (try all candidate URLs)
let swfOk = false;
for (const swfUrl of swfUrls) {
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
if (dl.ok) {
swfOk = true;
break;
}
}
// ── Nitro bundle fallback: try downloading pre-made .nitro ────────
// If SWF download failed, try downloading a pre-made .nitro from the
// source's nitro base URL (many retro hotels ship .nitro bundles).
let nitroDownloadOk = false;
if (
!swfOk &&
sourceNitroBaseUrl &&
!existsSync(/*turbopackIgnore: true*/ nitroPath)
) {
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" });
if (dl.ok) nitroDownloadOk = true;
}
// ── Icon fallback: extract from SWF if download failed ────────────
@@ -531,7 +577,8 @@ export async function importSingleFurni(params: {
const iconPng = extractIconFromSwf(swfBuf, baseClassname);
if (iconPng) {
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, iconPng);
warnings.push("Icon extracted from SWF (external download failed)");
iconOk = true;
warnings.push("Icon extracted from SWF (download failed)");
} else {
warnings.push(
"Icon download failed and SWF icon extraction returned nothing",
@@ -544,7 +591,7 @@ export async function importSingleFurni(params: {
warnings.push("Icon image download failed");
}
if (!swfOk) warnings.push("SWF download failed");
if (!swfOk && !nitroDownloadOk) warnings.push("SWF download failed");
// ── Convert SWF to Nitro ──────────────────────────────────────────
onProgress?.("converting");
@@ -577,8 +624,12 @@ export async function importSingleFurni(params: {
}
}
// ── Rollback: if conversion failed AND no .nitro exists, remove DB record ──
if (!existsSync(/*turbopackIgnore: true*/ nitroPath) && !conversionResult) {
// ── Rollback: if conversion/download failed AND no .nitro exists, remove DB record ──
if (
!existsSync(/*turbopackIgnore: true*/ nitroPath) &&
!conversionResult &&
!nitroDownloadOk
) {
try {
await db.execute(
sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`,