Files
LaunchPad/apps/backend/src/scanner/iconDb.ts

111 lines
3.9 KiB
TypeScript

import { request } from "node:https";
const TREE_URL = "https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json";
const CDN_BASE = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 Tag
let cachedNames: string[] | null = null;
let cachedAt = 0;
let inFlight: Promise<string[]> | null = null;
function fetchJson(url: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const req = request(url, { timeout: 8000 }, (res) => {
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}`));
res.resume();
return;
}
const chunks: Buffer[] = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
} catch (e) {
reject(e);
}
});
});
req.on("timeout", () => req.destroy(new Error("Timeout")));
req.on("error", reject);
req.end();
});
}
/**
* Lädt (und cacht 24h im Speicher) die Liste aller Icon-Namen aus dem
* homarr-labs/dashboard-icons-Projekt (freie Icon-Sammlung speziell für
* Homelab-/Selfhosted-Dashboards, via jsDelivr-CDN ausgeliefert).
*
* Bewusst als einzige Stelle im Projekt, die tatsächlich einen externen
* Dienst kontaktiert (abgesehen vom eigentlichen Aufruf der Dienste selbst)
* - das Icon-Datenbank-Feature wurde ausdrücklich so gewünscht. Schlägt der
* Abruf fehl (kein Internetzugang, Dienst nicht erreichbar), liefert die
* Suche einfach keine Treffer statt einen Fehler zu werfen.
*/
async function getIconNames(): Promise<string[]> {
if (cachedNames && Date.now() - cachedAt < CACHE_TTL_MS) return cachedNames;
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const tree = (await fetchJson(TREE_URL)) as { png?: string[] };
const names = (tree.png ?? []).map((f) => f.replace(/\.png$/, ""));
cachedNames = names;
cachedAt = Date.now();
return names;
} catch {
// Kein Absturz, nur keine Treffer - die restliche Favicon-Auswahl
// (bereits im System verwendete Favicons) funktioniert unabhängig davon weiter.
return cachedNames ?? [];
} finally {
inFlight = null;
}
})();
return inFlight;
}
export interface IconSearchResult {
name: string;
url: string;
}
export async function searchIconDb(query: string, limit = 24): Promise<IconSearchResult[]> {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
const names = await getIconNames();
const matches = names.filter((n) => n.includes(q)).slice(0, limit);
return matches.map((name) => ({ name, url: `${CDN_BASE}/${name}.png` }));
}
/**
* Sucht den besten Treffer in der Icon-Datenbank für einen Dienst-/
* Software-Namen ("Plex", "Pi-hole", "Adguard Home") - für den Fall, dass
* der Scanner selbst kein Favicon gefunden hat (siehe networkScanner.ts).
* Bewusst konservativ: nur ein Treffer, wenn der normalisierte Name exakt
* (oder nach Entfernen von Bindestrichen) übereinstimmt - eine reine
* Teilstring-Suche würde bei kurzen/generischen Namen zu viele falsche
* Zuordnungen liefern (z. B. "nas" -> "nas4free" wäre noch plausibel, aber
* "web" könnte alles Mögliche treffen).
*/
export async function findBestIconMatch(name: string): Promise<string | null> {
const normalized = name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (normalized.length < 2) return null;
const names = await getIconNames();
const nameSet = new Set(names);
if (nameSet.has(normalized)) return `${CDN_BASE}/${normalized}.png`;
const collapsed = normalized.replace(/-/g, "");
const exactCollapsed = names.find((n) => n.replace(/-/g, "") === collapsed);
if (exactCollapsed) return `${CDN_BASE}/${exactCollapsed}.png`;
return null;
}