perf: enable source-specific asset downloads and make nitro/icon URLs optional
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:
1 parent
4c93dc38c6
commit
a1775fbf1e
5 files changed
+156
-40
No files matched your search
@@ -8,6 +8,16 @@ export interface CloneSource {
|
||||
nitroBaseUrl: string;
|
||||
iconBaseUrl: string;
|
||||
hotel: string;
|
||||
/**
|
||||
* Optional per-source base URL for downloading SWF files.
|
||||
* When set, the import service will try this URL before falling back
|
||||
* to the official Habbo CDN (images.habbo.com).
|
||||
*
|
||||
* Retro hotels often have their own dcr/hof_furni structure,
|
||||
* e.g. "https://virtualcity.nl/dcr" →
|
||||
* "https://virtualcity.nl/dcr/hof_furni/{revision}/{classname}.swf"
|
||||
*/
|
||||
sourceSwfBaseUrl?: string;
|
||||
}
|
||||
|
||||
const KEY = "clone_sources";
|
||||
@@ -124,6 +134,19 @@ export const DEFAULT_SOURCES: CloneSource[] = [
|
||||
nitroBaseUrl: "https://www.habblet.city/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://www.habblet.city/nitro/icons",
|
||||
hotel: "com",
|
||||
// Retro hotels may use their own SWF server (Habbo official CDN still has legacy files)
|
||||
// This field enables per-source SWF/nitro/icon download base URL fallback
|
||||
// The import service will try: sourceSwfBaseUrl > images.habbo.com
|
||||
sourceSwfBaseUrl: "https://www.habblet.city/dcr",
|
||||
},
|
||||
{
|
||||
id: "default-virtualcity",
|
||||
name: "VirtualCity",
|
||||
furnidataUrl: "https://virtualc.nl/gamedata/furnidata_json/1",
|
||||
nitroBaseUrl: "https://virtualc.nl/nitro/bundled/furniture",
|
||||
iconBaseUrl: "https://virtualc.nl/nitro/icons",
|
||||
hotel: "com",
|
||||
sourceSwfBaseUrl: "https://virtualc.nl/dcr",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -149,6 +172,7 @@ export async function upsertSource(
|
||||
src: Omit<CloneSource, "id"> & { id?: string },
|
||||
): Promise<CloneSource> {
|
||||
const sources = await listSources();
|
||||
const swfBase = src.sourceSwfBaseUrl?.trim().replace(/\/+$/, "");
|
||||
const entry: CloneSource = {
|
||||
id: src.id || randomUUID(),
|
||||
name: src.name.trim(),
|
||||
@@ -156,6 +180,7 @@ export async function upsertSource(
|
||||
nitroBaseUrl: src.nitroBaseUrl.trim().replace(/\/+$/, ""),
|
||||
iconBaseUrl: src.iconBaseUrl.trim().replace(/\/+$/, ""),
|
||||
hotel: (src.hotel ?? "").trim(),
|
||||
sourceSwfBaseUrl: swfBase || undefined,
|
||||
};
|
||||
const idx = sources.findIndex((s) => s.id === entry.id);
|
||||
if (idx >= 0) sources[idx] = entry;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in new issue
Block a user