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

267 lines
10 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` }));
}
/**
* Begriffe, die beim Zerlegen eines Dienstnamens in Wörter komplett
* ignoriert werden - reine Infrastruktur-/Container-Präfixe, nie ein
* sinnvoller Icon-Treffer ("LXC Adguard" soll nach "Adguard" suchen, nicht
* nach "LXC").
*/
const STOPWORDS = new Set([
"lxc", "vm", "docker", "container", "srv", "ct", "instance",
"the", "der", "die", "das",
]);
/**
* Generische Kategorie-Wörter - beschreiben WAS etwas ist, nicht WELCHES
* Produkt es ist ("Cam Reolink Keller" soll primär nach "Reolink" suchen,
* nicht nach "Cam"). Werden nicht komplett verworfen (manchmal ist die
* Kategorie das einzige brauchbare Wort), aber erst als letzter Ausweg
* versucht, nachdem alle spezifischeren Wörter erfolglos blieben.
*/
const GENERIC_WORDS = new Set([
"cam", "camera", "media", "print", "printer", "bridge", "hub", "sync",
"box", "sensor", "plug", "light", "dock", "home", "server", "service",
"app", "web", "tv", "cast", "stream", "monitor", "gateway", "node",
"backup", "storage", "network", "device", "system",
]);
function normalize(s: string): string {
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
/** Wörter in ursprünglicher Reihenfolge (für Bigramme wie "philips-hue" wichtig). */
function significantWords(name: string): string[] {
return name
.split(/[\s_-]+/)
.map((w) => normalize(w))
.filter((w) => w.length >= 3 && !STOPWORDS.has(w));
}
/** Aufeinanderfolgende Wortpaare in Original-Reihenfolge, z. B. "philips hue bridge" -> ["philips-hue", "hue-bridge"]. */
function bigrams(words: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < words.length - 1; i++) out.push(`${words[i]}-${words[i + 1]}`);
return out;
}
/** Spezifische Wörter zuerst (längste zuerst - meist Markennamen), generische zuletzt. */
function byPriority(words: string[]): string[] {
const specific = words.filter((w) => !GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length);
const generic = words.filter((w) => GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length);
return [...specific, ...generic];
}
function exactMatch(candidate: string, names: string[]): string | null {
if (names.includes(candidate)) return candidate;
const collapsed = candidate.replace(/-/g, "");
return names.find((n) => n.replace(/-/g, "") === collapsed) ?? null;
}
/**
* Sucht den besten Treffer in der Icon-Datenbank für einen Dienst-/
* Software-Namen ("Plex", "LXC Adguard", "Philips Hue Bridge") - für den
* Fall, dass der Scanner selbst kein Favicon gefunden hat (siehe
* networkScanner.ts). Läuft unbeaufsichtigt während eines Scans, liefert
* deshalb nur EINEN Treffer und ausschließlich über exakte (bzw. nach
* Entfernen von Bindestrichen exakte) Übereinstimmungen - keine
* Teilstring-Suche, die wäre für eine automatische, unbeaufsichtigte
* Zuordnung zu unzuverlässig.
*
* Reihenfolge: ganzer Name -> Wortpaare in Originalreihenfolge (erkennt
* "Philips Hue" als Einheit) -> einzelne Wörter, spezifische (längere,
* nicht-generische) zuerst.
*/
export async function findBestIconMatch(name: string): Promise<string | null> {
const names = await getIconNames();
const wholeName = normalize(name);
if (wholeName.length >= 2) {
const match = exactMatch(wholeName, names);
if (match) return `${CDN_BASE}/${match}.png`;
}
const words = significantWords(name);
for (const pair of bigrams(words)) {
const match = exactMatch(pair, names);
if (match) return `${CDN_BASE}/${match}.png`;
}
for (const word of byPriority(words)) {
const match = exactMatch(word, names);
if (match) return `${CDN_BASE}/${match}.png`;
}
// Letzter, bewusst noch immer präziser Versuch: ein Name wie "adguard"
// trifft "adguard-home" nicht exakt (auch nicht nach Bindestrich-Vergleich),
// ist aber ein eindeutiger Wortanfang-Treffer, wenn KEIN anderer Icon-Name
// mit "<wort>-" beginnt. Nur dann automatisch übernehmen - bei mehreren
// möglichen Treffern bleibt es bei "kein Favicon" (dafür gibt es die
// interaktive Auswahl mit mehreren Kandidaten).
for (const candidate of [wholeName, ...byPriority(words)]) {
if (candidate.length < 3) continue;
const prefixMatches = names.filter((n) => n === candidate || n.startsWith(`${candidate}-`));
if (prefixMatches.length === 1) return `${CDN_BASE}/${prefixMatches[0]}.png`;
if (prefixMatches.length > 1) {
// Mehrere Treffer (z. B. "adguard-home" UND "adguard-home-sync" für
// "adguard") - den kürzesten (meist der "Haupt"-Name der Software)
// automatisch nehmen, aber NUR wenn er eindeutig der kürzeste ist,
// sonst bleibt es zweideutig (dafür gibt es die interaktive Auswahl).
const sorted = [...prefixMatches].sort((a, b) => a.length - b.length);
if (sorted[0].length < sorted[1].length) return `${CDN_BASE}/${sorted[0]}.png`;
}
}
return null;
}
/**
* Wie findBestIconMatch, liefert aber ALLE plausiblen Kandidaten statt nur
* den ersten - für die interaktive "Fehlende Favicons ergänzen"-Übersicht,
* wo der Mensch selbst auswählt, welcher Treffer stimmt.
*
* Reihenfolge: erst alle exakten Treffer (ganzer Name, Wortpaare, einzelne
* Wörter nach Priorität), DANACH erst - falls noch Platz bis zum Limit ist -
* Teilstring-Treffer, wieder erst über spezifische Wörter. Generische
* Kategorie-Wörter (siehe GENERIC_WORDS) werden für die Teilstring-Suche nur
* herangezogen, wenn die spezifischen Wörter GAR NICHTS geliefert haben -
* genau das war das gemeldete Problem ("Cam Blink Sync" fand nur
* Kamera-Zubehör über "Cam" statt "Blink").
*/
export async function suggestIconsForServiceName(name: string, limit = 8): Promise<IconSearchResult[]> {
const names = await getIconNames();
const seen = new Set<string>();
const results: IconSearchResult[] = [];
function addIfNew(iconName: string) {
if (seen.has(iconName) || results.length >= limit) return;
seen.add(iconName);
results.push({ name: iconName, url: `${CDN_BASE}/${iconName}.png` });
}
const words = significantWords(name);
const specificWords = byPriority(words).filter((w) => !GENERIC_WORDS.has(w));
const genericWords = byPriority(words).filter((w) => GENERIC_WORDS.has(w));
const wholeName = normalize(name);
if (wholeName.length >= 2) {
const exact = exactMatch(wholeName, names);
if (exact) addIfNew(exact);
}
for (const pair of bigrams(words)) {
const exact = exactMatch(pair, names);
if (exact) addIfNew(exact);
}
for (const word of specificWords) {
const exact = exactMatch(word, names);
if (exact) addIfNew(exact);
}
// Teilstring-Suche nur über spezifische Wörter, solange noch Platz ist.
for (const word of specificWords) {
if (results.length >= limit) break;
for (const n of names) {
if (results.length >= limit) break;
if (n.includes(word)) addIfNew(n);
}
}
// Generische Wörter (z. B. "cam", "media") nur als allerletzter Ausweg,
// wenn spezifische Wörter überhaupt nichts geliefert haben.
if (results.length === 0) {
for (const word of genericWords) {
const exact = exactMatch(word, names);
if (exact) addIfNew(exact);
}
for (const word of genericWords) {
if (results.length >= limit) break;
for (const n of names) {
if (results.length >= limit) break;
if (n.includes(word)) addIfNew(n);
}
}
}
return results;
}