Commit Graph
38 Commits
Author SHA1 Message Date
ckochandClaude Opus 5 ca70bcef34 Add market value: tiered prices and an eBay Browse provider
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>
2026-08-04 15:21:32 -04:00
ckochandClaude Opus 5 d5a0e42fed Add collector fields, including market value
Rating, notes, condition, region, purchase price and date, plus a market
value carrying the timestamp and source that make it interpretable.

Condition is load-bearing rather than cosmetic: price feeds quote per
condition, so it selects which quoted price applies to a copy. Market value
records when it was captured and where it came from — a collection total is
only as good as its staleness — and an edit to an unrelated field leaves
that timestamp alone, so a stale price cannot start looking freshly checked.

Two storage decisions worth naming:

  * Money is stored as integer minor units. SQLite has no decimal type and
    EF Core maps decimal to TEXT, which compares lexically: "9.00" sorts
    above "10.00" and SUM is unavailable. A value converter keeps decimals
    in C# while ordering and totalling work. A test pins the ordering.
  * Enums serialise as names. The default is ordinals, which meant the API
    rejected the browser's {"condition":"Cib"} with a 400 while the C# tests
    passed, because they round-tripped ints and never spoke the client's
    dialect. The tests now share the API's serializer options.

Also fixes a data-loss bug in the Python tools. Both built their PUT body
from a hardcoded list of field names, so any column added to the model was
omitted and therefore nulled. Adding collector fields meant the next art or
enrichment run would have erased every rating, note, condition, price and
valuation in the library. Payloads are now built by excluding the handful of
server-owned fields, so new columns carry through by default.

The migration was rehearsed against a copy of the live database before being
applied: 105 rows, descriptions and developers intact.

67 backend tests, 8 frontend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:29:48 -04:00
ckochandClaude Opus 5 b69a5c9d14 Add library export and import
The only backup was the Docker volume. Export writes the caller's whole
library as JSON or CSV; import reads either back, into the same account or
a different one.

Rows are matched on title + system rather than id, so a file is portable
between accounts and instances, and the same game on three consoles stays
three entries. Merge adds and updates but deletes nothing. Replace wipes
first, and is gated behind an explicit confirm dialog in the UI. dryRun
reports what would happen and writes nothing.

CSV is hand-rolled rather than pulling a dependency, but handles the parts
that actually bite: quoted fields containing commas, escaped quotes,
embedded newlines and CRLF endings. That is not hypothetical here — 101 of
the 105 descriptions contain newlines, and two titles contain accents, so a
naive split-on-comma would corrupt most of the library. Exports carry a BOM
so Excel reads them as UTF-8.

Verified against the real library, not just fixtures: 105 games exported to
CSV, imported into a scratch account and re-exported compare identical
field for field.

15 new tests cover round-trip fidelity, merge vs replace, dry run,
per-user isolation on the destructive path, malformed input, and the
awkward-quoting case. 51 backend tests total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:54:54 -04:00
ckochandClaude Opus 5 771b34bb4b Fill developer, publisher, year and description from Wikipedia
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>
2026-08-04 12:34:55 -04:00
ckochandClaude Opus 5 130921cf89 Add backend test suite covering auth, ownership and the query surface
The API had no tests. These run against the real application through
WebApplicationFactory — same pipeline, same Identity configuration, same JWT
validation — with only the SQLite file, upload folder and signing key
swapped, so a passing test says something about what ships.

The ownership tests pin the rule the old PHP API got wrong: a second user
sees an empty library, gets 404 (not 403, which would confirm the id exists)
when reading, updating or deleting someone else's game, and cannot reassign
ownership by putting ownerId or userId in the request body.

Also covered: the password policy, that login is indistinguishable between a
wrong password and an absent user, that the availability endpoint leaks no
row data, that a token signed with an untrusted key is refused, that the
sort parameter is allow-listed rather than interpolated, and that uploads
must decode as an image regardless of extension or content type.

36 tests, ~1s. Program is now declared public partial so the test host can
reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:16:33 -04:00
ckochandClaude Opus 5 cd5c8fb24e Add Wikipedia fallback so every game has cover art
libretro-thumbnails stops at the retro consoles, leaving the ten Xbox 360
titles blank. English Wikipedia carries a cover on essentially every
notable game article and needs no account, so it now runs as a second pass
for anything libretro cannot match.

Correcting an earlier claim in this repo: libretro does publish a
"Microsoft - Xbox 360" set. I had reported it as absent after checking only
my own hardcoded system map, not the actual catalogue of 123 sets. The set
turns out to hold about a dozen entries, none of them ours, so the
conclusion held but the reason given was wrong. It is now mapped and
searched anyway, in case it fills out later.

The cover filename is read from the article's infobox rather than inferred
from file names: filtering names for "box" also matches
"Xbox-360-Pro-wController.png". Two details that cost a round each:

  * the infobox writes the field both bare ("Halo 3 final boxshot.JPG") and
    prefixed ("File:Lost-Planet-New.jpg"), so any prefix is stripped before
    exactly one is added back
  * the API returns 429 under an unthrottled loop, so calls are spaced one
    second apart, retried with a longer backoff, and cached to disk

Coverage is now 105/105 — 93 from libretro, 12 from Wikipedia.

Two rows took art of the right game but the wrong platform, because their
system field looks wrong in the source data: a Game Boy "Donkey Kong
Country 2" and a DS "Donkey Kong Country Returns". Noted in the README
rather than silently corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:04:55 -04:00
ckochandClaude Opus 5 55182a4da7 Add cover art fetcher; letterbox covers instead of cropping
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>
2026-08-04 11:41:02 -04:00
ckochandClaude Opus 5 a99c8381b1 Disable critical-CSS inlining so the stylesheet survives the CSP
Angular's production build defers the main stylesheet with
`media="print" onload="this.media='all'"` and inlines a critical subset
ahead of it. The nginx CSP sets `script-src 'self'`, which blocks that
inline event handler — so the swap never ran and the stylesheet stayed
print-only. The app rendered from the ~23kB critical subset alone.

Most of the page still looked right, which is what made it easy to miss.
Material icons did not: `.material-icons` was not in the critical subset,
so every icon fell back to the body font and rendered its ligature name
("videogame_asset") clipped to the icon box.

`inlineCritical: false` emits a plain <link rel="stylesheet">. The
stylesheet is 24kB and same-origin, so the optimisation bought little and
cost correctness under a strict CSP.

Verified in headless Chromium: icons render as glyphs across the toolbar,
grid, editor and mobile layouts, and the console is now clean where it
previously logged four CSP violations per page load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:45:46 -04:00
ckochandClaude Opus 5 2a7d90b2d5 Rebuild on Angular 22 + ASP.NET Core 10, containerised
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>
2026-08-03 18:46:33 -04:00
ckoch 10757575c2 dcvdvdvd 2018-07-27 18:25:17 -04:00
ckoch 5cc5446421 build 2018-07-05 22:42:12 -04:00
ckoch b876467e78 images 2018-06-25 22:00:39 -04:00
programmingPug b9670579c5 stuff 2018-05-20 19:50:00 -04:00
programmingPug 7ed167bb4e style 2018-05-13 17:43:30 -04:00
ckoch e3053414e4 image 2018-05-12 17:07:08 -04:00
ckoch 4bc9c83e38 finished login stg 1 2018-05-10 09:27:21 -04:00
ckoch ca630dda67 jwt initial 2018-05-06 16:00:00 -04:00
ckoch 59d8cfe020 working test for login 2018-05-05 15:33:13 -04:00
ckoch cd10c7aaad more login work 2018-05-01 08:20:56 -04:00
ckoch 8884d1df83 login2 2018-04-29 21:55:39 -04:00
ckoch d8d248672b login 2018-04-29 21:34:47 -04:00
ckoch 732065c7b5 ccc 2018-04-27 21:06:19 -04:00
ckoch 0e4ceac959 validation 2018-04-25 20:12:10 -04:00
programmingPug 5db5cf17e1 username works! 2018-04-24 09:16:54 -04:00
programmingPug 2be5ab498f workjs! 2018-04-23 09:08:40 -04:00
ckoch 914118f52f val2 2018-04-22 21:30:29 -04:00
ckoch 3d263a956d loginv1 2018-04-21 20:55:21 -04:00
ckoch ace486c4d6 reg 2018-04-18 18:04:35 -04:00
ckoch 9403e850e1 d 2018-04-18 17:04:05 -04:00
ckoch 194ff9e141 Merge branch 'master' of https://github.com/programmingPug/LudosData 2018-04-18 17:03:35 -04:00
ckoch 35d200a865 UI fix 2018-04-18 17:01:37 -04:00
programmingPug abfe4ee830 d 2018-04-12 23:29:22 -04:00
ckoch 2e1a7f06d3 images 2018-04-01 21:19:06 -04:00
programmingPug c8e94178f8 feat. 2018-03-31 23:08:25 -04:00
ckoch 7ad3c6b93a working updates 2018-03-31 17:35:30 -04:00
ckoch 2a3b0509a8 stuff 2018-03-04 13:43:58 -05:00
ckoch c425c1542f day one build 2018-02-26 23:14:37 -05:00
ckoch 4196aa268a Initial commit 2018-02-26 13:02:56 -05:00