Researched the options first. PriceCharting is the standard for retro prices
but requires a paid subscription for both its API and its bulk download.
eBay's sold-price data sits behind the Marketplace Insights API, which is a
limited release closed to new developers. The free game-price APIs cover
current digital storefronts, not physical retro copies. So there is no free
route to sold prices, and this uses eBay Browse — active listings, which are
asking prices, labelled as such rather than presented as valuations.
Schema now holds three prices per game (loose, CIB, new), with marketValue
as whichever tier matches that copy's condition. Changing a condition
re-prices from the stored tiers with no further lookup, and the dashboard
can later show both actual value and what a collection would be worth
complete.
The judgement lives in classification and aggregation, both pure and both
tested without credentials:
* listings are sorted into tiers from their titles, and accessories,
reproductions and multi-game lots are discarded — a "box only" listing
at $45 counted as a copy would halve the loose estimate for a $130 cart
* the discard qualifier is required. The first version matched a bare
"box", which threw out "complete in box" and "with box and manual",
i.e. most of the CIB tier, while keeping exactly the listings the
filter existed to remove. A test asserting on tiers rather than counts
caught it.
* median with an interquartile trim, since one optimist asking 50x moves
a mean and not a median
* sample counts travel with the estimate, because a tier drawn from two
listings warrants less confidence than one drawn from thirty
Credentials are optional: with none set, /api/prices/status reports
configured=false and refresh answers 503 with instructions, while the rest
of the app is unaffected.
101 backend tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
15 KiB
LudosData
A personal video game library: catalogue what you own, what you've dumped, played and finished.
Originally built in 2018 on Angular 5 + PHP + MySQL. Rebuilt in 2026 on Angular 22 and ASP.NET Core 10 with SQLite, running in Docker.
Quick start
cp .env.example .env
# Generate a signing key and put it in .env as JWT_KEY:
openssl rand -base64 48
# Also set SEED_USERNAME / SEED_EMAIL / SEED_PASSWORD for the first account.
docker compose up --build
Then open http://localhost:8080 and sign in with the seed credentials.
On first run the API creates that account and imports the 105 games recovered from the 2018 database dump. Seeding only happens while the database has no users.
Password rules: 12+ characters, with an uppercase, a lowercase and a digit. The API refuses to start if
JWT_KEYis missing or shorter than 32 characters — that is deliberate, so a misconfigured deployment fails loudly instead of signing tokens with a guessable key.
Layout
backend/ ASP.NET Core 10 Web API (C#)
src/LudosData.Api/
Domain/ Game, AppUser
Data/ DbContext, migrations, seeder, games.json
Auth/ JWT options, token service
Controllers/ auth, games, images
Services/ image storage
frontend/ Angular 22 SPA
src/app/
core/ models, services, guard, HTTP interceptor
features/ login, register, game-grid, game-edit, account
shared/ toolbar, confirm dialog
archive/ the original 2018 MySQL dump, for provenance
Everything stateful lives in one Docker volume (ludos-data): the SQLite file,
uploaded box art, and the Data Protection keys. Back that volume up and you have
backed up the whole application.
Development
Host tooling (Node 24, .NET 10) is installed via Homebrew. dotnet-ef needs
~/.dotnet/tools on PATH, which ~/.bashrc.d/dotnet.sh sets up.
# API on http://localhost:5099
cd backend/src/LudosData.Api
Jwt__Key="a-dev-key-of-at-least-32-characters!!" dotnet run
# SPA on http://localhost:4200, proxying /api and /uploads to :5099
cd frontend
npm start
cd frontend && npm test # vitest
cd backend && dotnet build # 0 warnings expected
Cover art
tools/library/fetch_art.py fills in box art, pushing each image through the
app's own POST /api/images so it gets the same validation and WebP re-encoding
as a manual upload. Standard library only — no virtualenv, and neither source
needs an account.
It tries two sources in order:
- libretro-thumbnails — scanned retail
boxes, named to the No-Intro / Redump conventions. Best art where it has any,
but its coverage is the retro consoles. Its
Microsoft - Xbox 360set exists but holds about a dozen entries. - English Wikipedia — a cover on essentially every notable game article,
which is what fills the Xbox 360 shelf. The exact filename is read from the
article's infobox rather than guessed from file names, since filtering names
for "box" also matches
Xbox-360-Pro-wController.png. Calls are throttled to one per second and cached; the API returns 429 if pushed harder.
cd tools/library
python3 fetch_art.py --password '...' --dry-run # report matches, change nothing
python3 fetch_art.py --password '...' # download and attach
python3 fetch_art.py --password '...' --overwrite # also replace existing art
python3 fetch_art.py --password '...' --no-wikipedia # libretro only
Always dry-run first; it prints every match with a similarity score, marks the
source (W for Wikipedia), and flags anything below 0.95 for eyeballing.
Matching handles the gaps between a personal catalogue and a ROM-naming one:
accents (Pokemon → Pokémon), roman numerals (our SNES Final Fantasy 2 is
the catalogue's Final Fantasy II), trailing articles (Sims 2, The), missing
subtitles in either direction, and outright typos — Brett Hull Hocky 95 finds
Brett Hull Hockey 95. A sequel guard stops Donkey Kong Country 2 from
silently taking Donkey Kong Country's box.
All 105 games currently have art: 93 from libretro, 12 from Wikipedia.
Two rows got art of the right game but the wrong platform, because the
platform in the source data looks wrong — a Game Boy "Donkey Kong Country 2"
(never released on that system; the handheld sequels were Donkey Kong Land)
and a DS "Donkey Kong Country Returns" (a Wii game, later Returns 3D on 3DS).
Fix the system field and re-run with --overwrite to correct them.
Art is publisher copyright. Fetching it for a private collection is ordinary practice for library software; redistributing it is a different question.
Metadata enrichment
tools/library/enrich_metadata.py fills developer, publisher, year and
description from the same Wikipedia articles the cover fetcher locates. Only
empty fields are touched unless --overwrite is passed — anything typed by hand
outranks anything derived here.
cd tools/library
python3 enrich_metadata.py --password '...' --dry-run
python3 enrich_metadata.py --password '...'
python3 enrich_metadata.py --password '...' --fields developer,publisher
Coverage went from this to this:
| Field | Before | After |
|---|---|---|
| year | 79% | 96% |
| developer | 3.8% | 95% |
| publisher | 2.9% | 96% |
| description | 0% | 96% |
Developer, publisher and year are facts, written verbatim. Descriptions are article summaries, which are CC BY-SA, so each is stored with an attribution line naming the source article.
Parsing an infobox is messier than it looks, and the guards matter:
- Series articles are rejected. A substring test for
Infobox video gamealso matchesInfobox video game series, which resolved Banjo-Kazooie to the series overview instead of the 1998 game. - Year is only filled when the article covers that platform. An article spans every release, and its date block leads with the original — so a DS port would otherwise be dated to the SNES original.
- A search hit that neither covers the platform nor closely matches the title is discarded. Our "Dragon Ball Z Budokai" surfaces "Dragon Ball Z: Shin Budokai", a different game on a different console. A blank field beats a confidently wrong one.
- Platform headings (
'''PlayStation'''), region codes (JP,NA), trailing platform annotations (Rare (N64)) and named template parameters (title=) are all stripped, since each one otherwise reads as the value itself.
Four games have no usable article: a typo'd title (Brett Hull Hocky 95),
Dragon Ball Z Budokai, and two niche releases.
Export and import
Until this existed, the only backup was the Docker volume.
GET /api/library/export?format=json|csvPOST /api/library/import?mode=Merge|Replace&dryRun=true(multipartfile)
Both are in the UI on the account page. JSON round-trips exactly and is the
right choice for a backup; CSV opens in a spreadsheet and is written with a BOM
so Excel does not mangle Pokémon. Box art images are not bundled — they
live in the upload volume, and a library imported into a fresh instance will
reference art that is not there until the fetcher runs again.
Rows are matched on title + system, so the same game on three consoles stays
three entries. Merge adds and updates but never deletes; Replace wipes the
library first and is confirmed twice in the UI. dryRun reports exactly what
would happen and writes nothing.
Collector fields
Beyond the four original flags, each game carries rating (1-10), notes, condition, region, what you paid and when, and a current market value.
condition is not cosmetic: price feeds quote per condition, and the gap
between loose and sealed is routinely a multiple, so it selects which quoted
price applies to a copy.
Market value is stored with when it was captured and where it came from. A figure with neither is not something you can reason about, and a collection total is only as good as its staleness. Editing an unrelated field leaves the timestamp alone; changing the figure moves it. A future price feed writes the same three columns.
Money is stored as integer minor units. SQLite has no decimal type, and EF
Core's default maps decimal to TEXT, which compares lexically — "9.00" sorts
above "10.00", and SUM does not work at all. A value converter keeps the C#
side as decimal while ordering and totalling behave.
Enums travel as names, not ordinals. "Cib" is self-describing in a payload,
an export and a log line; 2 is not, and renumbering the enum would silently
reinterpret every stored export.
New query parameters: condition, region, minRating, hasValue. New sort
keys: rating, value, price, purchased.
Market value
Prices come from eBay's Browse API, which needs a free developer account:
register at developer.ebay.com, create a production application keyset, and put
the App ID and Cert ID in .env as EBAY_CLIENT_ID / EBAY_CLIENT_SECRET.
Without them the pricing endpoints answer 503 with an explanation and nothing
else is affected.
GET /api/prices/status is a provider configured?
POST /api/prices/refresh {dryRun, limit} price some games
These are asking prices, not sold prices. Browse returns active listings.
eBay's completed-sales data lives behind the Marketplace Insights API, which is
a limited release closed to new developers, and PriceCharting — the usual
alternative — requires a paid subscription. Asking prices skew high: sellers
list optimistically and unsold listings linger. Treat the numbers as an upper
bound. The provider name (ebay-asking) is stored with every value it writes,
so the source is always visible next to the figure.
Deriving a price from listings takes more than an average:
- Listings are classified into loose / CIB / new from the title, because a feed of mixed conditions has no single price. Accessories are discarded outright — a "box only" listing at $45 counted as a copy would halve the loose estimate for a $130 cartridge. So are reproductions and multi-game lots.
- The qualifier is required when discarding. An early version matched a bare "box", which threw away "complete in box" and "with box and manual" — most of the CIB tier — while keeping the cheap box-only listings the filter existed to remove. Caught by a test asserting on the tier, not on the count.
- Median, not mean, with an interquartile trim. One optimist asking 50x drags a mean past the point of usefulness; a median ignores them.
- Sample counts travel with the estimate. A tier from two listings deserves less confidence than one from thirty.
Three prices are stored per game, and marketValue is whichever tier matches
that copy's condition. Changing a game's condition re-prices it from the stored
tiers without another lookup.
Database changes
cd backend/src/LudosData.Api
dotnet ef migrations add <Name> --output-dir Data/Migrations
Migrations are applied automatically at startup.
API
All /api/games and /api/images routes require Authorization: Bearer <token>.
| Method | Route | Notes |
|---|---|---|
POST |
/api/auth/register |
Returns a token; signs the new user straight in |
POST |
/api/auth/login |
Returns { token, expiresAt, user } |
GET |
/api/auth/me |
Current user |
GET |
/api/auth/available?userName= / ?email= |
Returns only a boolean |
GET |
/api/games |
search, system, genre, own, dumped, played, finished, page, pageSize, sort, dir |
GET |
/api/games/{id} |
|
GET |
/api/games/facets |
Distinct systems and genres, for filter dropdowns |
POST |
/api/games |
|
PUT |
/api/games/{id} |
|
DELETE |
/api/games/{id} |
|
POST |
/api/images |
multipart file; re-encodes to WebP |
GET |
/health |
Anonymous |
Ownership is always taken from the JWT subject, never from the request. A game
belonging to another user returns 404, not 403, so the response does not
confirm that the id exists.
Security notes
Rotate the old database password
The 2018 code committed live MySQL credentials to this repository
(interfaceServices/dbConfig.php, and again in four other files). They are in git
history. That password must be considered compromised and rotated, regardless
of this rewrite. Removing the files does not remove them from history.
The new stack keeps secrets in .env, which is gitignored.
What was fixed in the rewrite
The old backend was ~16,700 lines of PHP, of which ~16,400 were vendored
third-party code — four near-identical copies of php-crud-api plus
class.upload.php. Only ~150 lines were application logic. These problems were
not carried across:
| Old behaviour | Now |
|---|---|
| Two endpoints exposed unauthenticated CRUD over every table | Every data route requires a valid token |
Client chose whose rows to read (filter[]=userId,eq,N) |
Owner comes from the JWT subject, server-side |
| Login hardcoded to a single username | Any registered user can sign in |
crypt() with one global salt, silently truncating passwords to 8 chars |
ASP.NET Core Identity (PBKDF2, per-user salt) |
JWT secret was the literal string "testing", tokens never expired |
Key required from config, 12-hour expiry |
| Token passed in the query string | Authorization: Bearer header |
| Uploads anonymous, path built from the client filename | Authenticated, server-generated name, per-user folder, must decode as an image |
Access-Control-Allow-Origin: * |
Explicit origin allowlist |
Passwords could not be migrated — the old hashes are unrecoverable by design.
Known accepted risk
npm audit reports a moderate advisory in @hono/node-server, reached
transitively through @angular/cli's MCP server feature. It is:
- dev-only —
npm audit --omit=devreports 0 vulnerabilities, and it is not in the browser bundle - a Windows-only path traversal, on a Linux-only toolchain here
npm audit fix --force would downgrade Angular CLI to 21.0.4, a breaking change.
Overriding the dependency means forcing a major bump the MCP SDK does not accept
(^1.19.9). Left as-is deliberately; revisit when Angular CLI updates the SDK.
Notable version facts (as of 2026-08)
- Angular 22.1 is zoneless — there is no
zone.jsin the dependency tree. Component state must be signal-based for change detection to see it. @angular/animationsis deprecated in v22; Material 22 no longer depends on it. There is noprovideAnimations()inapp.config.ts, and a test pins that Material still renders without one.- Unit tests run on Vitest, not Karma/Jasmine.
- Fonts and Material icons are bundled from
node_modules, so the app makes no third-party requests at runtime. - The backend pins two transitive packages (
Microsoft.OpenApi,SQLitePCLRaw.lib.e_sqlite3) to clear high-severity advisories. See the comment inLudosData.Api.csproj. - Image processing uses SkiaSharp, not ImageSharp: ImageSharp v4 requires a paid licence key at build time.