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 },
async (request, ctx) => {
const body = await request.json();
const { name, furnidataUrl, nitroBaseUrl, iconBaseUrl, hotel, id } =
body as Record<string, string>;
const {
name,
furnidataUrl,
nitroBaseUrl,
iconBaseUrl,
hotel,
id,
sourceSwfBaseUrl,
} = body as Record<string, string>;
const isHttps = (u: string) => {
try {
@@ -89,22 +96,21 @@ export const POST = withAdmin(
if (!name?.trim()) return apiError("name 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);
if (
!isHttps(furnidataUrl) ||
!isHttps(nitroBaseUrl) ||
!isHttps(iconBaseUrl)
)
return apiError("Source URLs must be valid https:// URLs", 400);
const normalized = (u: string | undefined) =>
(u ?? "").trim().replace(/\/+$/, "");
if (!isHttps(furnidataUrl))
return apiError("furnidataUrl must be a valid https:// URL", 400);
const entry = await upsertSource({
id,
name,
furnidataUrl,
nitroBaseUrl,
iconBaseUrl,
nitroBaseUrl: normalized(nitroBaseUrl),
iconBaseUrl: normalized(iconBaseUrl),
hotel: hotel ?? "",
sourceSwfBaseUrl: normalized(sourceSwfBaseUrl),
});
logAudit({
@@ -2,6 +2,7 @@ import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getSource } from "@/lib/services/clone-sources";
import { appendFurniEntriesBatch } from "@/lib/services/furni-data";
import {
ensureDirectories,
@@ -31,9 +32,16 @@ export const POST = withAdmin(
const body = await request.json();
const rawItems: BatchItem[] = body.items || [];
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);
// 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
// items_base.id when the same item is listed twice in the request body.
const seen = new Set<string>();
@@ -94,6 +102,9 @@ export const POST = withAdmin(
revision: item.revision ?? 0,
category: item.category ?? "unknown",
skipFurniDataWrite: true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl,
iconBaseUrl: source?.iconBaseUrl,
onProgress: (status: string) => {
send({
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 { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getSource } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
autoPriceFurni,
@@ -260,6 +261,10 @@ export const POST = withAdmin(
// ── Batch import ─────────────────────────────────────────────────
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<{
classname: string;
ok: boolean;
@@ -291,6 +296,9 @@ export const POST = withAdmin(
type: item.type ?? "flooritem",
revision: item.revision ?? 0,
category: item.category ?? "unknown",
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
});
results.push({ classname: item.classname, ...result });
@@ -341,10 +349,22 @@ export const POST = withAdmin(
}
// ── 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);
let source: Awaited<ReturnType<typeof getSource>> | null = null;
if (sourceId) source = await getSource(sourceId);
const result = await importSingleFurni({
id: id ?? 0,
classname,
@@ -353,6 +373,9 @@ export const POST = withAdmin(
type: type ?? "flooritem",
revision: revision ?? 0,
category: category ?? "unknown",
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
});
if (!result.ok) return apiError(result.error ?? "Import failed", 409);