feat: add default furni icon repair sources
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.
This commit is contained in:
1 parent
bac2f653af
commit
1f9e8a0eec
3 files changed
+99
-9
No files matched your search
@@ -11,6 +11,30 @@ export interface CloneSource {
|
|||||||
|
|
||||||
const KEY = "clone_sources";
|
const KEY = "clone_sources";
|
||||||
|
|
||||||
|
export const DEFAULT_ICON_SOURCES: CloneSource[] = [
|
||||||
|
{
|
||||||
|
id: "default-habboassets",
|
||||||
|
name: "HabboAssets",
|
||||||
|
furnidataUrl: "",
|
||||||
|
nitroBaseUrl: "",
|
||||||
|
iconBaseUrl: "https://www.habboassets.com/assets/furniture",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "default-hubbly",
|
||||||
|
name: "Hubbly",
|
||||||
|
furnidataUrl: "",
|
||||||
|
nitroBaseUrl: "",
|
||||||
|
iconBaseUrl: "https://hubbly.pw/swf/dcr/hof_furni/icons",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "default-leet",
|
||||||
|
name: "Leet",
|
||||||
|
furnidataUrl: "",
|
||||||
|
nitroBaseUrl: "",
|
||||||
|
iconBaseUrl: "https://images.leet.city/library/hof_furni/icons",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export async function listSources(): Promise<CloneSource[]> {
|
export async function listSources(): Promise<CloneSource[]> {
|
||||||
const raw = (await siteSettings.get(KEY, "[]")) ?? "[]";
|
const raw = (await siteSettings.get(KEY, "[]")) ?? "[]";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ export async function downloadFile(
|
|||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
||||||
}
|
}
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
const res = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36",
|
||||||
|
Accept: "image/png,image/*,*/*;q=0.8",
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const deterministic =
|
const deterministic =
|
||||||
res.status === 404 || res.status === 410 || res.status === 403;
|
res.status === 404 || res.status === 410 || res.status === 403;
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import path from "node:path";
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
||||||
import { listSources } from "@/lib/services/clone-sources";
|
import {
|
||||||
|
type CloneSource,
|
||||||
|
DEFAULT_ICON_SOURCES,
|
||||||
|
listSources,
|
||||||
|
} from "@/lib/services/clone-sources";
|
||||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||||
import { downloadFile } from "@/lib/services/import/core/download";
|
import { downloadFile } from "@/lib/services/import/core/download";
|
||||||
import { ensureDirectories } from "./furni-import";
|
import { ensureDirectories } from "./furni-import";
|
||||||
@@ -108,12 +112,34 @@ export async function repairMissingIcons(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
let sources: CloneSource[] = [];
|
||||||
try {
|
try {
|
||||||
const sources = await listSources();
|
sources = (await listSources()).filter(
|
||||||
for (const source of sources) {
|
(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 {
|
try {
|
||||||
const dl = await downloadFile(
|
const dl = await downloadFile(
|
||||||
`${source.iconBaseUrl}/${classname}_icon.png`,
|
`${source.iconBaseUrl}/${iconName}`,
|
||||||
iconPath,
|
iconPath,
|
||||||
{ maxRetries: 1, validate: "png" },
|
{ maxRetries: 1, validate: "png" },
|
||||||
);
|
);
|
||||||
@@ -121,12 +147,45 @@ export async function repairMissingIcons(
|
|||||||
ok = true;
|
ok = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch {
|
warnings.push(`${iconName} not found on ${source.name}`);
|
||||||
warnings.push(`download from ${source.name} failed`);
|
} catch (err) {
|
||||||
|
warnings.push(
|
||||||
|
`download from ${source.name} failed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
if (ok) break;
|
||||||
warnings.push(`listSources failed: ${(err as Error).message}`);
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user