round30: alle drei Scanner als Hintergrund-Job, Dienst-Duplikate strukturell verhindert (Migration+Constraint+Geraete-Sperre), zweiter Erkennungsversuch bei leerem Ersttreffer

This commit is contained in:
2026-07-24 15:14:00 +02:00
parent 2106c17301
commit 87fed21348
7 changed files with 308 additions and 140 deletions

View File

@@ -145,6 +145,55 @@ export function ensureSchema(): void {
ensureColumn("services", "last_suggested_display_name", "TEXT");
ensureColumn("services", "last_suggested_category", "TEXT");
ensureColumn("devices", "last_ping", "TEXT");
deduplicateServicesByDeviceAndPort();
// Verhindert künftig auf Datenbankebene, dass zwei Dienste mit demselben
// (Gerät, Port) gleichzeitig existieren - kann sonst passieren, wenn zwei
// Scans desselben Geräts überlappen (z. B. "Alle Geräte scannen" läuft
// noch, während gleichzeitig auch "Jetzt scannen" für genau dieses eine
// Gerät geklickt wird) und beide parallel einen "existiert noch nicht"-Fund
// zu einem CREATE statt UPDATE machen. Erst NACH der Bereinigung oben
// anlegen, sonst würde das Erstellen des Index an vorhandenen Duplikaten
// scheitern.
sqlite.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_services_device_port ON services(device_id, port)`
);
}
/**
* Entfernt doppelte Dienst-Einträge für dasselbe (Gerät, Port) - kann bei
* älteren Datenbanken vorkommen, wenn zwei überlappende Scans vor Einführung
* des UNIQUE-Index (s. o.) beide gleichzeitig einen neuen Dienst angelegt
* haben. Behält den Eintrag mit den meisten ausgefüllten Feldern (Favicon,
* Kategorie, ...), bei Gleichstand den ältesten (kleinste created_at).
*/
function deduplicateServicesByDeviceAndPort(): void {
const rows = sqlite
.prepare(`SELECT * FROM services ORDER BY created_at ASC`)
.all() as Array<Record<string, unknown>>;
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const row of rows) {
const key = `${row.device_id}::${row.port}`;
const list = groups.get(key) ?? [];
list.push(row);
groups.set(key, list);
}
function richness(row: Record<string, unknown>): number {
return ["favicon", "category", "description", "icon"].filter((f) => !!row[f]).length;
}
for (const group of groups.values()) {
if (group.length <= 1) continue;
const keeper = [...group].sort((a, b) => richness(b) - richness(a))[0];
const toDelete = group.filter((r) => r.id !== keeper.id);
for (const row of toDelete) {
sqlite.prepare(`DELETE FROM services WHERE id = ?`).run(row.id as string);
}
}
}
function ensureColumn(table: string, column: string, definition: string): void {