One GET /api/stats call, aggregated in a single pass over the library.
Completion funnel, breakdowns by system, genre, decade, condition and
rating, a backlog that links into the filtered library, and a value card.
Form was picked before colour, and most of the page is not a chart: single
numbers are stat tiles, the breakdowns are bar lists with the value printed
per row, which is also the table view.
The colour work, in order:
* one hue for the breakdown bars — identity is on the axis labels, so
colour has nothing to encode, and a darker-where-bigger ramp would just
double-encode bar length
* an ordinal ramp for the funnel, since owned/played/finished are ordered
stages rather than peers
* validated with the dataviz validator against this app's real card
surfaces rather than a reference one, which caught that the documented
ordinal light-end measures 1.91:1 here and fails the 2:1 floor; the ramp
starts a step darker
* status colour used once, on the stale-valuation notice, with an icon and
text so it never carries meaning alone
Two bugs found by rendering it and looking, which the validator cannot see:
* the ratings card was showing condition data under a ratings heading — a
chart whose title did not describe its contents. Fixed by adding a real
rating distribution rather than relabelling the card.
* dark mode rendered light cards on a dark page. Copying the reference
pattern's `color-scheme` onto the container overrode how every
descendant resolved light-dark(), and the `:root`-prefixed media
override never matched at all, because Angular's emulated encapsulation
scopes selectors in component styles. Both replaced by light-dark()
values that inherit the app's own scheme.
The value card reports coverage, age and source beside the total, and flags
valuations older than 90 days, because a bare figure mixes fresh with stale
and silently omits everything unpriced.
148 backend tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
424 lines
19 KiB
Markdown
424 lines
19 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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_KEY` is 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.
|
|
|
|
```bash
|
|
# 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
|
|
```
|
|
|
|
```bash
|
|
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:
|
|
|
|
1. **[libretro-thumbnails](https://thumbnails.libretro.com)** — 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 360` set exists
|
|
but holds about a dozen entries.
|
|
2. **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.
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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 game`
|
|
also matches `Infobox 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|csv`
|
|
- `POST /api/library/import?mode=Merge|Replace&dryRun=true` (multipart `file`)
|
|
|
|
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
|
|
|
|
Three routes, deliberately independent, because each is blocked differently.
|
|
|
|
| Route | Blocked by | Cost | Basis |
|
|
| --- | --- | --- | --- |
|
|
| **CSV price guide** | nothing | free | whatever you supply |
|
|
| **PriceCharting** | nothing — immediate on subscribing | paid | sale-derived, per condition |
|
|
| **eBay Browse** | production keyset needs account verification | free | asking prices, reads high |
|
|
| Manual entry | nothing | free | your own judgement |
|
|
|
|
```
|
|
GET /api/prices/status which sources are usable
|
|
POST /api/prices/refresh {provider, dryRun} price from a live source
|
|
POST /api/prices/import (multipart CSV) apply a price list
|
|
```
|
|
|
|
**The CSV route needs no account and works today.** Column names are matched by
|
|
alias, so `product-name` / `console-name` / `loose-price` from a PriceCharting
|
|
export and a hand-kept `title,system,loose,cib,new` sheet are both accepted, as
|
|
are `$`, thousands separators and blank cells. Rows are matched on title +
|
|
system, so the same game on two consoles is priced separately; rows for games
|
|
you do not own are reported rather than added.
|
|
|
|
Free sources that do **not** work for this: eBay's completed-sales data sits
|
|
behind the Marketplace Insights API, a limited release closed to new
|
|
developers; NEXARDA and CheapShark price current retail and digital
|
|
storefronts, not collectibles. Scraping PriceCharting violates their terms.
|
|
|
|
Deriving a price from eBay listings takes more than an average:
|
|
|
|
- **Listings are classified into loose / CIB / new** from their titles, since a
|
|
mixed feed has no single price. Accessories are discarded — a "box only"
|
|
listing at $45 counted as a copy would halve the loose estimate for a $130
|
|
cartridge — as are reproductions and multi-game lots.
|
|
- **The discard qualifier is required.** 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 exactly the listings the filter existed to remove. Caught
|
|
by a test asserting on tiers rather than counts.
|
|
- **Median with an interquartile trim.** One optimist asking 50x moves a mean
|
|
and not a median.
|
|
- **Sample counts travel with the estimate**, because a tier drawn from two
|
|
listings deserves less confidence than one drawn from thirty.
|
|
|
|
Three prices are stored per game, and `marketValue` is whichever tier matches
|
|
that copy's condition — so changing a condition re-prices it with no further
|
|
lookup. Every value carries the source that wrote it and the moment it was
|
|
captured.
|
|
|
|
**Using PriceCharting.** Subscribe, take the token from the Subscriptions page
|
|
("API/Download"), and set `PRICECHARTING_TOKEN` in `.env`. Their API access
|
|
comes with a paid subscription; the bulk CSV download is limited to their top
|
|
tier, so check which tier you need before subscribing — this integration does
|
|
per-game lookups and only needs the API.
|
|
|
|
Run a dry run first:
|
|
|
|
```bash
|
|
curl -X POST localhost:8080/api/prices/refresh \
|
|
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
-d '{"provider":"pricecharting","dryRun":true,"limit":10}'
|
|
```
|
|
|
|
The response reports **which product each game matched** — name, console and id
|
|
— alongside the prices. That matters more than it sounds: a lookup for the DS
|
|
"Chrono Trigger" that quietly resolves to the SNES original returns entirely
|
|
plausible numbers for the wrong game. Check the matches, then run without
|
|
`dryRun`.
|
|
|
|
Once a game is priced, the matched product id is stored and later refreshes look
|
|
it up directly, so they are cheaper and cannot drift to a different edition.
|
|
|
|
Their published API docs are not reachable without an account, so the response
|
|
parser follows the widely-used convention — integer pennies under hyphenated
|
|
keys — and is tolerant enough that a naming mismatch reads as "no price" rather
|
|
than throwing. `PriceChartingProvider.Parse` is the one place to adjust.
|
|
|
|
### Dashboard
|
|
|
|
`/dashboard` — one `GET /api/stats` call, aggregated server-side in a single pass
|
|
over the library rather than a dozen grouped queries that could disagree.
|
|
|
|
Form was chosen before colour, and most of the page is deliberately not a chart:
|
|
headline numbers are stat tiles, the backlog is a link into a filtered library
|
|
view, and the breakdowns are horizontal bar lists with the value printed on each
|
|
row — which doubles as the table view.
|
|
|
|
Colour decisions worth keeping:
|
|
|
|
- **One hue for the breakdown bars.** Identity is carried by the axis labels, so
|
|
colour has nothing to encode; twelve systems in twelve hues would be twelve
|
|
ways to be wrong, and a darker-where-bigger ramp would double-encode length.
|
|
- **An ordinal ramp for the funnel**, because owned → played → finished are
|
|
ordered stages, not peer categories.
|
|
- **The palette was validated with the dataviz validator against this app's own
|
|
card surfaces** (`#f8f2f6` light, `#1d1b1e` dark), not against a reference
|
|
surface. That mattered: the documented ordinal light-end measured 1.91:1 here
|
|
and failed the 2:1 floor, so the ramp starts a step darker.
|
|
- **Status colour appears once**, on the stale-valuation notice, always with an
|
|
icon and text so it never carries meaning alone.
|
|
|
|
The value card is deliberately wordy. A bare total silently mixes fresh and old
|
|
valuations and excludes everything unpriced, so coverage, age and source travel
|
|
with the figure, and a valuation older than 90 days is called out.
|
|
|
|
Theming note: the chart variables use `light-dark()` rather than a
|
|
`prefers-color-scheme` block. Angular's emulated encapsulation scopes selectors
|
|
in component styles, so a `:root`-prefixed media override never matches from
|
|
there — and setting `color-scheme` on the container overrides how every
|
|
descendant resolves `light-dark()`, which rendered light cards on a dark page.
|
|
|
|
### Database changes
|
|
|
|
```bash
|
|
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=dev` reports 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.js` in the dependency tree.
|
|
Component state must be signal-based for change detection to see it.
|
|
- **`@angular/animations` is deprecated in v22**; Material 22 no longer depends on
|
|
it. There is no `provideAnimations()` in `app.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
|
|
in `LudosData.Api.csproj`.
|
|
- Image processing uses **SkiaSharp**, not ImageSharp: ImageSharp v4 requires a
|
|
paid licence key at build time.
|