diff --git a/src/app/api/admin/import/clone/route.ts b/src/app/api/admin/import/clone/route.ts index 1c235bf5..a082f23e 100644 --- a/src/app/api/admin/import/clone/route.ts +++ b/src/app/api/admin/import/clone/route.ts @@ -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; + const { + name, + furnidataUrl, + nitroBaseUrl, + iconBaseUrl, + hotel, + id, + sourceSwfBaseUrl, + } = body as Record; 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({ diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts index ac55e8c7..dcea9886 100644 --- a/src/app/api/admin/import/furni/batch/route.ts +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -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> | 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(); @@ -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", diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 8b7ac1ff..0e9c6abf 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -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> | 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> | 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); diff --git a/src/lib/services/clone-sources.ts b/src/lib/services/clone-sources.ts index a8d1557d..f9f6e7c2 100644 --- a/src/lib/services/clone-sources.ts +++ b/src/lib/services/clone-sources.ts @@ -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 & { id?: string }, ): Promise { 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; diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 2ba47186..4bfe833e 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -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 { 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}`,