diff --git a/src/lib/services/clone-sources.ts b/src/lib/services/clone-sources.ts index 529aa02e..c73e0400 100644 --- a/src/lib/services/clone-sources.ts +++ b/src/lib/services/clone-sources.ts @@ -11,6 +11,30 @@ export interface CloneSource { 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 { const raw = (await siteSettings.get(KEY, "[]")) ?? "[]"; try { diff --git a/src/lib/services/import/core/download.ts b/src/lib/services/import/core/download.ts index 9c7cbd6a..38573968 100644 --- a/src/lib/services/import/core/download.ts +++ b/src/lib/services/import/core/download.ts @@ -29,7 +29,14 @@ export async function downloadFile( if (attempt > 0) { 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) { const deterministic = res.status === 404 || res.status === 410 || res.status === 403; diff --git a/src/lib/services/repair-icons.ts b/src/lib/services/repair-icons.ts index 3b79cd67..4b926d5c 100644 --- a/src/lib/services/repair-icons.ts +++ b/src/lib/services/repair-icons.ts @@ -3,7 +3,11 @@ import path from "node:path"; import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; 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 { downloadFile } from "@/lib/services/import/core/download"; import { ensureDirectories } from "./furni-import"; @@ -108,12 +112,34 @@ export async function repairMissingIcons( } if (!ok) { + let sources: CloneSource[] = []; try { - const sources = await listSources(); - for (const source of sources) { + 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}/${classname}_icon.png`, + `${source.iconBaseUrl}/${iconName}`, iconPath, { maxRetries: 1, validate: "png" }, ); @@ -121,12 +147,45 @@ export async function repairMissingIcons( ok = true; break; } - } catch { - warnings.push(`download from ${source.name} failed`); + warnings.push(`${iconName} not found on ${source.name}`); + } catch (err) { + warnings.push( + `download from ${source.name} failed: ${(err as Error).message}`, + ); } } - } catch (err) { - warnings.push(`listSources 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; + } } }