Compute the "new platforms" totals from the platforms already loaded via
get_platforms() instead of issuing one get_platform_by_fs_slug query per
platform. Fix the test fixture to report the existing platform through
get_platforms() so the mocked data matches the code path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Compute the "new platforms" totals from the same existence check used
per-platform in _identify_platform, so the tracker totals match what is
actually scanned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deleting a platform or collection in the v2 UI showed "Failed to
delete: unknown error" even though the deletion succeeded. The success
handler navigated to a non-existent route name ("platforms" /
"collections"), and Vue Router threw on the unknown name, which the
catch block surfaced as a generic error.
Navigate to the real index routes (PLATFORMS_INDEX / COLLECTIONS_INDEX)
instead.
Fixes#3598
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cv77VUJgPe1H7ipSRitjpH
The patcher uploads the patched ROM and then fires a platform scan to
register it. When a second scan runs against the same platform around the
same time (a filesystem-watcher rescan, a scheduled rescan, or another
manual scan on a multi-worker setup), both scans could see the new file as
absent from the DB and each insert it, producing two identical library
entries for one patched file.
A platform folder can't physically hold two entries with the same name, so
a ROM is uniquely identified by (platform_id, fs_name). Enforce that with a
unique index instead of the previous plain index, which makes the duplicate
impossible. The scan's early ROM insert now adopts the row created by a
concurrent scan (catching the integrity error and skipping) instead of
failing, and ROM rename pre-checks for a name collision so it returns a
clean 409 rather than hitting the constraint.
Includes a migration that removes any pre-existing duplicates (keeping the
lowest id; dependents cascade) before upgrading the index to unique.
Fixes#3590
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0135UV8Xn2XHkRhjzhm9UptP
The scan tracker computed total_platforms and total_roms over every
filesystem platform, ignoring both the selected platforms and the scan
type. For a "new platforms" scan, existing platforms are skipped inside
_identify_platform, so their ROMs never count toward scanned_roms, yet
they were all included in total_roms. This made the tracker wildly
overcount (the whole library instead of just the new platform).
Resolve the platform list before computing totals and, for NEW_PLATFORMS
scans, exclude platforms that already exist in the database so the totals
match what is actually processed.
Fixes#3599
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158RJnc7MmAbwRz6Qiyrj5a
The miximage hover video had no height bound, so a tall/narrow source
overflowed the cover. Match the miximage frame's box and `object-fit:
cover` it so the clip fills the bezel screen and crops instead of
spilling past it.
Also gate the PWA dev service worker behind DEV_PWA: it intercepted dev
requests and forced full page reloads on every edit (CSS included),
defeating HMR. Default dev now gets working HMR; set DEV_PWA=true to test
the PWA in dev.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The frontend rewrites every cover URL to .webp as soon as the heartbeat
reports ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP, but existing covers have
no .webp sibling until the scheduled cron eventually runs (the inline
conversion only covers art fetched after enabling). This produced 404s on
all existing covers until the cron fired.
Enqueue a one-off backfill run of the conversion task on startup when the
feature is enabled, mirroring the recompute-save-hashes pattern. A fixed
job_id + Job.exists guard prevents duplicate jobs across restarts, and the
task already skips covers that have a .webp sibling so repeated runs are
cheap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removing the aspect_ratio body field left update_platform with a single
scalar Body() param. FastAPI stops embedding a lone scalar body, so the
endpoint began expecting a bare JSON string while the frontend keeps
sending {"custom_name": "..."}, producing a 422 when editing a
platform's display name in v2.
Restore the embedded-key contract with Body(embed=True), matching the
frontend payload and every sibling update endpoint. Regenerate the
frontend types (restores the Body_update_platform model) and add an
endpoint regression test.
AI assistance: written with Claude Code (Opus 4.8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The char index and rom id index sidecars are cached under a key that
encodes only user/order/grouping. is_unscoped previously excluded only
scope and search, so metadata/tag/status filters and the bool flags
applied to the query bypassed the gate: a filtered all-games request
stored a narrowed id list under the shared "all" key and later
unfiltered (or differently-filtered) requests read it back, showing the
wrong set and count of games.
Treat any narrowing parameter as scoped so those sets compute live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deduplicate the identical cache key expression used by the char index,
filter values, and rom id index sidecars so the key scheme stays
consistent across them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /api/roms list endpoint did several O(library) computations on every
request. On a 100k-rom library each request took 4-5s. This addresses the
dominant costs, all measured on a real 100k-rom MariaDB.
- Cache rom_id_index: the full ordered id list backing virtual scroll was
recomputed (the sibling-dedup window over the whole library) on every
request, even limit=1, and shipped uncached. Memoise the unscoped scan
under the same versioned cache as the other sidecars. 2815ms -> 7ms on hit.
- Slim the sibling-dedup query: the inner derived table materialized all of
Rom (including JSON metadata blobs) for 100k rows, and carried a wide unused
fs_name_no_ext through the window's temp table (spilling the sort to disk),
plus a pointless inner ORDER BY. Select only the columns the window needs.
2.79s -> 0.86s, identical results, no schema change.
- Rewrite with_char_index: replace row_number() over the whole library (full
materialization + double filesort) with a per-letter COUNT and an
accumulate. Identical output, drops a filesort layer.
- Add idx_roms_sibling_cover covering index for the sibling_roms view
self-join, so the 7-way metadata-id OR resolves from the index instead of
reading wide rows per parent. ~8x on dense pages warm, far more cold.
AI assistance: written with Claude Code (diagnosis, query rewrites, migration,
tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Derive the Docker Hub namespace from DOCKER_NAMESPACE, falling back to
DOCKER_USERNAME and then github.repository_owner, so forks whose GitHub
owner name differs from their writable Docker Hub namespace can push.
GHCR is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the comment step to reflect that PR builds only push to GHCR,
add a note explaining the hardcoded registry, and inline the image
string into the updateComment call.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use UPS members for the platform-slug keys instead of bare strings.
zxspectrum and windows now use their real UPS slugs (UPS.ZXS, UPS.WIN);
naomi, chip-8 and steam stay as raw strings since they have no UPS member
(platform.slug falls back to the folder name for those). Lookups by raw
slug string still resolve, since UPS is a StrEnum.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Give all three tools the same header shape: shebang, summary docstring, a
short detail paragraph, and a "Run from the backend directory:" command
block. Adds docstrings to generate_supported_platforms.py and
xml_diagnostics.py, which previously had none.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the backend/tools note into the repo-wide rules section so it reads
as a standing convention rather than a backend command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relocate generate_test_data.py (from backend/scripts) and
generate_supported_platforms.py (from backend/utils) into backend/tools,
alongside the existing xml_diagnostics.py. Update their run-command
references and document backend/tools in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CodeQL flags the value as clear-text logging of sensitive info. Print the
username and reference the --password flag instead of its value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- B311: random is used for deterministic fake data, not security
- B608: DELETE table names come from a hardcoded list, not user input
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- patcher.js resolves rom-patcher-js from both the relocated sibling
layout (docker/Dockerfile) and the plain node_modules layout (root
Dockerfile), so both build flows work without a manual copy
- apply_patch wraps the node subprocess in asyncio.wait_for with a
timeout and kills it on expiry; a semaphore bounds concurrency, and the
endpoint rejects oversized ROM/patch files to avoid OOM
- report the patch source-checksum validation result via an
X-Patch-Validated header; the patcher UI warns on a mismatch
- return a generic "Patching failed" detail to clients and log the real
error server-side, so node/RomPatcher.js paths don't leak
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>