CASE · 072025 — ONGOINGSOLOSELF-HOSTED · SSE · ENRICHMENT
◦ LUDOTEK · self-hosted · docker

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.

§ 01Problem · motivation

Why this exists.

Retro-ROM-Sammlungen wachsen verteilt über Geräte. Keine vorhandene Lösung deckt Remote-Scan + Metadaten + UI ab.

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.

§ 02Constraints · operating box

The box it had to fit in.

Hobbyist-First: kein DevOps-Profil, kein Cloud-Abo, keine Config-Files.
C/01 · HOSTING
Self-hosted first. Ein-Kommando-Docker-Deploy mit persistenten Volumes, keine Cloud-Abhängigkeit.
C/02 · DATABASE
SQLite + Prisma 6.x, damit Hobbyisten ohne DB-Setup starten — kein Prisma 7 wegen inkompatibler Config.
C/03 · RATE LIMITS
IGDB & SteamGridDB erzwingen sequenzielles Processing mit 500ms-Delay und Exponential-Backoff bei 429/5xx.
C/04 · REMOTE PROTO
Heterogen: User wählt SSH, FTP oder local — Defaults pro Device-Type, weil Steam Deck SSH spricht und viele Android-Handhelds nur FTP.
C/05 · PLATFORM COV
66 Plattform-Konventionen. ES-DE legt ROMs in xbox/roms/, andere flach — rekursiver Scan mit subdir-Awareness.
C/06 · SECRETS
Device-Passwörter + API-Keys AES-256-GCM-verschlüsselt on disk, Key aus ENV oder auto-generiert.
C/07 · LONG OPS
Enrichment für 2.000+ Games darf im Browser nicht timeouten — SSE-Streaming statt REST-Polling.
C/08 · UX FLOOR
Kein Config-File-Editieren. Setup Wizard in 5 Schritten. Zielgruppe ist kein DevOps-Profil.
§ 03Architecture · dual long-running pipelines

How it runs.

Zwei Long-Running-Pipelines: Enrichment streamt Progress-Events per SSE live in den Browser, Scan läuft als Background-Job mit 2s-Polling auf /api/scan/status. Zwei React-Contexts kanalisieren beide Streams in UI-State.
pipeline.live·devices 3·roms 2,147·enriched 1,892·missed 56
sse connected·igdb ms
INPUT · 01
Remote hosts
steam-deck · retroid · nas
online3 / 3
PROTO · 02
SSH · FTP · local
test-connection · 10s timeout
reconnects12
SCANNER · 03
Recursive walk
66 platform conventions
STORE · 04
SQLite · Prisma 6
Game · Device · Cover
rows2,147
CLEAN · 05
Filename normalize
strip (USA) · (Rev A) · [!]
guardFIFA 98 ✓
MATCHER · 06
searchIgdb() · 3-stage
platform → cross → split
PLATCROSSSPLIT
FETCH · 07
IGDB · SGDB · AI
500ms delay · backoff
igdbsgdbopenrouter
CACHE · 08
/api/cache mirror
data/ · offline-ok
fill88%
SECRETS · 09
AES-256-GCM
env · file · auto-gen key
vaulton disk
WIZARD · 10
Setup · 5 steps
no config-file · proto preset
first-runguided
SSE · 11
/api/enrich stream
progress · enriched · done
scan48%
LIBRARY · 12
Next.js 14 UI
search · filter · discover
visible1,892
EVENT LOG · /api/scan/status (poll) · /api/enrich (sse)
12:04:18scanprogress 1,247/2,147 · nintendo-switch
12:04:17enrichmatched "Project Justice" → igdb:2583
12:04:16enrichcover cached: data/covers/co1a2b.jpg
12:04:14scanfound 14 roms · xbox/roms/
12:04:11enrichai: fun-fact generated · Skies of Arcadia
12:04:08scanpoll /api/scan/status · psp
12:04:05cachemirror fill 88% · 1,892 covers
12:04:02enrichmatched "Wind Waker" → igdb:1033
12:03:58secretsaes-256-gcm · device:steam-deck unlocked
12:04:18scanprogress 1,247/2,147 · nintendo-switch
12:04:17enrichmatched "Project Justice" → igdb:2583
12:04:16enrichcover cached: data/covers/co1a2b.jpg
12:04:14scanfound 14 roms · xbox/roms/
12:04:11enrichai: fun-fact generated · Skies of Arcadia
12:04:08scanpoll /api/scan/status · psp
12:04:05cachemirror fill 88% · 1,892 covers
12:04:02enrichmatched "Wind Waker" → igdb:1033
12:03:58secretsaes-256-gcm · device:steam-deck unlocked
§ 04Decisions · trade-offs

Four deliberate choices.

Pro Entscheidung: was gewählt, statt was, warum.
D/01

SSE-Streaming für Enrichment, Background-Polling für Scan.

chosen
Enrichment: Server-Sent Events mit progress/enriched/missed/error/done. Scan: fire-and-forget POST + 2s-Polling auf /api/scan/status.
instead of
Beide Pipelines über dieselbe Mechanik (entweder beides SSE oder beides Polling)
reason
Enrichment für 2.000+ Games dauert 20–30 Minuten und produziert pro Titel ein Event, das sofort sichtbar sein soll — REST-Polling hätte den Browser belogen (Snapshot-Granularität) und jeder 504-Timeout hätte State verloren. SSE pushed echten Fortschritt: jeder gematchte Titel erscheint live. Scan dagegen produziert nur wenige Status-Updates pro Path, läuft komplett serverseitig durch und überlebt einen Browser-Reload — da reicht ein schmaler Status-Endpoint mit 2s-Polling. Verschiedene Lasten, verschiedene Mechaniken.
D/02

SQLite + Prisma 6 statt Postgres.

chosen
SQLite-File in data/ · Prisma 6.x (nicht 7)
instead of
Postgres-Container als Docker-Compose-Dependency
reason
Zielgruppe sind Hobbyisten. Wer einen Postgres-Container setup, der compose'd auch ohne mich. Ziel war: ein 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.
D/03

Drei Protokolle, per Device-Type vorausgewählt.

chosen
ssh · ftp · local — Defaults pro Device-Type (Steam Deck → SSH, Android → FTP, Local PC → Filesystem)
instead of
Ein einziges Protokoll mit Auto-Fallback-Magic darunter
reason
Das Device-Ökosystem ist zu heterogen für einen Default: Steam Deck spricht SSH out-of-the-box, viele Android-Handhelds (Retroid, Odin) öffnen ihren eingebauten FTP-Server, NAS-Mounts laufen rein über das lokale Filesystem. Der Setup-Wizard wählt das passende Protokoll basierend auf dem gewählten Device-Type vor — User können in Custom-Devices alles überschreiben. Eine Test-Connection-Route mit 10s-Timeout fängt Falsch-Konfigurationen vor dem ersten Scan ab, bevor sie als "hängender Scan" auftauchen.
D/04

Field-Level-Encryption für Secrets on disk.

chosen
AES-256-GCM · getDecryptedDevice()-Wrapper · Auto-Migration von Plaintext
instead of
Plaintext in SQLite oder OS-Keyring-Dependency
reason
Geräte-Passwörter in Plaintext in data/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.
§ 05Highlights · interesting bits

Things that were not obvious.

Edge-Cases und Details, die erst beim Bauen klar wurden.

Dreistufiger IGDB-Matcher

H/01
searchIgdb() versucht drei Suchen nacheinander: plattformspezifischcross-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

H/02
Zwei Games mit demselben IGDB-Cover-Hash hatten vorher den Cache-State anderer Games korrumpiert — eine URL-keyed Shared-Map überschrieb 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

H/03
Region-Tags (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

H/04
Recharts rendert 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

H/05
Multi-Arch (linux/amd64 + linux/arm64) auf 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

H/06
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.
§ 06Stack · in production

What's running.

Working toolchain — nichts Theoretisches.
Next.js 14 · App RouterTypeScript 5Prisma 6 · SQLiteTailwind 3.4 · custom UIRecharts (theme-aware)SSE · React Contextssh2 + basic-ftpIGDB + SteamGridDBOpenRouter · Gemini 2.5AES-256-GCMDocker · Multi-ArchVitest · PlaywrightSetup Wizard · 5 steps
§ 07Reflection · takeaways

What I learned.

Projekt läuft als Docker-Image. Diese Dinge nehme ich in die nächsten mit.

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.

◦ NEXT CASE · 08 / 11
AgoraHub
← alle Projekte