diff --git a/src/app/api/admin/studio/source-assets/route.test.ts b/src/app/api/admin/studio/source-assets/route.test.ts
new file mode 100644
index 0000000000..59fe40a4cc
--- /dev/null
+++ b/src/app/api/admin/studio/source-assets/route.test.ts
@@ -0,0 +1,86 @@
+import { NextRequest } from "next/server";
+import { beforeEach, expect, it, vi } from "vitest";
+import { PERMS } from "@/lib/permission-slugs";
+
+const mocks = vi.hoisted(() => ({
+ guard: vi.fn(),
+ source: vi.fn(),
+ official: vi.fn(),
+ inspect: vi.fn(),
+}));
+vi.mock("@/lib/api-handler", () => ({
+ withAdmin: (options: unknown, handler: unknown) => {
+ mocks.guard(options);
+ return handler;
+ },
+}));
+vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
+vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
+ getOfficialHabboFurnidata: mocks.official,
+}));
+vi.mock("@/lib/services/furniture-source-assets", () => ({
+ inspectSourceAssets: mocks.inspect,
+}));
+
+import { POST } from "./route";
+
+const item = {
+ id: 1,
+ classname: "chair",
+ name: "Chair",
+ description: "",
+ revision: 1,
+ type: "flooritem",
+ category: "other",
+};
+const request = (body: unknown) =>
+ new NextRequest("http://localhost/api/admin/studio/source-assets", {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+beforeEach(() => {
+ mocks.source.mockReset().mockResolvedValue(null);
+ mocks.official.mockReset().mockResolvedValue(new Map());
+ mocks.inspect
+ .mockReset()
+ .mockResolvedValue({
+ classname: "chair",
+ state: "missing",
+ revision: 1,
+ alternatives: [],
+ });
+});
+it("requires import permission", () =>
+ expect(mocks.guard).toHaveBeenCalledWith({
+ permission: PERMS.ASSETS_IMPORT,
+ }));
+it.each([
+ [],
+ Array(21).fill(item),
+ [{ ...item, classname: "../secret" }],
+ [{ ...item, revision: -1 }],
+ [null],
+])("rejects invalid items before network checks: %j", async (items) => {
+ expect((await POST(request({ items }))).status).toBe(400);
+ expect(mocks.inspect).not.toHaveBeenCalled();
+});
+it("rejects unknown configured sources", async () => {
+ expect(
+ (await POST(request({ items: [item], sourceId: "missing" }))).status,
+ ).toBe(404);
+ expect(mocks.inspect).not.toHaveBeenCalled();
+});
+it("uses server source configuration instead of client URLs", async () => {
+ const source = { id: "custom", nitroBaseUrl: "https://configured.example" };
+ mocks.source.mockResolvedValue(source);
+ const response = await POST(
+ request({
+ items: [item],
+ sourceId: "custom",
+ nitroBaseUrl: "https://untrusted.example",
+ }),
+ );
+ expect(response.status).toBe(200);
+ expect(mocks.inspect).toHaveBeenCalledWith(item, [], source);
+ expect((await response.json()).items[0].state).toBe("missing");
+});
diff --git a/src/app/api/admin/studio/source-assets/route.ts b/src/app/api/admin/studio/source-assets/route.ts
new file mode 100644
index 0000000000..a0e024312a
--- /dev/null
+++ b/src/app/api/admin/studio/source-assets/route.ts
@@ -0,0 +1,59 @@
+import { withAdmin } from "@/lib/api-handler";
+import { apiError, apiOk } from "@/lib/api-response";
+import type {
+ SourceAssetCheck,
+ SourceAssetItem,
+} from "@/lib/furni/source-assets";
+import { validateClassnames } from "@/lib/furni/studio-inspection";
+import { PERMS } from "@/lib/permission-slugs";
+import { getSource } from "@/lib/services/clone-sources";
+import { inspectSourceAssets } from "@/lib/services/furniture-source-assets";
+import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
+export const POST = withAdmin(
+ { permission: PERMS.ASSETS_IMPORT },
+ async (request) => {
+ const body = await request.json().catch(() => null);
+ if (
+ !Array.isArray(body?.items) ||
+ body.items.length > 20 ||
+ !validateClassnames(
+ body.items.map((item: SourceAssetItem) => item?.classname),
+ )
+ )
+ return apiError("Provide 1–20 furniture items", 400);
+ if (
+ body.items.some(
+ (item: SourceAssetItem) =>
+ !Number.isSafeInteger(item.revision) ||
+ item.revision < 0 ||
+ typeof item.name !== "string" ||
+ item.name.length > 500,
+ )
+ )
+ return apiError("Invalid source metadata", 400);
+ const source = body.sourceId ? await getSource(body.sourceId) : null;
+ if (body.sourceId && !source) return apiError("Source not found", 404);
+ const entries = await getOfficialHabboFurnidata();
+ const official: SourceAssetItem[] = [...entries.values()].map((entry) => ({
+ id: entry.id,
+ classname: entry.classname,
+ name: entry.name,
+ description: entry.description,
+ type: entry.itemType === "i" ? "wallitem" : "flooritem",
+ revision: entry.revision,
+ category: entry.category,
+ }));
+ const results: SourceAssetCheck[] = [];
+ for (let offset = 0; offset < body.items.length; offset += 4)
+ results.push(
+ ...(await Promise.all(
+ body.items
+ .slice(offset, offset + 4)
+ .map((item: SourceAssetItem) =>
+ inspectSourceAssets(item, official, source),
+ ),
+ )),
+ );
+ return apiOk({ items: results });
+ },
+);
diff --git a/src/components/admin/studio/import-review.tsx b/src/components/admin/studio/import-review.tsx
index 451d4505bc..1e662c730b 100644
--- a/src/components/admin/studio/import-review.tsx
+++ b/src/components/admin/studio/import-review.tsx
@@ -16,23 +16,33 @@ import {
useFurnitureInspection,
} from "./furniture-inspector";
import type { FurniItem } from "./studio-types";
+import { useSourceAssetChecks } from "./use-source-asset-checks";
export function ImportReview({
- items,
+ items: originalItems,
+ sourceId,
source,
translation,
onCancel,
onConfirm,
}: {
items: FurniItem[];
+ sourceId?: string;
source: string;
translation: string;
onCancel: () => void;
- onConfirm: (items: FurniItem[]) => void;
+ onConfirm: (items: FurniItem[], replaced?: boolean) => void;
}) {
+ const [items, setItems] = useState(originalItems);
+ const [replacements, setReplacements] = useState([]);
const inspection = useFurnitureInspection(
items.map((item) => item.classname),
);
+ const assets = useSourceAssetChecks(items, sourceId, inspection);
+ const assetsByName = new Map(
+ assets.items.map((item) => [item.classname, item]),
+ );
+ const unavailable = assets.items.filter((item) => item.state === "missing");
const [expanded, setExpanded] = useState(items[0]?.classname ?? "");
const [step, setStep] = useState<"review" | "confirm">("review");
const [page, setPage] = useState(0);
@@ -42,7 +52,11 @@ export function ImportReview({
const complete = items.every((item) => localByName.has(item.classname));
const ready = items.filter((item) => {
const local = localByName.get(item.classname);
- return local && compareFurniture(item, local).state !== "conflict";
+ return (
+ local &&
+ assetsByName.get(item.classname)?.state !== "missing" &&
+ compareFurniture(item, local).state !== "conflict"
+ );
});
const blocked = items.filter((item) => {
const local = localByName.get(item.classname);
@@ -98,6 +112,104 @@ export function ImportReview({
Selection: {items.length} furniture items
+ {replacements.length > 0 && (
+
+ Replacements selected: {replacements.join(" · ")}{" "}
+
+
+ )}
+ {assets.loading && !inspection.loading && (
+
+ Checking source files… {assets.items.length} checked
+
+ )}
+ {assets.error && (
+
+
{assets.error}
+
+
+ )}
+ {assets.items.some((item) => item.state === "unknown") && (
+
+ Some sources could not be verified. The import will still try to
+ download their files.
+
+ )}
+ {unavailable.map((check) => (
+
+
+ Source files unavailable: {check.classname}
+
+
+ This item will be skipped. Choose a reachable alternative below
+ to replace it in this import.
+
+ {check.alternatives.length === 0 && (
+
+ No verified alternative found. Upload the original .nitro from
+ the import page if you have it.
+
+ )}
+ {check.alternatives.map((alternative) => (
+
+
+
{alternative.name}
+
{alternative.classname}
+
+ {alternative.type} · Revision {alternative.revision} · ID{" "}
+ {alternative.id}
+
+
+
+
+ ))}
+
+ ))}
{inspection.loading ? (
Checking local data and assets…
) : inspection.error ? (
@@ -115,7 +227,8 @@ export function ImportReview({
<>
{ready.length} ready to import · {existing} already in SQL
- (complete missing data) · {blocked} conflicts (skipped)
+ (complete missing data) · {blocked} conflicts ·{" "}
+ {unavailable.length} unavailable (skipped)
{inspection.items.some(
(item) =>
@@ -257,6 +370,8 @@ export function ImportReview({
}}
disabled={
inspection.loading ||
+ assets.loading ||
+ !!assets.error ||
!!inspection.error ||
!complete ||
ready.length === 0
@@ -265,7 +380,7 @@ export function ImportReview({
if (step === "review") {
setStep("confirm");
setPage(0);
- } else onConfirm(ready);
+ } else onConfirm(ready, replacements.length > 0);
}}
>
{step === "review"
diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx
index 4c6324267a..298916e094 100644
--- a/src/components/admin/studio/studio-client.tsx
+++ b/src/components/admin/studio/studio-client.tsx
@@ -2391,6 +2391,7 @@ export function StudioClient({
{review && (
entry.id === activeSource)?.name ??
source.label
@@ -2403,10 +2404,10 @@ export function StudioClient({
: "Off"
}
onCancel={() => setReview(null)}
- onConfirm={(accepted) => {
+ onConfirm={(accepted, replaced) => {
const single = review.single;
setReview(null);
- if (review.cloneAll)
+ if (review.cloneAll && !replaced)
void cloneAllMissing(accepted.map((item) => item.classname));
else if (single && accepted[0]) void importSingle(accepted[0]);
else
diff --git a/src/components/admin/studio/use-source-asset-checks.ts b/src/components/admin/studio/use-source-asset-checks.ts
new file mode 100644
index 0000000000..0eb583b9f6
--- /dev/null
+++ b/src/components/admin/studio/use-source-asset-checks.ts
@@ -0,0 +1,80 @@
+"use client";
+import { useEffect, useState } from "react";
+import { adminFetch } from "@/lib/admin-fetch";
+import type { SourceAssetCheck } from "@/lib/furni/source-assets";
+import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
+import type { FurniItem } from "./studio-types";
+export function useSourceAssetChecks(
+ items: FurniItem[],
+ sourceId: string | undefined,
+ local: { items: FurnitureInspection[]; loading: boolean; error: string },
+) {
+ const [attempt, setAttempt] = useState(0);
+ const needed = items.filter(
+ (item) =>
+ !local.items.some(
+ (entry) =>
+ entry.classname === item.classname && entry.nitro.exists === true,
+ ),
+ );
+ const key =
+ local.loading || local.error
+ ? ""
+ : JSON.stringify({ items: needed, sourceId, attempt });
+ const [state, setState] = useState<{
+ key: string;
+ loading: boolean;
+ error: string;
+ items: SourceAssetCheck[];
+ }>({ key: "", loading: true, error: "", items: [] });
+ useEffect(() => {
+ if (!key) return;
+ const abort = new AbortController();
+ setState({ key, loading: true, error: "", items: [] });
+ void (async () => {
+ const { items, sourceId } = JSON.parse(key) as {
+ items: FurniItem[];
+ sourceId?: string;
+ };
+ const results: SourceAssetCheck[] = [];
+ try {
+ for (let offset = 0; offset < items.length; offset += 20) {
+ const res = await adminFetch("/api/admin/studio/source-assets", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ items: items.slice(offset, offset + 20),
+ sourceId,
+ }),
+ signal: abort.signal,
+ });
+ const data = await res.json();
+ if (!res.ok || !Array.isArray(data.items))
+ throw Error(data.error || "Could not check source assets");
+ results.push(...data.items);
+ if (!abort.signal.aborted)
+ setState({ key, items: [...results], loading: true, error: "" });
+ }
+ if (results.length !== items.length)
+ throw Error("Source check is incomplete. Retry before importing.");
+ if (!abort.signal.aborted)
+ setState({ key, items: results, loading: false, error: "" });
+ } catch (error) {
+ if (!abort.signal.aborted)
+ setState({
+ key,
+ items: [],
+ loading: false,
+ error: (error as Error).message,
+ });
+ }
+ })();
+ return () => abort.abort();
+ }, [key]);
+ return {
+ ...state,
+ loading: !key || state.key !== key || state.loading,
+ items: state.key === key ? state.items : [],
+ retry: () => setAttempt((value) => value + 1),
+ };
+}
diff --git a/src/lib/furni/source-assets.ts b/src/lib/furni/source-assets.ts
new file mode 100644
index 0000000000..f0399bc0ee
--- /dev/null
+++ b/src/lib/furni/source-assets.ts
@@ -0,0 +1,40 @@
+export interface SourceAssetItem {
+ id: number;
+ classname: string;
+ name: string;
+ description: string;
+ type: string;
+ revision: number;
+ category: string;
+}
+export type AssetAvailability = "available" | "missing" | "unknown";
+export interface SourceAssetCheck {
+ classname: string;
+ state: AssetAvailability;
+ revision: number;
+ alternatives: SourceAssetItem[];
+}
+export function relatedFurniture(
+ item: SourceAssetItem,
+ candidates: SourceAssetItem[],
+) {
+ const tail = (name: string) =>
+ name.toLowerCase().split("_").slice(-2).join("_");
+ const label = (name: string) => (name ?? "").trim().toLowerCase();
+ return candidates
+ .filter(
+ (candidate) =>
+ candidate.classname !== item.classname &&
+ candidate.type === item.type &&
+ ((label(item.name) && label(candidate.name) === label(item.name)) ||
+ (item.classname.split("_").length >= 3 &&
+ tail(candidate.classname) === tail(item.classname))),
+ )
+ .sort(
+ (a, b) =>
+ Number(label(b.name) === label(item.name)) -
+ Number(label(a.name) === label(item.name)) ||
+ a.classname.localeCompare(b.classname),
+ )
+ .slice(0, 5);
+}
diff --git a/src/lib/services/furni-import-repair.test.ts b/src/lib/services/furni-import-repair.test.ts
index 79314b180c..5f6064b710 100644
--- a/src/lib/services/furni-import-repair.test.ts
+++ b/src/lib/services/furni-import-repair.test.ts
@@ -178,3 +178,22 @@ it("imports recycler_kintsugiB with an allocated ID throughout SQL and furnidata
).toContain("2000037258");
expect(result.warnings.join(" ")).toContain("16940");
});
+
+it("does not create or delete SQL rows when a new furniture asset is unavailable", async () => {
+ vi.clearAllMocks();
+ mocks.official.mockResolvedValue(null);
+ mocks.download.mockResolvedValue(false);
+ mocks.select.mockImplementation(() => ({
+ from: () => ({ where: () => ({ limit: async () => [] }) }),
+ }));
+ mocks.stat.mockRejectedValue(new Error("missing"));
+ mocks.conflict.mockResolvedValue(null);
+ const result = await importSingleFurni({
+ ...input,
+ classname: "missing_new_asset",
+ });
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain("Import not started");
+ expect(mocks.execute).not.toHaveBeenCalled();
+ expect(mocks.append).not.toHaveBeenCalled();
+});
diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts
index 35f2e493fc..93e41389cd 100644
--- a/src/lib/services/furni-import.ts
+++ b/src/lib/services/furni-import.ts
@@ -619,45 +619,6 @@ export async function importSingleFurni(params: {
const bestRevision = revision || officialHabbo?.revision || 0;
const bestCategory = category || officialHabbo?.category || "unknown";
- // ── Insert into DB with enriched data ──────────────────────────────
- onProgress?.("writing_db");
- try {
- if (preservingExisting) {
- warnings.push(
- `Existing furniture #${itemId}: SQL settings and catalog offers preserved`,
- );
- } else if (isUpdate) {
- // NOTE: live Arcturus `items_base` has no description/revision/rare columns —
- // those metadata fields are persisted in FurnitureData.json (see entry below),
- // not in the DB. Only refresh the columns this import manages.
- await db.execute(sql`
- UPDATE items_base SET sprite_id = ${spriteId}, public_name = ${bestName}, item_name = ${classname}, type = ${itemType}
- WHERE id = ${spriteId}
- `);
- } else {
- itemId = await reserveFurnitureId(
- originalId,
- classname,
- async (allocatedId) => {
- await db.execute(
- sql`INSERT INTO items_base (id, sprite_id, public_name, item_name, type) VALUES (${allocatedId}, ${allocatedId}, ${bestName}, ${classname}, ${itemType})`,
- );
- },
- );
- spriteId = itemId;
- if (spriteId !== originalId)
- warnings.push(
- `Source ID ${originalId} is occupied; imported with local ID ${spriteId}`,
- );
- }
- } catch (err) {
- return {
- ok: false,
- warnings,
- error: `DB ${isUpdate ? "update" : "insert"} failed: ${(err as Error).message}`,
- };
- }
-
// ── Derive file-safe names ────────────────────────────────────────
const rev = bestRevision;
const assetTargets = await getFurniAssetWriteTargets();
@@ -828,7 +789,7 @@ export async function importSingleFurni(params: {
.catch(() => null);
const nitroValid = nitroStat && nitroStat.size >= 128;
- if (!nitroValid && !conversionResult) {
+ if (!nitroValid) {
if (nitroStat) {
warnings.push(
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
@@ -839,24 +800,56 @@ export async function importSingleFurni(params: {
/* ignore */
}
}
- try {
- if (!isUpdate)
- await db.execute(
- sql`DELETE FROM items_base WHERE id = ${itemId} AND item_name = ${classname}`,
- );
- } catch {
- /* best effort cleanup */
- }
+
return {
ok: false,
warnings,
- error: `${isUpdate ? "No .nitro file could be created; existing furniture was preserved" : "Import rolled back: no .nitro file could be created"} (${classname}, revision ${rev}). ${warnings
+ error: `${isUpdate ? "No .nitro file could be created; existing furniture was preserved" : "Import not started: no .nitro file could be created"} (${classname}, revision ${rev}). ${warnings
.filter((warning) => /conversion|download|SWF/.test(warning))
.slice(-3)
.join(". ")}`,
};
}
+ // ── Insert into DB with enriched data ──────────────────────────────
+ onProgress?.("writing_db");
+ try {
+ if (preservingExisting) {
+ warnings.push(
+ `Existing furniture #${itemId}: SQL settings and catalog offers preserved`,
+ );
+ } else if (isUpdate) {
+ // NOTE: live Arcturus `items_base` has no description/revision/rare columns —
+ // those metadata fields are persisted in FurnitureData.json (see entry below),
+ // not in the DB. Only refresh the columns this import manages.
+ await db.execute(sql`
+ UPDATE items_base SET sprite_id = ${spriteId}, public_name = ${bestName}, item_name = ${classname}, type = ${itemType}
+ WHERE id = ${spriteId}
+ `);
+ } else {
+ itemId = await reserveFurnitureId(
+ originalId,
+ classname,
+ async (allocatedId) => {
+ await db.execute(
+ sql`INSERT INTO items_base (id, sprite_id, public_name, item_name, type) VALUES (${allocatedId}, ${allocatedId}, ${bestName}, ${classname}, ${itemType})`,
+ );
+ },
+ );
+ spriteId = itemId;
+ if (spriteId !== originalId)
+ warnings.push(
+ `Source ID ${originalId} is occupied; imported with local ID ${spriteId}`,
+ );
+ }
+ } catch (err) {
+ return {
+ ok: false,
+ warnings,
+ error: `DB ${isUpdate ? "update" : "insert"} failed: ${(err as Error).message}`,
+ };
+ }
+
await Promise.all([
mirrorFileToDirs(swfPath, `${safeSwfName}.swf`, mirrorSwfDirs, warnings),
mirrorFileToDirs(
diff --git a/src/lib/services/furniture-source-assets.test.ts b/src/lib/services/furniture-source-assets.test.ts
new file mode 100644
index 0000000000..d2324ac68c
--- /dev/null
+++ b/src/lib/services/furniture-source-assets.test.ts
@@ -0,0 +1,99 @@
+// @vitest-environment node
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ relatedFurniture,
+ type SourceAssetItem,
+} from "@/lib/furni/source-assets";
+import { inspectSourceAssets, probeAsset } from "./furniture-source-assets";
+
+const source: SourceAssetItem = {
+ id: 1,
+ classname: "h25_monkeybzn_pole",
+ name: "Jungle flags",
+ description: "Flags",
+ type: "flooritem",
+ revision: 73253,
+ category: "other",
+};
+const alternative = {
+ ...source,
+ id: 2,
+ classname: "br_c25_monkeybzn_pole",
+ revision: 73257,
+};
+afterEach(() => vi.unstubAllGlobals());
+describe("source asset preflight", () => {
+ it("suggests reachable alternatives for unavailable source files", async () => {
+ const fetchMock = vi.fn(
+ async (url: string) =>
+ new Response(null, { status: url.includes("/73257/") ? 200 : 404 }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ const result = await inspectSourceAssets(source, [source, alternative]);
+ expect(result.state).toBe("missing");
+ expect(result.alternatives).toEqual([alternative]);
+ });
+ it("does not describe blocked or unreachable servers as missing files", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(null, { status: 403 })),
+ );
+ const result = await inspectSourceAssets(
+ { ...source, classname: "blocked_test" },
+ [alternative],
+ );
+ expect(result.state).toBe("unknown");
+ expect(result.alternatives).toEqual([]);
+ });
+ it("accepts an available configured Nitro source", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ async (url: string) =>
+ new Response(null, { status: url.endsWith(".nitro") ? 200 : 404 }),
+ ),
+ );
+ const result = await inspectSourceAssets(
+ { ...source, classname: "custom_test" },
+ [],
+ {
+ id: "custom",
+ name: "Custom",
+ hotel: "",
+ furnidataUrl: "https://source.example/data.json",
+ nitroBaseUrl: "https://source.example/furniture",
+ iconBaseUrl: "",
+ },
+ );
+ expect(result.state).toBe("available");
+ });
+ it("bounds suggestions and excludes different furniture types", () => {
+ expect(
+ relatedFurniture(source, [source, { ...alternative, type: "wallitem" }]),
+ ).toEqual([]);
+ expect(
+ relatedFurniture(
+ source,
+ Array.from({ length: 20 }, (_, id) => ({
+ ...alternative,
+ id,
+ classname: `f${id}_monkeybzn_pole`,
+ })),
+ ),
+ ).toHaveLength(5);
+ });
+ it("handles unnamed source entries while finding related assets", () => {
+ expect(
+ relatedFurniture(source, [
+ { ...alternative, name: null as unknown as string },
+ ]),
+ ).toHaveLength(1);
+ });
+ it("caches a probe to avoid repeating CDN requests", async () => {
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+ await probeAsset("https://example.test/cache.nitro");
+ await probeAsset("https://example.test/cache.nitro");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/services/furniture-source-assets.ts b/src/lib/services/furniture-source-assets.ts
new file mode 100644
index 0000000000..90482fac5b
--- /dev/null
+++ b/src/lib/services/furniture-source-assets.ts
@@ -0,0 +1,98 @@
+import type {
+ AssetAvailability,
+ SourceAssetCheck,
+ SourceAssetItem,
+} from "@/lib/furni/source-assets";
+import { relatedFurniture } from "@/lib/furni/source-assets";
+import type { CloneSource } from "./clone-sources";
+import { browserHeaders } from "./import/core/browser-headers";
+
+const cache = new Map();
+export async function probeAsset(url: string): Promise {
+ const cached = cache.get(url);
+ if (cached && cached.expires > Date.now()) return cached.state;
+ let state: AssetAvailability = "unknown";
+ try {
+ const response = await fetch(url, {
+ method: "HEAD",
+ signal: AbortSignal.timeout(6000),
+ headers: browserHeaders(),
+ });
+ if (
+ response.ok &&
+ !response.headers.get("content-type")?.includes("text/html")
+ )
+ state = "available";
+ else if (response.status === 404 || response.status === 410)
+ state = "missing";
+ } catch {
+ /* A network problem is not proof the asset is absent. */
+ }
+ if (cache.size >= 2000) cache.clear();
+ cache.set(url, { expires: Date.now() + 60000, state });
+ return state;
+}
+export function furnitureAssetUrls(
+ item: SourceAssetItem,
+ source?: CloneSource | null,
+) {
+ const base = encodeURIComponent(item.classname.split("*")[0]);
+ const urls: string[] = [];
+ if (source?.nitroBaseUrl)
+ urls.push(`${source.nitroBaseUrl.replace(/\/+$/, "")}/${base}.nitro`);
+ if (source?.sourceSwfBaseUrl) {
+ const root = source.sourceSwfBaseUrl.replace(/\/+$/, "");
+ urls.push(
+ `${root}/hof_furni/${item.revision}/${base}.swf`,
+ `${root}/hof_furni/${base}.swf`,
+ );
+ }
+ urls.push(
+ `https://images.habbo.com/dcr/hof_furni/${item.revision}/${base}.swf`,
+ );
+ return urls;
+}
+async function available(
+ item: SourceAssetItem,
+ source?: CloneSource | null,
+): Promise {
+ const states = await Promise.all(
+ furnitureAssetUrls(item, source).map(probeAsset),
+ );
+ return states.includes("available")
+ ? "available"
+ : states.every((state) => state === "missing")
+ ? "missing"
+ : "unknown";
+}
+export async function inspectSourceAssets(
+ item: SourceAssetItem,
+ official: SourceAssetItem[],
+ source?: CloneSource | null,
+): Promise {
+ const canonical = official.find(
+ (entry) => entry.classname === item.classname,
+ );
+ const effective = {
+ ...item,
+ revision: item.revision || canonical?.revision || 0,
+ };
+ const state = await available(effective, source);
+ const alternatives: SourceAssetItem[] = [];
+ if (state === "missing") {
+ for (const candidate of relatedFurniture(
+ { ...effective, name: canonical?.name || item.name },
+ official,
+ )) {
+ if ((await available(candidate)) === "available")
+ alternatives.push(candidate);
+ if (alternatives.length === 3) break;
+ }
+ }
+ return {
+ classname: item.classname,
+ revision: effective.revision,
+ state,
+ alternatives,
+ };
+}