The library carried titles, systems and genres but almost nothing else:
developer was 3.8% filled, publisher 2.9%, description 0%. These are columns
the app has always had and never been able to populate.
enrich_metadata.py reads them off the same articles the cover fetcher
locates. Only empty fields are touched unless --overwrite is given.
year 79% -> 96%
developer 3.8% -> 95%
publisher 2.9% -> 96%
description 0% -> 96%
Parsing infoboxes needed several guards, each found by checking output
rather than trusting the first pass:
* "Infobox video game" is a substring of "Infobox video game series", so
the loose test resolved Banjo-Kazooie to the series overview. Now
rejected, which also fixes the cover fetcher's article resolution.
* An article spans every release and its date block leads with the
original, so year is only filled when the article covers that platform.
Otherwise a DS port inherits the SNES original's year.
* A search hit that neither covers the platform nor closely matches the
title is discarded: "Dragon Ball Z Budokai" surfaces "Shin Budokai", a
different game on a different console. Left blank instead.
* Values are grouped under bold platform headings, tagged with region
codes, annotated with the platform in parentheses, and wrapped in
templates whose named parameters leak through. Each of those read as
the developer or publisher before being handled.
Developer, publisher and year are facts and written verbatim. Descriptions
are article summaries under CC BY-SA, stored with an attribution line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools/cover-art/fetch_art.py matches each game against libretro-thumbnails
by title + system and attaches the result through the app's own
POST /api/images, so fetched art goes through the same validation and WebP
re-encoding as a manual upload. Standard library only.
Matching bridges a personal catalogue and a ROM-naming one:
* accents stripped, so "Pokemon Yellow" reaches "Pokémon"
* roman numerals folded to digits, so the SNES "Final Fantasy 2" lands on
"Final Fantasy II" and the PS1 "Final Fantasy V" on its own entry
* trailing articles unwound ("Sims 2, The" -> "The Sims 2")
* subtitle containment in both directions, since our rows sometimes omit
what the catalogue carries ("Wave Race 64" vs "... - Kawasaki Jet Ski")
and sometimes carry what it omits ("Donkey Kong Country 2: Diddy's Kong
Quest" vs the GBA set's "Donkey Kong Country 2")
* a sequel guard, so containment cannot collapse "Donkey Kong Country 2"
onto "Donkey Kong Country"
* fuzzy enough to absorb typos: "Brett Hull Hocky 95" finds "Hockey 95"
93 of 105 games now have art. The remainder: 10 Xbox 360 titles, which
libretro has no thumbnail set for, and two rows whose platform looks wrong
in the source data (a Game Boy "Donkey Kong Country 2", which was never
released on that system, and a DS "Donkey Kong Country Returns", which was
Wii and later 3DS).
Real art also invalidated a layout assumption: the grid used object-fit:
cover, which was fine for uniform placeholders but crops actual boxes, whose
aspect ratios run from near-square SNES to tall N64. Switched the grid and
the editor preview to object-fit: contain so the whole cover is visible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2018 stack (Angular 5.2 / CLI 1.7, PHP, MySQL) had not been touched since
July 2018. Rebuilt rather than upgraded in place: the frontend was 17 major
versions behind, and of ~16,700 lines of PHP only ~150 were application logic —
the rest was four near-identical vendored copies of php-crud-api plus
class.upload.php.
Backend — ASP.NET Core 10, EF Core, SQLite
* ASP.NET Core Identity (PBKDF2) + JWT bearer auth
* Clean REST API replacing php-crud-api's filter[]/transform query syntax
* Box art uploads re-encoded to WebP via SkiaSharp
* Imports the 105 games recovered from the 2018 dump on first run
Frontend — Angular 22, zoneless, signals, Material 22
* Standalone components, lazy routes, functional guards and interceptor
* Vitest replaces Karma/Jasmine; fonts and icons bundled, no CDN calls
* No provideAnimations: @angular/animations is deprecated in v22 and
Material no longer depends on it (pinned by a test)
Docker
* Multi-stage builds for both services, non-root at runtime
* nginx serves the SPA and reverse-proxies the API, so everything is
same-origin; one volume holds the database, uploads and DP keys
Security issues in the old code, not carried across:
* Two endpoints exposed unauthenticated CRUD over every table
* The client chose whose rows to read (filter[]=userId,eq,N); ownership now
comes from the JWT subject server-side
* Login was hardcoded to a single username
* crypt() with one global salt, silently truncating passwords to 8 chars
* JWT secret was the literal string "testing", tokens never expired
* Token travelled in the query string rather than a header
* Uploads were anonymous with the path built from the client filename
* Access-Control-Allow-Origin: *
The live MySQL password committed in 2018 remains in git history and must be
rotated independently of this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>