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 | null = null; function fetchJson(url: string): Promise { 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 { 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 { 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 { 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; }