Files
EpicNext-Cms/src/lib/services/repair-icons.ts
T
openhands d587749b50
CI / check (push) Successful in 23s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m3s
fix: precise item classification in catalog audit and repair
Detect badges by items_base.type='b' (authoritative, album gifs as
fallback), recognize pet/animals (a0 pet<N>, pet<N> interaction) and
system items (effects, bots, sticky notes), and resolve asset names for
dot/star classname variants so the audit no longer floods with false
missing nitro/icon errors and repair skips non-furni items.
2026-08-04 11:07:04 +02:00

429 lines
11 KiB
TypeScript

import { existsSync, promises as fs } from "node:fs";
import os from "node:os";
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 {
type FurniAssetWriteTargets,
getFurniAssetWriteTargets,
getGamedataRoot,
} from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download";
import { classifyCatalogItem, NO_ASSET_KINDS } from "@/lib/services/item-kind";
import { ensureDirectories } from "./furni-import";
const REPAIR_CONCURRENCY = 6;
/** Sliding-window worker pool: keeps up to `concurrency` tasks in flight. */
async function runPool<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>,
): Promise<void> {
const limit = Math.min(Math.max(concurrency, 1), items.length);
if (limit === 0) return;
let next = 0;
await Promise.allSettled(
Array.from({ length: limit }, async () => {
while (true) {
const i = next++;
if (i >= items.length) break;
await worker(items[i], i);
}
}),
);
}
function uniqueDirs(dirs: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const dir of dirs) {
const key = process.platform === "win32" ? dir.toLowerCase() : dir;
if (seen.has(key)) continue;
seen.add(key);
out.push(dir);
}
return out;
}
async function writeIconToDirs(
icon: Buffer,
dirs: string[],
fileName: string,
): Promise<string[]> {
const errors: string[] = [];
for (const dir of dirs) {
try {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, fileName), icon);
} catch (err) {
errors.push(`write icon to ${dir} failed: ${(err as Error).message}`);
}
}
return errors;
}
async function copyIconAcrossDirs(
srcDir: string,
destDirs: string[],
fileName: string,
): Promise<string[]> {
const errors: string[] = [];
const src = path.join(srcDir, fileName);
for (const dir of destDirs) {
if (dir === srcDir) continue;
try {
await fs.mkdir(dir, { recursive: true });
await fs.copyFile(src, path.join(dir, fileName));
} catch (err) {
errors.push(`copy icon to ${dir} failed: ${(err as Error).message}`);
}
}
return errors;
}
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 targets: FurniAssetWriteTargets;
try {
targets = await getFurniAssetWriteTargets();
} catch (err) {
onEvent?.({
type: "error",
message: `getFurniAssetWriteTargets failed: ${(err as Error).message}`,
});
return { succeeded: 0, failed: 0, skipped: 0 };
}
// Every location the app mirrors furni assets into — the webroot (public/)
// plus the live gamedata (e.g. /var/www/Gamedata). Repair writes to all of
// them so both the CMS and the client see the icon.
const iconDirs = uniqueDirs([
targets.iconDir,
...targets.mirrorDirs.map((d) => d.iconDir),
]);
const nitroDirs = uniqueDirs([
targets.nitroDir,
...targets.mirrorDirs.map((d) => d.nitroDir),
]);
// Badges and other special items (pets, effects, bots, sticky notes) never
// ship furni `_icon.png` files — resolve the skip set from the DB so badge
// detection is precise (`items_base.type = 'b'`) instead of relying only on
// which .gif files happen to exist in the album directory.
let skipSet: Set<string> | null = null;
try {
const gamedataRoot = await getGamedataRoot();
if (gamedataRoot) {
const badgeFiles = await fs
.readdir(path.join(gamedataRoot, "album1584"))
.catch(() => [] as string[]);
const albumBadgeSet = new Set(
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
);
skipSet = albumBadgeSet;
}
} catch {
skipSet = null;
}
let items: Array<{
id: number;
item_name: string;
type: string;
interaction_type: string;
}>;
try {
const [rows] = (await db.execute(sql`
SELECT id, item_name, type, interaction_type FROM items_base ORDER BY id
`)) as unknown as [
Array<{
id: number;
item_name: string;
type: string;
interaction_type: string;
}>,
unknown,
];
items = rows;
} catch (err) {
onEvent?.({
type: "error",
message: `Database query failed: ${(err as Error).message}`,
});
return { succeeded: 0, failed: 0, skipped: 0 };
}
// Build the authoritative skip set from the DB, supplemented by album .gifs.
const dbSkipSet = new Set<string>();
for (const item of items) {
if (NO_ASSET_KINDS.has(classifyCatalogItem(item))) {
dbSkipSet.add(item.item_name);
}
}
for (const code of skipSet ?? []) {
dbSkipSet.add(code);
}
const total = items.length;
onEvent?.({ type: "started", total });
const processItem = async (classname: string) => {
// Special items (badges, pets, effects, bots, sticky notes) don't have
// .png icon files to repair. Skip them.
if (dbSkipSet.has(classname)) {
return { status: "skipped" } as const;
}
const fileName = `${classname}_icon.png`;
// Badge items store icons as badge_<code>_icon.png (the classname already
// includes the `badge_` prefix, so fileName matches correctly).
const presentDirs = iconDirs.filter((dir) =>
existsSync(path.join(dir, fileName)),
);
const missingDirs = iconDirs.filter((dir) => !presentDirs.includes(dir));
if (missingDirs.length === 0) {
return { status: "skipped" } as const;
}
let ok = false;
const warnings: string[] = [];
// 1) Copy across mirrors — the icon already exists in one target (e.g.
// gamedata), so just fill the ones that are missing it.
if (presentDirs.length > 0) {
try {
const copyErrors = await copyIconAcrossDirs(
presentDirs[0],
missingDirs,
fileName,
);
warnings.push(...copyErrors);
ok = copyErrors.length === 0;
} catch (err) {
warnings.push(
`copy across icon dirs failed: ${(err as Error).message}`,
);
}
}
// 2) Extract from a local .nitro bundle (checked across every target).
if (!ok) {
let foundNitro = false;
for (const dir of nitroDirs) {
const nitroPath = path.join(dir, `${classname}.nitro`);
if (!existsSync(nitroPath)) continue;
foundNitro = true;
try {
const nitroBuf = await fs.readFile(nitroPath);
const icon = extractFurniIconPng(nitroBuf);
if (icon) {
const writeErrors = await writeIconToDirs(
icon,
missingDirs,
fileName,
);
warnings.push(...writeErrors);
ok = writeErrors.length === 0;
break;
}
warnings.push("no icon asset in .nitro bundle");
} catch (err) {
warnings.push(
`extract from .nitro failed: ${(err as Error).message}`,
);
}
}
if (!foundNitro) warnings.push(".nitro file not found on disk");
}
// 3) Download from configured or default public sources.
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`,
]
: []),
// Classnames with dots (`post.it`) are stored on disk with
// underscores (`post_it_icon.png`).
...(classname.includes(".")
? [`${classname.replace(/\./g, "_")}_icon.png`]
: []),
];
for (const iconName of iconCandidates) {
try {
const dl = await downloadFile(
`${source.iconBaseUrl}/${iconName}`,
path.join(targets.iconDir, fileName),
{ maxRetries: 1, validate: "png" },
);
if (dl.ok) {
const icon = await fs.readFile(
path.join(targets.iconDir, fileName),
);
const writeErrors = await writeIconToDirs(
icon,
missingDirs,
fileName,
);
warnings.push(...writeErrors);
ok = writeErrors.length === 0;
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(os.tmpdir(), `.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) {
const writeErrors = await writeIconToDirs(
icon,
missingDirs,
fileName,
);
warnings.push(...writeErrors);
ok = writeErrors.length === 0;
} 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) {
return { status: "done", warnings } as const;
}
return {
status: "failed",
message: warnings[0] || "could not obtain icon",
} as const;
};
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
const classname = item.item_name;
const result = await processItem(classname);
if (result.status === "skipped") {
skipped++;
onEvent?.({
type: "progress",
classname,
status: "skipped",
index: i,
total,
});
return;
}
if (result.status === "done") {
succeeded++;
onEvent?.({
type: "progress",
classname,
status: "done",
index: i,
total,
warnings: result.warnings.length ? result.warnings : undefined,
});
return;
}
failed++;
onEvent?.({
type: "progress",
classname,
status: "failed",
index: i,
total,
message: result.message,
});
});
onEvent?.({
type: "batch_complete",
succeeded,
failed,
skipped,
});
return { succeeded, failed, skipped };
}