Fall back to well-known public furni icon hosts (HabboAssets, Hubbly, Leet) when no clone sources are configured, so Repair Icons can download missing icons out of the box. Also handle variant classnames (base name and '*' replaced with '_') and extract icons from remote .nitro bundles. Send a browser User-Agent on downloads to avoid being blocked by hotels.
224 lines
5.5 KiB
TypeScript
224 lines
5.5 KiB
TypeScript
import { existsSync, promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { sql } from "drizzle-orm";
|
|
import { db } from "@/lib/db";
|
|
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
|
import {
|
|
type CloneSource,
|
|
DEFAULT_ICON_SOURCES,
|
|
listSources,
|
|
} from "@/lib/services/clone-sources";
|
|
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
|
import { downloadFile } from "@/lib/services/import/core/download";
|
|
import { ensureDirectories } from "./furni-import";
|
|
|
|
export interface RepairEvent {
|
|
type: "started" | "progress" | "done" | "batch_complete" | "error";
|
|
classname?: string;
|
|
status?: string;
|
|
index?: number;
|
|
total?: number;
|
|
message?: string;
|
|
warnings?: string[];
|
|
succeeded?: number;
|
|
failed?: number;
|
|
skipped?: number;
|
|
duration?: number;
|
|
}
|
|
|
|
export async function repairMissingIcons(
|
|
onEvent?: (evt: RepairEvent) => void,
|
|
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
|
let succeeded = 0;
|
|
let failed = 0;
|
|
let skipped = 0;
|
|
|
|
try {
|
|
await ensureDirectories();
|
|
} catch (err) {
|
|
onEvent?.({
|
|
type: "error",
|
|
message: `ensureDirectories failed: ${(err as Error).message}`,
|
|
});
|
|
return { succeeded: 0, failed: 0, skipped: 0 };
|
|
}
|
|
|
|
let iconDir: string;
|
|
let nitroDir: string;
|
|
try {
|
|
const dirs = await getFurniAssetDirs();
|
|
iconDir = dirs.iconDir;
|
|
nitroDir = dirs.nitroDir;
|
|
} catch (err) {
|
|
onEvent?.({
|
|
type: "error",
|
|
message: `getFurniAssetDirs failed: ${(err as Error).message}`,
|
|
});
|
|
return { succeeded: 0, failed: 0, skipped: 0 };
|
|
}
|
|
|
|
let items: Array<{ id: number; item_name: string }>;
|
|
try {
|
|
const [rows] = (await db.execute(sql`
|
|
SELECT id, item_name FROM items_base ORDER BY id
|
|
`)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
|
|
items = rows;
|
|
} catch (err) {
|
|
onEvent?.({
|
|
type: "error",
|
|
message: `Database query failed: ${(err as Error).message}`,
|
|
});
|
|
return { succeeded: 0, failed: 0, skipped: 0 };
|
|
}
|
|
|
|
const total = items.length;
|
|
onEvent?.({ type: "started", total });
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const { item_name: classname } = items[i];
|
|
const iconPath = path.join(iconDir, `${classname}_icon.png`);
|
|
|
|
if (existsSync(iconPath)) {
|
|
skipped++;
|
|
onEvent?.({
|
|
type: "progress",
|
|
classname,
|
|
status: "skipped",
|
|
index: i,
|
|
total,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let ok = false;
|
|
const warnings: string[] = [];
|
|
|
|
const nitroPath = path.join(nitroDir, `${classname}.nitro`);
|
|
if (existsSync(nitroPath)) {
|
|
try {
|
|
const nitroBuf = await fs.readFile(nitroPath);
|
|
const icon = extractFurniIconPng(nitroBuf);
|
|
if (icon) {
|
|
await fs.writeFile(iconPath, icon);
|
|
ok = true;
|
|
} else {
|
|
warnings.push("no icon asset in .nitro bundle");
|
|
}
|
|
} catch (err) {
|
|
warnings.push(`extract from .nitro failed: ${(err as Error).message}`);
|
|
}
|
|
} else {
|
|
warnings.push(".nitro file not found on disk");
|
|
}
|
|
|
|
if (!ok) {
|
|
let sources: CloneSource[] = [];
|
|
try {
|
|
sources = (await listSources()).filter(
|
|
(s) => s.iconBaseUrl || s.nitroBaseUrl,
|
|
);
|
|
} catch (err) {
|
|
warnings.push(`listSources failed: ${(err as Error).message}`);
|
|
}
|
|
if (sources.length === 0) sources = DEFAULT_ICON_SOURCES;
|
|
|
|
for (const source of sources) {
|
|
const iconCandidates = [
|
|
`${classname}_icon.png`,
|
|
// Variant classnames (e.g. `table_silo_med*10`) often only exist
|
|
// on sources under their base name (e.g. `table_silo_med_icon.png`)
|
|
// or with the `*` replaced by `_` (e.g. `table_silo_med_10_icon.png`).
|
|
...(classname.includes("*")
|
|
? [
|
|
`${classname.split("*")[0]}_icon.png`,
|
|
`${classname.replace(/\*/g, "_")}_icon.png`,
|
|
]
|
|
: []),
|
|
];
|
|
|
|
for (const iconName of iconCandidates) {
|
|
try {
|
|
const dl = await downloadFile(
|
|
`${source.iconBaseUrl}/${iconName}`,
|
|
iconPath,
|
|
{ maxRetries: 1, validate: "png" },
|
|
);
|
|
if (dl.ok) {
|
|
ok = true;
|
|
break;
|
|
}
|
|
warnings.push(`${iconName} not found on ${source.name}`);
|
|
} catch (err) {
|
|
warnings.push(
|
|
`download from ${source.name} failed: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
if (ok) break;
|
|
|
|
// Some sources only ship icons embedded in their `.nitro` bundles —
|
|
// grab the bundle and extract the catalog icon from it.
|
|
if (source.nitroBaseUrl) {
|
|
const nitroTmp = path.join(nitroDir, `.repair_${classname}.nitro`);
|
|
try {
|
|
const dl = await downloadFile(
|
|
`${source.nitroBaseUrl}/${classname}.nitro`,
|
|
nitroTmp,
|
|
{ maxRetries: 1 },
|
|
);
|
|
if (dl.ok) {
|
|
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
|
if (icon) {
|
|
await fs.writeFile(iconPath, icon);
|
|
ok = true;
|
|
} else {
|
|
warnings.push(`no icon asset in ${source.name} .nitro bundle`);
|
|
}
|
|
} else {
|
|
warnings.push(`.nitro not found on ${source.name}`);
|
|
}
|
|
} catch (err) {
|
|
warnings.push(
|
|
`extract from ${source.name} .nitro failed: ${(err as Error).message}`,
|
|
);
|
|
} finally {
|
|
await fs.unlink(nitroTmp).catch(() => {});
|
|
}
|
|
if (ok) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ok) {
|
|
succeeded++;
|
|
onEvent?.({
|
|
type: "progress",
|
|
classname,
|
|
status: "done",
|
|
index: i,
|
|
total,
|
|
warnings: warnings.length ? warnings : undefined,
|
|
});
|
|
} else {
|
|
failed++;
|
|
onEvent?.({
|
|
type: "progress",
|
|
classname,
|
|
status: "failed",
|
|
index: i,
|
|
total,
|
|
message: warnings[0] || "could not obtain icon",
|
|
});
|
|
}
|
|
}
|
|
|
|
onEvent?.({
|
|
type: "batch_complete",
|
|
succeeded,
|
|
failed,
|
|
skipped,
|
|
});
|
|
|
|
return { succeeded, failed, skipped };
|
|
}
|