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

+18 -12
View File
@@ -76,8 +76,15 @@ export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT }, { permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => { async (request, ctx) => {
const body = await request.json(); const body = await request.json();
const { name, furnidataUrl, nitroBaseUrl, iconBaseUrl, hotel, id } = const {
body as Record<string, string>; name,
furnidataUrl,
nitroBaseUrl,
iconBaseUrl,
hotel,
id,
sourceSwfBaseUrl,
} = body as Record<string, string>;
const isHttps = (u: string) => { const isHttps = (u: string) => {
try { try {
@@ -89,22 +96,21 @@ export const POST = withAdmin(
if (!name?.trim()) return apiError("name is required", 400); if (!name?.trim()) return apiError("name is required", 400);
if (!furnidataUrl?.trim()) return apiError("furnidataUrl is required", 400); if (!furnidataUrl?.trim()) return apiError("furnidataUrl is required", 400);
if (!nitroBaseUrl?.trim()) return apiError("nitroBaseUrl is required", 400);
if (!iconBaseUrl?.trim()) return apiError("iconBaseUrl is required", 400); const normalized = (u: string | undefined) =>
if ( (u ?? "").trim().replace(/\/+$/, "");
!isHttps(furnidataUrl) ||
!isHttps(nitroBaseUrl) || if (!isHttps(furnidataUrl))
!isHttps(iconBaseUrl) return apiError("furnidataUrl must be a valid https:// URL", 400);
)
return apiError("Source URLs must be valid https:// URLs", 400);
const entry = await upsertSource({ const entry = await upsertSource({
id, id,
name, name,
furnidataUrl, furnidataUrl,
nitroBaseUrl, nitroBaseUrl: normalized(nitroBaseUrl),
iconBaseUrl, iconBaseUrl: normalized(iconBaseUrl),
hotel: hotel ?? "", hotel: hotel ?? "",
sourceSwfBaseUrl: normalized(sourceSwfBaseUrl),
}); });
logAudit({ logAudit({
@@ -2,6 +2,7 @@ import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response"; import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { getSource } from "@/lib/services/clone-sources";
import { appendFurniEntriesBatch } from "@/lib/services/furni-data"; import { appendFurniEntriesBatch } from "@/lib/services/furni-data";
import { import {
ensureDirectories, ensureDirectories,
@@ -31,9 +32,16 @@ export const POST = withAdmin(
const body = await request.json(); const body = await request.json();
const rawItems: BatchItem[] = body.items || []; const rawItems: BatchItem[] = body.items || [];
const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5); const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
const sourceId: string | undefined = body.sourceId;
if (rawItems.length === 0) return apiError("No items to import", 400); if (rawItems.length === 0) return apiError("No items to import", 400);
// Look up clone source for SWF/nitro/icon URL fallbacks
let source: Awaited<ReturnType<typeof getSource>> | null = null;
if (sourceId) {
source = await getSource(sourceId);
}
// Deduplicate by (spriteId, classname) to avoid concurrent INSERT races on // Deduplicate by (spriteId, classname) to avoid concurrent INSERT races on
// items_base.id when the same item is listed twice in the request body. // items_base.id when the same item is listed twice in the request body.
const seen = new Set<string>(); const seen = new Set<string>();
@@ -94,6 +102,9 @@ export const POST = withAdmin(
revision: item.revision ?? 0, revision: item.revision ?? 0,
category: item.category ?? "unknown", category: item.category ?? "unknown",
skipFurniDataWrite: true, skipFurniDataWrite: true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl,
iconBaseUrl: source?.iconBaseUrl,
onProgress: (status: string) => { onProgress: (status: string) => {
send({ send({
type: "item_progress", type: "item_progress",
+24 -1
View File
@@ -6,6 +6,7 @@ import { apiError, apiOk } from "@/lib/api-response";
import { CatalogPages, db, ItemsBase } from "@/lib/db"; import { CatalogPages, db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { getSource } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { import {
autoPriceFurni, autoPriceFurni,
@@ -260,6 +261,10 @@ export const POST = withAdmin(
// ── Batch import ───────────────────────────────────────────────── // ── Batch import ─────────────────────────────────────────────────
if (Array.isArray(body.items)) { if (Array.isArray(body.items)) {
const { sourceId } = body as { sourceId?: string };
let source: Awaited<ReturnType<typeof getSource>> | null = null;
if (sourceId) source = await getSource(sourceId);
const results: Array<{ const results: Array<{
classname: string; classname: string;
ok: boolean; ok: boolean;
@@ -291,6 +296,9 @@ export const POST = withAdmin(
type: item.type ?? "flooritem", type: item.type ?? "flooritem",
revision: item.revision ?? 0, revision: item.revision ?? 0,
category: item.category ?? "unknown", category: item.category ?? "unknown",
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
}); });
results.push({ classname: item.classname, ...result }); results.push({ classname: item.classname, ...result });
@@ -341,10 +349,22 @@ export const POST = withAdmin(
} }
// ── Single import ──────────────────────────────────────────────── // ── Single import ────────────────────────────────────────────────
const { id, classname, name, description, type, revision, category } = body; const {
id,
classname,
name,
description,
type,
revision,
category,
sourceId,
} = body;
if (!classname || !name) return apiError("Missing classname or name", 400); if (!classname || !name) return apiError("Missing classname or name", 400);
let source: Awaited<ReturnType<typeof getSource>> | null = null;
if (sourceId) source = await getSource(sourceId);
const result = await importSingleFurni({ const result = await importSingleFurni({
id: id ?? 0, id: id ?? 0,
classname, classname,
@@ -353,6 +373,9 @@ export const POST = withAdmin(
type: type ?? "flooritem", type: type ?? "flooritem",
revision: revision ?? 0, revision: revision ?? 0,
category: category ?? "unknown", category: category ?? "unknown",
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
}); });
if (!result.ok) return apiError(result.error ?? "Import failed", 409); if (!result.ok) return apiError(result.error ?? "Import failed", 409);
+25
View File
@@ -8,6 +8,16 @@ export interface CloneSource {
nitroBaseUrl: string; nitroBaseUrl: string;
iconBaseUrl: string; iconBaseUrl: string;
hotel: 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"; const KEY = "clone_sources";
@@ -124,6 +134,19 @@ export const DEFAULT_SOURCES: CloneSource[] = [
nitroBaseUrl: "https://www.habblet.city/nitro/bundled/furniture", nitroBaseUrl: "https://www.habblet.city/nitro/bundled/furniture",
iconBaseUrl: "https://www.habblet.city/nitro/icons", iconBaseUrl: "https://www.habblet.city/nitro/icons",
hotel: "com", 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 }, src: Omit<CloneSource, "id"> & { id?: string },
): Promise<CloneSource> { ): Promise<CloneSource> {
const sources = await listSources(); const sources = await listSources();
const swfBase = src.sourceSwfBaseUrl?.trim().replace(/\/+$/, "");
const entry: CloneSource = { const entry: CloneSource = {
id: src.id || randomUUID(), id: src.id || randomUUID(),
name: src.name.trim(), name: src.name.trim(),
@@ -156,6 +180,7 @@ export async function upsertSource(
nitroBaseUrl: src.nitroBaseUrl.trim().replace(/\/+$/, ""), nitroBaseUrl: src.nitroBaseUrl.trim().replace(/\/+$/, ""),
iconBaseUrl: src.iconBaseUrl.trim().replace(/\/+$/, ""), iconBaseUrl: src.iconBaseUrl.trim().replace(/\/+$/, ""),
hotel: (src.hotel ?? "").trim(), hotel: (src.hotel ?? "").trim(),
sourceSwfBaseUrl: swfBase || undefined,
}; };
const idx = sources.findIndex((s) => s.id === entry.id); const idx = sources.findIndex((s) => s.id === entry.id);
if (idx >= 0) sources[idx] = entry; if (idx >= 0) sources[idx] = entry;
+78 -27
View File
@@ -383,6 +383,11 @@ export async function importSingleFurni(params: {
category: string; category: string;
skipFurniDataWrite?: boolean; skipFurniDataWrite?: boolean;
onProgress?: (status: string) => void; 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> { }): Promise<ImportSingleResult> {
const { const {
id: originalId, id: originalId,
@@ -394,6 +399,9 @@ export async function importSingleFurni(params: {
category, category,
skipFurniDataWrite, skipFurniDataWrite,
onProgress, onProgress,
sourceSwfBaseUrl,
nitroBaseUrl: sourceNitroBaseUrl,
iconBaseUrl: sourceIconBaseUrl,
} = params; } = params;
const warnings: string[] = []; const warnings: string[] = [];
@@ -495,33 +503,71 @@ export async function importSingleFurni(params: {
// fall back to base-classname icon (Nitro reuses base for all colors // fall back to base-classname icon (Nitro reuses base for all colors
// when no color-specific icon exists). // when no color-specific icon exists).
onProgress?.("downloading"); onProgress?.("downloading");
const iconColorAware = classname.replace(/\*/g, "_"); 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([ // Build candidate URL lists — source-specific URLs first, then Habbo CDN fallback
downloadFile(iconUrl, iconPath, { validate: "png" }), const iconUrls: string[] = [];
downloadFile( const swfUrls: string[] = [];
`https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(safeSwfName)}.swf`,
swfPath,
{ validate: "swf" },
),
]);
let iconOk = // Source-specific icon URLs
downloadResults[0].status === "fulfilled" && downloadResults[0].value.ok; if (sourceIconBaseUrl) {
const swfOk = // e.g. https://habblet.city/nitro/icons/{classname}_icon.png
downloadResults[1].status === "fulfilled" && downloadResults[1].value.ok; iconUrls.push(
`${sourceIconBaseUrl}/${encodeURIComponent(iconColorAware)}_icon.png`,
// If color-specific icon 404'd, try the base-classname icon. `${sourceIconBaseUrl}/${encodeURIComponent(baseClassname)}_icon.png`,
if (!iconOk && hasColor) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`,
iconPath,
{ validate: "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 ──────────── // ── Icon fallback: extract from SWF if download failed ────────────
@@ -531,7 +577,8 @@ export async function importSingleFurni(params: {
const iconPng = extractIconFromSwf(swfBuf, baseClassname); const iconPng = extractIconFromSwf(swfBuf, baseClassname);
if (iconPng) { if (iconPng) {
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, 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 { } else {
warnings.push( warnings.push(
"Icon download failed and SWF icon extraction returned nothing", "Icon download failed and SWF icon extraction returned nothing",
@@ -544,7 +591,7 @@ export async function importSingleFurni(params: {
warnings.push("Icon image download failed"); 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 ────────────────────────────────────────── // ── Convert SWF to Nitro ──────────────────────────────────────────
onProgress?.("converting"); onProgress?.("converting");
@@ -577,8 +624,12 @@ export async function importSingleFurni(params: {
} }
} }
// ── Rollback: if conversion failed AND no .nitro exists, remove DB record ── // ── Rollback: if conversion/download failed AND no .nitro exists, remove DB record ──
if (!existsSync(/*turbopackIgnore: true*/ nitroPath) && !conversionResult) { if (
!existsSync(/*turbopackIgnore: true*/ nitroPath) &&
!conversionResult &&
!nitroDownloadOk
) {
try { try {
await db.execute( await db.execute(
sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`, sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`,