Deine ROMs, remote gescannt. Jeder Titel angereichert.
Zentrale Web-Bibliothek für Retro-Gaming-Sammlungen: scannt Steam Deck, Retroid Pocket, NAS remote per SSH/FTP/local, normalisiert ROM-Dateinamen, reichert gegen IGDB + SteamGridDB + OpenRouter-AI an. Next.js 14, SQLite, Docker. Enrichment streamt per SSE live in den Browser, Scan läuft als Background-Job mit 2s-Polling.
Warum es das gibt.
Retro-Gaming-Enthusiasten verteilen ihre ROM-Sammlungen über mehrere Geräte — Steam Deck, Retroid Pocket, NAS, lokale Platten — und verlieren schnell den Überblick. Dateinamen wie Super Mario 64 (USA).z64 sagen nichts über Release-Jahr, Genre, Entwickler oder Cover-Art.
Bestehende Lösungen sind entweder reine Frontends für einzelne Geräte (EmulationStation, ES-DE) oder Desktop-Tools ohne Remote-Scanning und ohne kuratierte Metadaten-Pipeline. Ludotek schließt diese Lücke: ein zentraler Web-Hub, der Geräte remote scannt, ROMs automatisch anreichert und die Sammlung als durchsuchbare, visuell kuratierte Bibliothek präsentiert — auch wenn das Zielgerät gerade offline ist.
Der Rahmen, in den es passen musste.
xbox/roms/, andere flach — rekursiver Scan mit subdir-Awareness.Wie es läuft.
◦ Schematische Darstellung, Zahlen illustrativ.
Vier bewusste Entscheidungen.
SSE-Streaming für Enrichment, Background-Polling für Scan.
progress/enriched/missed/error/done. Scan: fire-and-forget POST + 2s-Polling auf /api/scan/status.SQLite + Prisma 6 statt Postgres.
data/ · Prisma 6.x (nicht 7)docker compose up -d und es läuft. SQLite skaliert locker für 10K Games pro Instanz, und das Backup ist File-Copy. Prisma 7 schied wegen inkompatibler Config-Breaking-Changes aus — 6.x bleibt die stabile Wahl.Drei Protokolle, per Device-Type vorausgewählt.
ssh · ftp · local — Defaults pro Device-Type (Steam Deck → SSH, Android → FTP, Local PC → Filesystem)Field-Level-Encryption für Secrets on disk.
getDecryptedDevice()-Wrapper · Auto-Migration von Plaintextdata/ludotek.db sind ein No-Go für ein self-hosted Tool, das Nutzer unreflektiert in Homelab-Netze stellen. OS-Keyring scheidet aus — läuft nicht im Docker-Container. AES-GCM mit Key aus ENV > data/.encryption-key > auto-gen gibt sichere Defaults UND Override-Pfad für Power-User — Reihenfolge ist wichtig, damit der ENV-Key ein altes Datei-Secret nicht auferstehen lässt.Was nicht offensichtlich war.
Dreistufiger IGDB-Matcher
searchIgdb() versucht drei Suchen nacheinander: plattformspezifisch → cross-platform → Hyphen-Split-Fallback. Letzteres ist der Grund, warum „Project Justice - Rival Schools 2“ überhaupt gefunden wird, wenn IGDB den Titel nur als „Project Justice“ kennt — der Split nimmt den ersten Teil vor dem Bindestrich als Fallback-Query.Ein naiver Single-Query-Matcher hatte ~18 % Miss-Rate auf einer 2.147-Game-Sammlung. Mit dreistufigem Fallback: unter 3 %. Die verbleibenden Misses sind Homebrew + Fan-Übersetzungen, die IGDB schlicht nicht listet.
Per-Game-CacheEntry statt URL-keyed Map
Game A mit dem Ladefortschritt von Game B, weil beide auf dieselbe Cover-URL zeigten.Lösung: Per-Game-CacheEntry statt URL-keyed Map. Jedes Game hält seinen eigenen Cache-State, auch wenn das Asset dedupliziert wird. Grundsatz: wenn zwei Einträge denselben Hash teilen, ist das eine Asset-Optimierung, kein State-Sharing. (Commit
2839055)ROM-Filename-Cleaning ohne Overcorrection
(USA), Revisionen (Rev A), Dump-Flags [!], Multi-Disc-Suffixes müssen entfernt werden — aber nicht zu aggressiv.Ein Regex-Bug entfernte alle nachgestellten Zahlen und machte aus „FIFA 98“ stillschweigend „FIFA“. IGDB matchte dann ein völlig anderes Spiel. Lösung: Cleaning nur auf geklammerte Suffix-Patterns, niemals auf Standalone-Year/Version-Tokens. Plus: Multi-Disc-Dedup greift nur, wenn der Base-Title identisch ist — „Disc 2“ allein ist kein Merge-Signal.
Theme-Switch ohne Chart-Flash
stroke/fill einmalig bei Mount. Hardcoded Hex-Werte überleben keinen Theme-Toggle — die Charts blieben beim Wechsel auf Dark-Mode noch zehn Sekunden in Light-Colors, bis der React-Subtree neu gemountet war.Lösung: CSS-Variablen als RGB-Kanäle (
--vault-amber: 217 119 6) statt als fertige rgb()-Strings, dann rgb(var(--vault-border)) in den Chart-Props. Theme-Switch rebindet die Variablen, CSS rebindet die Charts. Bonus: Tailwind-Opacity-Modifier (/50) funktionieren automatisch mit.Prisma-Binary-Targets in Docker Multi-Arch
node:20-slim brauchte explizite binaryTargets in schema.prisma — sonst crasht der Prisma-Client bei der ersten Query auf ARM-Geräten mit „Query engine binary not found“.Der Build lief lokal (amd64) grün durch, CI pushte das Image, und User auf Raspberry Pi / Apple Silicon crashten sofort. Fix: alle realistischen Runtime-Targets in einem Generator:
["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x"]. (Commit cc58f7b.) Einer dieser Fails, die man nur mit CI-Matrix-Builds gegen echte ARM-Targets vorher gefunden hätte.Dual-Auth für Browser + API-Clients
ADMIN_TOKEN nicht gesetzt = offene Dev-Instanz; gesetzt = Bearer-Header oder admin_token-Cookie akzeptiert. Browser-Navigation setzt das Cookie, API-Clients senden den Header — beide funktionieren gegen dieselben Routes.Kein erzwungener Login-Flow für Single-User-Homelabs, aber ein Upgrade-Pfad, sobald die Instanz öffentlich erreichbar wird. Eine Env-Var macht aus einem offenen Tool eine authentifizierte App — ohne Code-Änderung, ohne UI-Migration.
Was läuft.
Was ich gelernt habe.
Self-hosted ist ein UX-Constraint, kein Deployment-Detail.
Ich bin gestartet mit „läuft in Docker“, das fühlte sich wie die ganze Arbeit an. Realität: für ein Tool, das Hobbyisten in ihr Heimnetz stellen, ist jede Config-Datei, jede ENV-Var ohne Default, jede Fehlermeldung ohne Recovery-Pfad ein UX-Fail. Der Setup-Wizard und die Auto-Migration von Plaintext-Secrets sind dadurch entstanden — nicht weil sie cool waren, sondern weil die Alternative „lies die README und editier YAML“ nicht akzeptabel war. Self-hosted heißt: der erste 60-Sekunden-Eindruck muss genauso poliert sein wie bei Supabase oder Vercel, nur ohne deren DevRel-Team im Rücken.
SSE ist unterbewertet.
WebSockets werden immer empfohlen, aber für unidirektionales Server-Push ist SSE leichter, proxy-freundlicher und mit einem Event-Handler abgefrühstückt. Kein Protokoll-Upgrade-Dance, keine Heartbeats, kein Reconnect-Code. Für Long-Running-Ops mit Progress-Events ist SSE der richtige Default — WebSockets wären hier Over-Engineering gewesen und hätten das Routing durch Reverse-Proxies im Homelab zum Thema gemacht.