Your ROMs, remotely scanned. Every title, enriched.
Central web library for retro-gaming collections: scans Steam Deck, Retroid Pocket, and NAS remotely over SSH/FTP/local, normalises ROM filenames, enriches against IGDB + SteamGridDB + OpenRouter AI. Next.js 14, SQLite, Docker. Enrichment streams live to the browser via SSE, scan runs as a background job with 2s polling.
Why this exists.
Retro-gaming enthusiasts spread their ROM collections across multiple devices — Steam Deck, Retroid Pocket, NAS, local drives — and quickly lose the overview. Filenames like Super Mario 64 (USA).z64 say nothing about release year, genre, developer, or cover art.
Existing solutions are either pure frontends for individual devices (EmulationStation, ES-DE) or desktop tools without remote scanning and without a curated metadata pipeline. Ludotek closes that gap: a central web hub that scans devices remotely, enriches ROMs automatically, and presents the collection as a searchable, visually curated library — even when the target device is currently offline.
The box it had to fit in.
xbox/roms/, others keep them flat — recursive scan with subdir awareness.How it runs.
Four deliberate choices.
SSE streaming for enrichment, background polling for scan.
progress/enriched/missed/error/done. Scan: fire-and-forget POST + 2s polling on /api/scan/status.SQLite + Prisma 6 instead of Postgres.
data/ · Prisma 6.x (not 7)docker compose up -d and it runs. SQLite scales comfortably to 10K games per instance, and the backup is a file copy. Prisma 7 was ruled out because of incompatible config breaking changes — 6.x stays the stable choice.Three protocols, preselected per device type.
ssh · ftp · local — defaults per device type (Steam Deck → SSH, Android → FTP, Local PC → filesystem)Field-level encryption for secrets on disk.
getDecryptedDevice() wrapper · auto-migration from plaintextdata/ludotek.db is a no-go for a self-hosted tool that users drop into home-lab networks without a second thought. The OS keyring is out — it doesn't work inside a Docker container. AES-GCM with the key sourced from ENV > data/.encryption-key > auto-gen delivers safe defaults AND an override path for power users — the order matters, so that an ENV key can't resurrect an old on-disk secret.Things that were not obvious.
Three-stage IGDB matcher
searchIgdb() runs three searches in sequence: platform-specific → cross-platform → hyphen-split fallback. The last stage is the reason "Project Justice - Rival Schools 2" gets found at all when IGDB only knows the title as "Project Justice" — the split takes the part before the hyphen as the fallback query.A naive single-query matcher had a ~18% miss rate on a 2,147-game collection. With the three-stage fallback: under 3%. The remaining misses are homebrew and fan translations that IGDB simply doesn't list.
Per-game CacheEntry instead of URL-keyed map
Game A's metadata with Game B's load progress because both pointed at the same cover URL.Fix: per-game CacheEntry instead of a URL-keyed map. Every game holds its own cache state, even when the asset is deduplicated. Ground truth: when two entries share a hash, that's an asset optimisation, not state sharing. (Commit
2839055)ROM filename cleaning without overcorrection
(USA), revisions (Rev A), dump flags [!], multi-disc suffixes all need to go — but not too aggressively.A regex bug stripped every trailing number and silently turned "FIFA 98" into "FIFA". IGDB then matched a completely different game. Fix: clean only on parenthesised suffix patterns, never on standalone year/version tokens. Plus: multi-disc dedup only fires when the base title is identical — "Disc 2" alone is not a merge signal.
Theme switch without chart flash
stroke/fill once on mount. Hardcoded hex values don't survive a theme toggle — the charts stayed on light colours for ten seconds after a dark-mode switch, until the React subtree remounted.Fix: CSS variables as RGB channels (
--vault-amber: 217 119 6) instead of prebuilt rgb() strings, then rgb(var(--vault-border)) in the chart props. Theme switch rebinds the variables, CSS rebinds the charts. Bonus: Tailwind opacity modifiers (/50) work automatically on top.Prisma binary targets in Docker multi-arch
node:20-slim needed explicit binaryTargets in schema.prisma — otherwise the Prisma client crashes on the first query on ARM devices with "Query engine binary not found".The build went green locally (amd64), CI pushed the image, and users on Raspberry Pi / Apple Silicon crashed immediately. Fix: every realistic runtime target in one 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.) One of those failures you would only have caught with CI matrix builds against real ARM targets.Dual auth for browser + API clients
ADMIN_TOKEN unset = open dev instance; set = Bearer header or admin_token cookie accepted. Browser navigation drops a cookie, API clients send the header — both hit the same routes.No forced login flow for single-user home labs, but an upgrade path the moment the instance becomes publicly reachable. One env var turns an open tool into an authenticated app — no code change, no UI migration.
What's running.
What I learned.
Self-hosted is a UX constraint, not a deployment detail.
I started with "runs in Docker", and that felt like the whole job. Reality: for a tool that hobbyists drop into their home network, every config file, every ENV var without a default, every error message without a recovery path is a UX failure. The setup wizard and the auto-migration of plaintext secrets came out of that — not because they were cool, but because the alternative ("read the README and edit YAML") was not acceptable. Self-hosted means: the first 60-second impression has to be as polished as Supabase or Vercel, just without their DevRel team behind you.
SSE is underrated.
WebSockets always get recommended, but for unidirectional server push, SSE is lighter, proxy-friendlier, and done with a single event handler. No protocol-upgrade dance, no heartbeats, no reconnect code. For long-running ops with progress events, SSE is the right default — WebSockets would have been over-engineering here and would have turned routing through reverse proxies in a home lab into its own problem.