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
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user