Your ROMs, remotely scanned. Every title, enriched.
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.
Why this exists.
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.
The box it had to fit in.
xbox/roms/, andere flach — rekursiver Scan mit subdir-Awareness.How it runs.
Four deliberate choices.
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 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 ist 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 ENV-Key eine alte Datei-Secret nicht auferstehen lässt.Things that were not obvious.
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.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-translations, die IGDB schlicht nicht listet.
Per-Game-CacheEntry statt URL-keyed Map
Game A's Metadaten mit Game B's Ladeprogress, 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. Nullpunkt: 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 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 das React-Subtree re-mounted.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 rebinded 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 crashed der Prisma-Client beim 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 hätte vorher gefunden.Dual-Auth für Browser + API-Clients
ADMIN_TOKEN nicht gesetzt = offene Dev-Instanz; gesetzt = Bearer-Header oder admin_token-Cookie akzeptiert. Browser-Navigation legt Cookie, API-Clients senden Header — beide funktionieren gegen dieselben Routes.Kein erzwungener Login-Flow für Single-User-Homelabs, aber ein Upgrade-Pfad sobald die Instanz public erreichbar wird. Ein Env-Var macht aus einem offenen Tool eine authentifizierte App — ohne Code-Änderung, ohne UI-Migration.
What's running.
What I learned.
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. Realität: für ein Tool, das Hobbyisten in ihr Heimnetz stellen, ist jede Config-Datei, jeder 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. Keine 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.