69 Commits
Author SHA1 Message Date
Claude f5ce0af4f9 style: use alias imports for useEscapable and trim new comments
Addresses review: v2 imports go through the @/v2 alias, and comments
stay within two lines with docstrings in one sentence.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZW9ChvmiTKXYYWY6JvTKU
2026-10-01 11:20:58 +00:00
Claude d91af962d2 style: sort the useEscapable import in RComboboxField
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZW9ChvmiTKXYYWY6JvTKU
2026-10-01 11:17:53 +00:00
Claude cb7bb1a0aa fix(v2): time stream play sessions from the moment the stream is live
onPlay started the play session only if the state was already 'playing'
when it returned, but a claim stays 'loading' until the launch-ready socket
push, which usually lands later. Start the session when the game starts
running and flush it when it stops.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZW9ChvmiTKXYYWY6JvTKU
2026-10-01 04:12:17 +00:00
Claude b57cbd3e51 fix(v2): keep the gallery search box in sync with the URL
The search box was only written by typing, and the refetch only ran from
the debounce, so a search term changed by navigation (the platform view
reuses the shell across /platform/:id) left a stale query in the box.
Move the debounced search into useDebouncedSearch, which mirrors the store
term into the box, and refetch from the shell's filter watch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZW9ChvmiTKXYYWY6JvTKU
2026-10-01 04:12:17 +00:00
Claude 2be99ae170 fix(v2): close select, combobox and date popovers before their overlay
RSelect, RComboboxField and RDateField never registered on the escape
stack, whose window capture listener runs first. Escape (or pad B) with
one of them open inside a dialog closed the dialog and left the popover
open; outside an overlay, pad B navigated back. Add useEscapable and
register each popover while its panel is shown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZW9ChvmiTKXYYWY6JvTKU
2026-10-01 04:12:17 +00:00
Claude cc20146049 fix(streaming): release the claim when a claim fails unexpectedly
claim_session released the container only at the hand-placed abort_claim
calls before each planned refusal. Any other error after the claim was won
(a DB or Redis error, say) left the container reserved for every player
until the claim went stale, and could orphan the blank memory card.
Release the claim, and the blank card it created, on any such error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZW9ChvmiTKXYYWY6JvTKU
2026-10-01 04:12:17 +00:00
Claude a846b7edc1 fix(frontend): build the cached /roms query with the shared mapping
The cached client kept its own copy of the GetRomsParams to query mapping,
which had drifted: it dropped publishers, developers, physical,
has_soundtrack, the HLTB length bounds, released_days and the with_* flags.
Export buildGetRomsQuery from the API client and use it in both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZW9ChvmiTKXYYWY6JvTKU
2026-10-01 04:12:17 +00:00
Claude b2c628093d fix(config): reject exclude lists that are not lists
The exclude lists were spread into a set before validation ran, so the
isinstance checks could never fail: 'exclude.platforms: ps2' excluded the
folders 'p', 's' and '2', and a number crashed startup with a TypeError.
Validate the raw YAML values instead, and read only list values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZW9ChvmiTKXYYWY6JvTKU
2026-10-01 04:12:16 +00:00
Claude 500728708c fix(roms): clear every match id when unmatching a ROM
The unmatch branch of update_rom nulled 15 ids but not gamelist_id, while
clearing gamelist_metadata, so an unmatched ROM that came from a
gamelist.xml still counted as identified. Build the reset (and the audit
record of what was unmatched) from METADATA_SOURCE_COLUMNS plus sgdb_id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZW9ChvmiTKXYYWY6JvTKU
2026-10-01 04:12:16 +00:00
Claude ef57ee300d fix(scan): count every metadata source when deciding a ROM is identified
The not-identified check in scan_rom listed 13 provider ids by hand and
missed tgdb_id and libretro_id, so a ROM matched only by Libretro (or by a
TGDB id from Hasheous) took the early return: it skipped the SteamGridDB
lookup and missing_from_fs reset, while Rom.is_identified counted it as
matched. Derive the check from METADATA_SOURCE_COLUMNS instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZW9ChvmiTKXYYWY6JvTKU
2026-10-01 04:12:16 +00:00
Claude 87d00a54b2 revert: keep starting the RQ streaming worker unconditionally
Reading streaming.enabled from config.yml in the init script couples the
shell to the YAML config, and the worker's isolation is what keeps a slow
teardown or exit save pull off the shared task worker (#4632). The worker
stays always-on; its idle cost is reduced separately by slimming what RQ
worker parents import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gh1SRMfuK7BZmf6YxMqSyT
2026-10-01 02:40:31 +00:00
Claude 258b87de0d fix: satisfy mypy and isort, and trim comments
The reaper test now reads HOLD_CEILING_SECONDS from handler.streaming.config,
where it is defined. Merges the duplicate handler.auth.base_handler imports
the rewrite left in two test files, and shortens the comments on the hold
ceiling, the LaunchBox spec and the registry module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gh1SRMfuK7BZmf6YxMqSyT
2026-10-01 01:44:09 +00:00
Claude 103c59e9c0 fix: keep a consumer on the streaming queue when streaming is off
config.yml is re-read on every request, so streaming can be turned on
without a restart, and exit save pulls queued before it was turned off
still need a worker. With streaming off at boot, the general RQ worker
now listens on the streaming queue too instead of leaving it without a
consumer; the dedicated streaming worker still starts only when
streaming is on.

The check reads the exit status inline rather than through a function
in an `if !`, which shellcheck flags (SC2310) since it disables errexit.

Adds a fresh-interpreter test that init_sentry() never imports the SDK
without a DSN, and drops a comment that restated the PR's rationale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gh1SRMfuK7BZmf6YxMqSyT
2026-10-01 01:40:44 +00:00
Claude 1e6dd1a2ac perf: keep task code out of the cron scheduler and idle RQ workers
The registry imported every task module to read each task's title,
schedule, queue and timeout, so `rq cron` and every RQ worker parent
loaded the whole app. Task metadata now lives in the registry as
`TaskSpec`s with a dotted path to the implementation, which `get_task`
imports only when a job runs. Cron, the tasks API, enqueueing and the
worker's failure reporting read specs only.

Every log line also imported handler.metadata.base_handler, and with
it every metadata provider, for the redaction regex. The sensitive keys
and their regex now live in logger.formatter.

A test pins that importing the cron config or the worker class loads
neither handler.database, the auth handlers nor fastapi.

Measured RSS at LOGLEVEL=INFO, master -> this branch:
- rq cron: 164 MB -> 72 MB
- idle RQ worker (x3): 133 MB -> 68 MB
- gunicorn web worker: 214 MB -> 206 MB

Each job's work horse now imports its task module itself, 0.1-0.5s
more per job on an x86 host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gh1SRMfuK7BZmf6YxMqSyT
2026-10-01 01:32:21 +00:00
Claude dd3b0a6f99 perf: stop loading the auth and web stack in background processes
Importing any handler.auth submodule ran the package __init__, which
built the auth handlers and so loaded authlib, joserfc and Socket.IO.
models.user imports handler.auth.constants, so every process that
touched the models paid for the whole auth stack. The handler
instances now live next to their classes in handler.auth.base_handler.

utils.context imported fastapi only for annotations; that import now
sits under TYPE_CHECKING.

An idle RQ worker drops from about 118 MB to 89 MB RSS. Each job's
forked work horse now imports a little more on its own, about 0.2s
on an x86 host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gh1SRMfuK7BZmf6YxMqSyT
2026-10-01 01:02:37 +00:00
Claude 78379718c7 perf: trim idle memory of the backend processes
Skip sentry_sdk.init when no SENTRY_DSN is set. Without a DSN the SDK
reports nothing, yet init still loads every default integration (httpx,
rich, jinja2, mako, pygments...), about 10 MB per process.

Import alembic.config only in main.py's __main__ block, the one place
that uses it, instead of in every gunicorn worker (about 8 MB).

Don't start the RQ streaming worker when config.yml leaves streaming
off (the default). It only runs streaming teardowns and exit save pulls,
and costs about 115 MB idle.

Measured on a gunicorn worker importing main: 213 MB -> 193 MB RSS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gh1SRMfuK7BZmf6YxMqSyT
2026-09-30 23:54:42 +00:00
Claude bc211b1dfc Fix broken dash pairs from the comment sweep (Greptile review)
Six comments had a dash pair around a 'which' clause turned into two
colons. Use commas so each reads as one sentence.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RyamrxVw5BzEidVzm68LY7
2026-09-28 17:53:47 +00:00
Claude 54a6ca6c96 Remove the no-emdash-in-comment ESLint rule after a one-time sweep
Replace the em-dashes in frontend comments outside the frozen v1 UI
(script, template and style comments, plus the header build-tokens.ts
writes into tokens.css), then drop the rule. String literals keep their
em-dash glyphs. The AGENTS.md rule stays, and pr-ready's humanizer pass
catches new ones in prose.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RyamrxVw5BzEidVzm68LY7
2026-09-28 17:25:42 +00:00
Claude 42e5b5d119 Remove the no-emdash Trunk linter after a one-time sweep
The linter ran over every Markdown file and Python comment, so full-repo
trunk runs on master kept failing on em-dashes that predate it. Replace
the existing ones once and drop the check. The AGENTS.md rule stays, the
v2 ESLint rule stays, and pr-ready's humanizer pass catches new ones in
prose.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RyamrxVw5BzEidVzm68LY7
2026-09-28 16:59:56 +00:00
Claude 35d21af392 chore(skills): humanize release notes and announcement drafts
Both drafting skills now apply the vendored humanizer before their handover
checks, so the character count and PR accounting run on the rewritten text.
The announcement keeps its voice from the past posts it already reads; the
release notes limit the pass to Highlights and callouts written for this tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXuG7JWgxP6DPLB182yD9v
2026-09-27 18:38:28 +00:00
Claude e979b3e9ee chore(skills): move humanizer scope out of the step list (simplify)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXuG7JWgxP6DPLB182yD9v
2026-09-27 18:27:05 +00:00
Claude a4186eb4c0 chore(skills): tighten humanizer step scope and directives (code-review)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXuG7JWgxP6DPLB182yD9v
2026-09-27 18:24:49 +00:00
Claude c6b6d4668e chore(skills): vendor humanizer and run it as the last pr-ready step
Vendors blader/humanizer (MIT) unmodified at 9862685f, excluded from Trunk
because its examples quote the em dashes it removes. pr-ready gains a fifth
step that runs it over the prose a change adds, with RomM's comment limits and
house formatting taking precedence over its patterns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXuG7JWgxP6DPLB182yD9v
2026-09-27 15:46:00 +00:00
Claude 061cde1e73 fix(v2): close open overlays when the route changes
v2 dialogs and drawers are mounted by GlobalDialogs at the layout level,
above the router view, so nothing dismissed them when the route changed
underneath: a browser back with the Match ROM dialog open left the panel
floating over the gallery.

Add a router guard that closes every surface registered on the shared
escape stack when the route identity changes. Query-only navigations are
left alone, since gallery filters, view mode and the details subtabs
rewrite the query string while their overlay is open.

Also settle useConfirm's promise when the confirm dialog is closed
without a pick, which the header close button could already do.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-12 19:56:46 +00:00
Claude 4e07aa45bb feat(frontend-v2): surface scraped videos on the Overview tab
Extract the art-asset resolution into a shared util (resolveRomArtwork)
so the Media tab's Artwork subtab and the Overview tab stay in sync, and
add a Videos section to the Overview tab for the scraped preview clips.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FFq1CxyxqM5nBCY6Tfgyv1
2026-06-29 14:46:34 +00:00
Claude c1c1797abb fix(frontend-v2): expose scraped art assets in Media tab
The V2 game details view only surfaced screenshots and title screens,
hiding the majority of scraped art (bezel, logo, marquee, box art, fan
art, mix images) and the scraped videos that the classic GUI showed in
its overview carousel.

Add an "Artwork" subtab to the Media tab: a read-only, labelled gallery
of every scraped art asset that doesn't already have its own surface.
Images open a fullscreen lightbox; videos play inline. ScreenScraper is
preferred with gamelist fallbacks, mirroring v1's MediaCarousel.

Closes #3613

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FFq1CxyxqM5nBCY6Tfgyv1
2026-06-29 14:40:08 +00:00
Claude f9a5537efc fix(upload): store chunk temp dir under writable resources path
Chunked ROM uploads created their temp directory directly under
ROMM_BASE_PATH (/romm/tmp/uploads). The bare /romm directory is owned by
the image's romm user (1000:1000, mode 755), so containers running with a
custom UID/GID cannot create directories there, causing uploads to fail
with "PermissionError: [Errno 13] Permission denied: '/romm/tmp'".

Default the upload temp root to RESOURCES_BASE_PATH instead, which is a
writable mounted volume in those setups (this is why manual uploads, which
write under resources/, already worked). ROMM_TMP_PATH still overrides it.

Fixes #3574

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ficBAiEtYtZyBY8nWExKh
2026-06-29 14:36:09 +00:00
Claude 0e38eb4024 fix(frontend-v2): show revision tag on game Overview tab
The v2 game details Overview tab did not surface the parsed revision
tag (e.g. "Rev 1", "Rev B"), which the classic GUI showed in its Info
section. Add a Revision fact row to the Overview tab, sourced from the
ROM's revision field.

Closes #3617

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TUiuFX615VBcwYT7xb6dsm
2026-06-29 14:35:41 +00:00
Claude e8a7ae3fee fix(emulation): make save state capture and restore reliable
Save states were inconsistently saved and frequently failed to load,
especially on threaded cores (SNES, N64). Two timing races were the
cause:

- On game start, a selected state was applied after a fixed 10ms delay
  with no check that the core was ready. Heavier cores aren't booted in
  that window, so the state applied partially and left a black screen.
  Replace the fixed delay with a gameManager readiness poll plus a short
  settle window before applying the state (mirrors the console player).

- The Save & Quit handler read the state from a running emulator. On
  threaded cores the worker thread mutates emulator memory while
  getState() reads it, producing torn/corrupt buffers that are then
  stored permanently. Capture the screenshot while running, then pause
  before serializing the state and save file.

Also refuse to upload zero-length state buffers so a failed capture
can't overwrite good states on the server.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PoAdK2fmqHGmGuXWaKN1HT
2026-06-28 20:00:38 +00:00
Claude 0b20ca331e fix(scan): replace placeholder filename with matched provider name
A ROM's name defaults to its raw filename (extension included) when it is
first discovered. On UNMATCHED and UPDATE rescans, the block that preserves
existing base fields kept that placeholder name even after a metadata
provider finally matched the ROM, leaving the filename (with its ".zip"
extension and region tags) as the title.

Treat a name equal to the ROM's filename as "no name" so a freshly matched
provider name replaces it, while still preserving user-edited names.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Shf4omKSP6dHC3dC2Jjg7b
2026-06-28 18:31:30 +00:00
Claude 5845971331 fix: normalize Sony serial casing before Redis lookup
Sony serial codes captured from filenames preserved their original casing.
Dash-separated slug filenames (e.g. game-u-slus-00431-.rar) yield a
lowercase serial, but the serial index keys in Redis are uppercase, so the
lookup missed and the ROM went unidentified.

Uppercase the serial before the hget so PS1/PS2/PSP serial matching works
regardless of filename casing.

Closes #3621

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VHtzKWXK7nbUDgEmHxpnKc
2026-06-28 18:21:16 +00:00
Claude 302288e62f fix(v2): use correct route name when navigating after delete
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cv77VUJgPe1H7ipSRitjpH
2026-06-25 23:54:44 +00:00
Claude e4660aca4c fix(scan): prevent duplicate ROM entries from racing scans
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_0135UV8Xn2XHkRhjzhm9UptP
2026-06-25 23:01:37 +00:00
Claude 4f9117d9f8 fix(ra): hash PSP compressed-ISO containers natively (#3600)
PSP games stored as .cso (and .ciso/.zso/.dax) never matched on
RetroAchievements because RomM hands these files to the external RAHasher
binary, which can't read CSO/ZISO/DAX compressed-ISO containers and fails
with "Could not open track". The game was then left without an RA link.

The PSP RA hash does not depend on the whole disc: rcheevos computes it as
MD5(PSP_GAME/PARAM.SFO + PSP_GAME/SYSDIR/EBOOT.BIN), each file capped at
64 MiB. Those containers are block-indexed, so we can decompress just the
blocks those two files occupy and hash them in-process, producing the
identical hash RA stores (and PPSSPP computes).

Add utils/psp_hasher.py: a seekable block-decoder for CISO/ZISO (deflate
and LZ4) and DAX containers, a minimal ISO9660 reader to locate the two
files, and calculate_psp_ra_hash(). RAHasherService now computes the hash
natively for PSP .cso/.ciso/.zso/.dax files, falling back to RAHasher on
any failure. Raw .iso PSP discs still go to RAHasher, which reads them fine.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KsyrT6n8WYWWxR2XGoEASs
2026-06-25 22:58:56 +00:00
Claude fb6f6affad fix(scan): make scan tracker totals reflect platforms actually scanned
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_0158RJnc7MmAbwRz6Qiyrj5a
2026-06-25 22:53:46 +00:00
Claude c456034657 fix(v2): stop gamepad B/cancel from backing out of controller test and games
The global useGamepad loop translated every button into a built-in
action regardless of context. On the controller-test screen this meant
pressing B (or Circle / Nintendo-A, which share the standard B index)
fired "back" and left the page before the press could be inspected. The
same happened while a game was running: the emulator owns the pad, but
useGamepad still ran "back" on B and quit the game.

Suppress the built-in translation in two contexts:
- While a game is running (storePlaying.playing), so the emulator owns
  the pad entirely.
- On the controller-test route, so every button can be pressed and
  inspected in place.

Because the test screen no longer exits on B, ControllerDebug owns its
own escape: hold a back button (B / Circle or Back / Share) for 700ms to
navigate away, with an on-screen hint and a fill indicator. A short tap
just registers in the inspector.

Fixes #3591

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EdiAR2HMM5pCMVfn2Zhbrx
2026-06-25 20:25:40 +00:00
Claude 23dbffd17a fix(deps): bump dependencies to patch reported vulnerabilities
Address vulnerabilities flagged by the security scanner:

Backend (pyproject.toml / uv.lock):
- cryptography 46.0.5 -> 49.0.0 (CVE-2026-39892, memory buffer)
- mako 1.3.10 -> 1.3.12 (CVE-2026-41205/44307, path traversal)
- gunicorn 23.0.0 -> 26.0.0 (HTTP request smuggling hardening)
- yarl 1.20.1 -> 1.24.2 (SSRF via host parsing)

Frontend (package.json / package-lock.json):
- axios ^1.16.0 -> ^1.18.1 (sensitive data exposure)
- form-data pinned to ^4.0.6 via override (CVE-2026-12143, CRLF injection)

starlette is already on 1.0.1, which is the patched release for the
BadHost advisory (CVE-2026-48710), so no change is needed there.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01W5nnYmsCNdUjNpuwRxqUiR
2026-06-24 17:45:22 +00:00
Claude ef7f91d643 Merge remote-tracking branch 'origin/master' into claude/kind-goodall-0b77y7
# Conflicts:
#	frontend/src/locales/bg_BG/rom.json
#	frontend/src/locales/cs_CZ/rom.json
#	frontend/src/locales/de_DE/rom.json
#	frontend/src/locales/en_GB/rom.json
#	frontend/src/locales/en_US/rom.json
#	frontend/src/locales/es_ES/rom.json
#	frontend/src/locales/fr_FR/rom.json
#	frontend/src/locales/hu_HU/rom.json
#	frontend/src/locales/it_IT/rom.json
#	frontend/src/locales/ja_JP/rom.json
#	frontend/src/locales/ko_KR/rom.json
#	frontend/src/locales/pl_PL/rom.json
#	frontend/src/locales/pt_BR/rom.json
#	frontend/src/locales/ro_RO/rom.json
#	frontend/src/locales/ru_RU/rom.json
#	frontend/src/locales/zh_CN/rom.json
#	frontend/src/locales/zh_TW/rom.json
2026-06-23 02:00:27 +00:00
Claude 5a9eda9eeb docs: split v2 constitution into skills, make CLAUDE.md holistic
Reorient the repo for agentic development by outside contributors.

- Rewrite CLAUDE.md from a frontend-v2-only constitution into a lean,
  holistic guide covering both stacks, the v1/v2 split, repo-wide rules
  (English, AI disclosure, branch/PR flow, Trunk, verification), a skills
  index, and a quick command reference.
- Extract the v2 constitution and add backend guidance as seven focused
  skills under .claude/skills/:
  - frontend-v2-components (tiers, file/SFC conventions, anti-patterns)
  - frontend-v2-theming (tokens, dual theme, zero-hex-literal policy)
  - frontend-v2-input (universal input + responsive viewport)
  - frontend-v2-patterns (errors, loading, sockets, persistence, forms,
    permissions, confirmations)
  - frontend-i18n (locale parity rule + check script)
  - backend-development (FastAPI/SQLAlchemy layering, scopes, migrations,
    OpenAPI -> frontend type pipeline, uv/pytest/trunk workflow)
  - pre-pr-verification (per-stack pre-PR gate mirroring CI)
- Track shared skills in git while keeping personal/local Claude config
  ignored.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_012EigwEL43km8mskHjT6sjr
2026-06-23 01:49:08 +00:00
Claude f7f0ca9c2c feat: allow a list of structure templates per platform (union discovery)
A single fixed-depth template can't describe a platform that holds loose
games in its root AND organizes others into grouping subfolders — `{gameFile}`
drops the grouped games, `{category}/{gameFile}` drops the loose ones. Let a
platform's `filesystem.structure` value be a list of templates; discovery is
their union, deduplicated by full path:

  structure:
    nes:
      - "{gameFile}"             # loose top-level games
      - "{category}/{gameFile}"  # games inside grouping subfolders

- config: accept str | list[str]; platform_structure() returns a tuple of
  parsed structures; parse_platform_structures() + validation handle both forms.
- fs handler: _collect_fs_roms unions each structure's discovery, dedup by
  (fs_path, fs_name).
- tests: list parsing, per-platform list config loading, and the mixed
  loose+grouped discovery case.
- docs: config.example.yml leads with the mixed-layout list example.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Sk2dzM3K9qxWBdPAeGb7us
2026-06-23 01:45:07 +00:00
Claude d7c5ab7e06 feat(v2): RBox3D faces right, self-spins, and coasts on flick
- Default orientation now faces right (initialYaw flipped).
- Idle auto-spin actually resumes: the time-based check moved into the RAF
  loop, since a computed reading performance.now() cached and never restarted
  after the first interaction. Quiet window is 2s.
- Flick momentum without a JS decay loop: the release velocity is handed to
  the box as one extra rotation and a CSS ease-out curve coasts it to a stop.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_019itLXRfJXGGbhPY3JyqnuN
2026-06-22 23:23:37 +00:00
Claude 1b02bf6a3e fix(v2): RBox3D adopts box-2D dimensions even when cached
Read the front (box-2D) and spine image natural ratios on mount, not only on
the load event — a cached cover decodes before the listener binds, which left
the box stuck at the default ratio instead of the real box-2D proportions.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_019itLXRfJXGGbhPY3JyqnuN
2026-06-22 22:17:22 +00:00
Claude ab2b61783b fix(v2): RBox3D rotates on drag instead of selecting faces
Add user-select: none to the box and pointer-events: none to the faces so a
drag always lands on the root (which owns the rotation listeners) and never
starts a text / image selection.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_019itLXRfJXGGbhPY3JyqnuN
2026-06-22 22:12:48 +00:00
Claude 77980a79ec feat(v2): interactive 3D box art on game detail hero
Adds an RBox3D primitive that builds a rotatable, fake-3D game box from
three flat ScreenScraper scans (front, back, spine) using CSS 3D
transforms. Box proportions derive from the images themselves; it rotates
via pointer drag, arrow keys / gamepad D-pad, and the right analog stick,
drifts gently when idle, and honours prefers-reduced-motion.

The game detail hero (CoverColumn) upgrades to the spinning box when the
"3D box" boxart style is selected and the rom has the full set of faces,
falling back to the flat cover otherwise.

Backend: persist the box-2D-side (spine) scan locally, mirroring the
existing box-2D-back handling — new BOX2D_SIDE media type + box2d_side_path
on ss_metadata, opt-in via scan.media.

- RBox3D primitive + Storybook story (controls + keyboard-rotation play())
- useBoxFaces composable resolving the three faces + a `complete` gate
- box3d-alt i18n key across all locales
- backend BOX2D_SIDE persistence + tests

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_019itLXRfJXGGbhPY3JyqnuN
2026-06-22 21:45:39 +00:00
Claude b5f457b53a feat: replace subfolder flag with per-platform custom library structure
Generalizes the opt-in subfolder scanning into a per-platform custom
library structure template, modeled on Retrom's. Replaces the
`scan.subfolders` (bool | list) flag with `filesystem.structure`
(fs_slug -> template); platforms without a template keep RomM's default
discovery (top-level files and folders), so existing setups are unaffected.

A template is relative to the platform's ROM folder (RomM already resolves
the library root, roms_folder and platform directory) and is a sequence of
`/`-separated path sections:

  - a braced section is a macro, a bare section is a literal folder matched
    exactly;
  - the last section must be the terminal {gameFile} (each file is a game) or
    {gameDir} (each folder is a single multi-file game);
  - any other braced section ({region}, {category}, ...) is a wildcard
    directory level that matches any folder (organizational only).

Examples:
  filesystem:
    structure:
      nes: "{category}/{gameFile}"   # roms/nes/Hacks/foo.nes
      ps3: "{category}/{gameDir}"    # roms/ps3/PSN/Game/
      snes: "{region}/{gameFile}"    # roms/snes/USA/foo.sfc

Declaring {gameFile} vs {gameDir} removes the previous disc-descriptor
guessing entirely — the user states file-vs-folder. Hidden (dot-prefixed)
folders are never descended into. Hash-based non-destructive identity
(relocate a moved/renamed game in place, preserving saves/history/favorites/
collections) is retained and now triggers for any platform with a custom
structure.

- config: parse + validate templates at load (parse_library_structure,
  LibraryStructure); reject {platform}/{library} (RomM resolves those) and
  malformed templates.
- fs handler: _discover_structured_roms walks literal/wildcard levels to the
  terminal; _discover_default_roms preserves the default behavior.
- Tests: template parser (valid/invalid), per-platform config loading, and
  structured discovery (wildcard/literal levels, file/dir terminals, depth>1,
  hidden-folder skip, cross-folder name collisions). Subfolder-flag tests
  replaced.
- Docs: config.example.yml documents filesystem.structure; scan.subfolders
  removed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Sk2dzM3K9qxWBdPAeGb7us
2026-06-21 22:01:05 +00:00
Claude 5b494c3bc4 feat: opt-in per-platform subfolder scanning with non-destructive identity
Adds opt-in, per-platform subfolder scanning (closes #2050). When enabled
for a platform, RomM recurses that platform's subfolders and treats each
nested file as its own ROM, instead of collapsing a subfolder into one
multi-file ROM — so libraries organized into Hacks/, Translations/,
Homebrew/, etc. are scanned correctly.

Enable per platform in config.yml (default off, existing setups unaffected):

  scan:
    subfolders:
      nes: true                       # recurse every subfolder
      snes: ["Hacks", "Translations"] # recurse only these; keep others whole

Key difference from a naive path-identity approach: identity is reconciled
by content hash on scan. Moving or renaming a ROM between subfolders is
detected by its hash against a now-missing ROM and relocated in place, so
saves, play history, favorites and collection membership follow it. Falls
back to path identity when hashing is unavailable (skip_hash_calculation or
a non-hashable platform). A cheap size pre-filter avoids hashing every
newly-seen file (e.g. on first enable).

Details:
- Per-platform opt-in via scan.subfolders (fs_slug -> bool | list[str]); no
  DB schema change.
- Recursion skips hidden (dot-prefixed) folders, and keeps a folder whole as
  a single multi-file ROM when it holds a disc/playlist descriptor
  (.m3u/.cue/.gdi/.ccd/.toc) — covers cue+bin / multi-disc games.
- Path-based keying for the steady-state lookup (get_roms_by_fs_name and
  mark_missing_roms key on full path) so identically-named files in different
  subfolders stay distinct; the (platform_id, fs_name) index is non-unique.
- File resolution (download/delete/hash) uses the ROM's stored fs_path.
- Scan log flags entries that became "missing" because a folder is now
  recursed, so stale entries are easy to clean up.
- Frontend: the Files tab shows a ROM's on-disk Location (click-to-copy),
  built as a shared LocationChip mirroring HashChip's RTag-based pattern.
  New i18n keys added to all locales.

Tests: backend unit tests for recursion (collisions, hidden folders,
descriptor dirs kept whole, named-list form), full-path keying in
get_roms_by_fs_name / mark_missing_roms, and hash-based relocation.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Sk2dzM3K9qxWBdPAeGb7us
2026-06-21 20:46:50 +00:00
Claude 105bca7e68 Add IGDBService rate-limiter unit test
The IGDB service acquires a rate-limiter slot before each request like the
other metadata services, but unlike them had no direct unit test asserting it
(IGDB is otherwise exercised via cassette-backed handler tests). Add a focused
test so the limiter call can't be silently removed or moved after the request.

https://claude.ai/code/session_01133QQuWvq8Zm25DZMP9PVr
2026-06-07 12:16:02 +00:00
Claude cd41422660 Use a concurrency limiter for ScreenScraper, honoring account threads
ScreenScraper enforces a per-account *thread* (concurrency) cap rather than
a request rate. Requests can take several seconds, so spacing out request
starts at 1/s could still leave multiple requests overlapping in flight,
exceeding the cap and getting rejected with ScreenScraper's custom HTTP codes.

- Add ConcurrencyLimiter: a runtime-resizable, loop-agnostic limiter that
  bounds simultaneous in-flight operations (held for the whole request via
  async context manager), instead of spacing request starts.
- Switch the ScreenScraper service from the req/s RateLimiter to a module-level
  ConcurrencyLimiter defaulting to a single thread.
- Recognize contributor/donor accounts: parse `ssuser.maxthreads` from each
  response and raise the concurrency allowance to match, so supporters scrape
  with their full thread count instead of the conservative default.

Adds unit tests for the limiter (blocking, wake-on-release, runtime resize)
and for the ScreenScraper slot-holding and thread-allowance updates.

https://claude.ai/code/session_01133QQuWvq8Zm25DZMP9PVr
2026-06-06 16:02:13 +00:00
Claude 8cc79f9e5c Add pre-emptive rate limiting to metadata API services
Wire the existing RateLimiter into the IGDB, ScreenScraper, MobyGames and
RetroAchievements services so requests are spaced under each provider's
documented req/s cap, instead of only reacting to HTTP 429 after the fact.

- IGDB: 4 req/s (documented hard limit)
- MobyGames: 1 req/s (free-tier burst cap)
- ScreenScraper: 1 req/s (free-tier throttle)
- RetroAchievements: 4 req/s (conservative; no published hard limit)

A slot is acquired before both the initial request and the timeout/429
retry. The reactive 2s 429 backoff is kept as a fallback. Tests neutralize
the shared limiters via an autouse fixture and assert acquire() is awaited.

https://claude.ai/code/session_01133QQuWvq8Zm25DZMP9PVr
2026-06-06 14:34:56 +00:00
Claude 6d533e0a25 fix(docker): declare /romm as single volume to enable asset hardlinks
PR #3388 added hardlink-based asset import/export (os.link with a
shutil.copy2 fallback) to avoid duplicating disk space. The Dockerfile
VOLUME instruction listed each /romm subdirectory (resources, library,
assets, config, sync) separately, which makes Docker create an
independent mount point for each one — even when the user bind-mounts a
single parent at /romm. Each mount point is its own st_dev, so every
cross-directory os.link() failed with EXDEV and silently fell back to a
full copy, defeating the optimization.

Declare the parent /romm directory instead so all subdirectories share a
single filesystem and hardlinks can succeed when paths reside on the same
underlying host filesystem.
2026-06-06 14:20:29 +00:00
Claude 3da3765ea3 test(feeds): give pkgj feed tests RomFile rows so they exercise output
The five pkgj feed tests created a ROM but no RomFile, so the per-file
feeds emitted only a header and the "in response.text" assertions never
actually verified output (pre-existing failures, also red on master).

Add a top-level `.pkg` GAME file (games feeds) or a DLC-category file
(dlc feeds), mirroring the pkgi_ps3 test, so the feeds produce rows.
This also gives real coverage of the new `include_files=True` path that
these feeds rely on.

https://claude.ai/code/session_01PSXKmejPRzdxLFMN6P2QQ4
2026-06-02 18:40:45 +00:00
Claude c4dd922491 perf(roms): make filter_roms file-loading opt-in for the gallery query
filter_roms feeds both the gallery/list endpoint (SimpleRomSchema, no
files) and the feed endpoints (which iterate rom.files / is_top_level).
The cleanup commit's unconditional selectinload(Rom.files) + joinedload
made the gallery/list and filter-value paths pay for files they never
serialize.

Gate the files load behind a new `include_files` flag (default False),
mirroring the existing `include_file_stats` opt-in, and plumb it through
get_roms_scalar. The 9 feed endpoints that actually read rom.files opt
in; the gallery/list, filter-values, identifiers, smart-collection, and
the three feeds that don't touch files (webrcade, fpkgi, kekatsu) skip
the load entirely — keeping the gallery query at zero file cost.

https://claude.ai/code/session_01PSXKmejPRzdxLFMN6P2QQ4
2026-06-02 12:56:02 +00:00
Claude 10a6df585f perf(scan): stop eager-loading files in get_roms_by_fs_name
Restore the "platform only" contract of `get_roms_by_fs_name` (per its
docstring) by dropping the `selectinload(Rom.files)` + `joinedload`. That
load only existed for `scan_rom`'s rare `fs_rom["files"] or rom.files`
fallback, but it forced files (and a per-file join back to roms) for every
ROM in a scan batch — expensive on large platforms, and only used when the
filesystem scan yielded no files.

Instead, fetch the persisted files on demand: `scan_rom` now resolves match
files via a small helper that returns the filesystem-scanned files, falling
back to `db_rom_handler.get_rom_files_by_rom_id(rom.id)` only when there are
none. The new getter eager-loads the `RomFile.rom` backref so `is_top_level`
keeps working on the detached results (the rare path was already latently
broken on master, which loaded files without the backref).

https://claude.ai/code/session_01PSXKmejPRzdxLFMN6P2QQ4
2026-06-02 12:31:46 +00:00
Claude fbd703cccb fix(roms): eager-load RomFile.rom in detail/scan queries instead of a hook
Replace the `_link_rom_files_to_parent` post-fetch hook with the
declarative loader pattern PR #3425 originally removed, restoring
`joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name)` on the two
queries that still load `Rom.files` (`with_details` and
`get_roms_by_fs_name`).

#3425 dropped that joinedload everywhere as part of denormalizing file
stats into the `multi_file` / `top_level_file_count` column properties.
But `is_top_level` / `file_name_for_download` (multi-file downloads, 3DS
QR codes, metadata matching) still read `RomFile.rom.full_path`, so the
two file-loading paths were over-cleaned, causing a `DetachedInstanceError`
(500) on multi-file downloads once the session closed.

The gallery query (`filter_roms`) dropped `Rom.files` entirely and is
untouched, so the performance win from #3425 is preserved; the restored
join only adds an index-backed PK lookup of two columns to the existing
files `selectin` on the detail/scan paths.

https://claude.ai/code/session_01PSXKmejPRzdxLFMN6P2QQ4
2026-06-02 11:59:36 +00:00
Claude 824ce185fe test(roms): add fixture-driven multi-file ROM download test
Add a shared `multi_file_rom` fixture (a game folder with multiple
RomFile rows) and an endpoint-level test that downloads it via
`GET /api/roms/{id}/content/{file_name}`. This exercises the multi-file
download path end-to-end, which builds each mod_zip manifest entry from
`file.rom.full_path` after the handler session has closed — the exact
path that 500'd with `DetachedInstanceError` before the backref fix.

The download endpoint had no test coverage for multi-file ROMs (the
`rom` fixture has no RomFile rows), which is why the regression slipped
through. Reuse the new fixture in the handler-level regression test too.

https://claude.ai/code/session_01PSXKmejPRzdxLFMN6P2QQ4
2026-06-02 09:13:34 +00:00
Claude 342857b14b fix(roms): repair multi-file ROM downloads broken by deferred file stats
PR #3425 dropped `lazy="joined"` from `RomFile.rom` and removed the
`joinedload(RomFile.rom)` from the ROM loaders to speed up the gallery
query. That left the `RomFile.rom` backref unpopulated. Single-file
downloads only read `RomFile.full_path` (built from `file_path`/
`file_name`), so they kept working, but multi-file (game folder)
downloads call `file_name_for_download()` / `is_top_level`, which read
`self.rom.full_path`. With no eager-loaded backref, that triggered a
lazy load on a detached instance once the handler session closed,
raising `DetachedInstanceError` and returning a 500.

Rather than reverting the loader changes (and the gallery gains), wire
the `RomFile.rom` backref up in Python from the parent ROM we already
hold in memory, via `set_committed_value`. This is zero extra DB cost
and only runs on the detail/download paths (`with_details` and
`get_roms_by_fs_name`); the optimized `filter_roms` gallery query is
untouched.

https://claude.ai/code/session_01PSXKmejPRzdxLFMN6P2QQ4
2026-06-02 08:52:25 +00:00
Claude 009d358175 fix(security): resolve hostnames in SSRF URL validator
validate_url_for_http_request previously skipped DNS resolution, so
attacker-controlled domains that resolve to private/loopback/link-local
addresses (e.g. 127.0.0.1.nip.io) passed validation and the subsequent
httpx GET hit internal services. Resolve the hostname via getaddrinfo
and reject any result whose IP is private, loopback, link-local,
reserved, multicast, or unspecified. Unresolvable hostnames are
rejected as well.

https://claude.ai/code/session_01T335ZvA825YhuzPctmYzUy
2026-05-27 12:33:36 +00:00
Claude 8b21487099 fix(deps): bump starlette to 1.0.1 for CVE-2026-48710
Starlette versions 0.8.3-1.0.0 are vulnerable to improper Host header
validation when reconstructing request.url, which can cause
request.url.path to differ from the actual requested path and bypass
path-based security checks. Patched in 1.0.1.

Bumps fastapi to 0.134.0 since 0.121.x pins starlette<0.50.0.

https://claude.ai/code/session_01P9NtqqFN9dVW1c5Uno6oRC
2026-05-26 19:22:31 +00:00
Claude 95b1a99f2a perf(roms): avoid hydrating full Rom rows for siblings on list endpoint
The paginated ROM list eager-loaded sibling_roms via selectinload, which
hydrated full Rom ORM instances (including heavy JSON metadata columns)
for every sibling even though only an existence/count check was needed
on the frontend. On large collections this dominated request latency.

Split sibling handling by response shape:
- SimpleRomSchema (list): siblings is now list[int]; populated per page
  by a single SELECT against the sibling_roms view projecting only
  (rom_id, sibling_rom_id) — no Rom row hydration.
- DetailedRomSchema (detail): keeps full SiblingRomSchema objects, with
  load_only on (id, name, fs_name_no_tags, fs_name_no_ext) so sibling
  rows stop dragging in JSON metadata.

Frontend usage already only consumes siblings.length on list views; the
detail-page VersionSwitcher continues to receive the richer schema.
2026-05-24 23:17:34 +00:00
Claude 8fcc16bad2 refactor(roms): replace denormalized columns with deferred column_property
Drop the migration and the multi_file / top_level_file_count columns on
roms; express both as deferred column_property correlated subqueries
against rom_files instead. The gallery list and detail queries opt in
via undefer, so they get the values computed in the same SELECT via
indexed subqueries (rom_id index already in place); other code paths
that don't read the flags pay nothing.

This keeps the gallery perf win (no rom_files load for cards) without
introducing schema state that has to stay in sync with rom_files at
write time.
2026-05-24 20:41:44 +00:00
Claude f434fde772 Refactor OTEL wrapping into shared helpers
Collapse the duplicated OTEL_SDK_DISABLED / opentelemetry-instrument
branches in run_startup, start_bin_gunicorn, start_bin_watcher, and
start_bin_sync_watcher into two small helpers:

- otel_prefix: emits the wrapper as NUL-delimited argv tokens (for
  direct process invocation).
- otel_prefix_str: emits the wrapper as a shell-string prefix (for
  embedding inside `watchfiles --target-type command`).

Each call site becomes a single command instead of a 2- or 3-way
branch with a fully duplicated command body. As a side effect, the
watcher functions now also gain the `command -v opentelemetry-instrument`
fallback that the gunicorn/startup paths added.
2026-05-24 18:21:40 +00:00
Claude 5adffeca71 perf(roms): skip rom_files load on gallery list endpoint
The gallery list endpoint was eager-loading every rom_file row for each
paginated ROM via selectinload, then re-joining each row back to its
parent rom for the is_top_level computation. For platforms with extracted
multi-file ROMs (Xbox 360 ~1394 files/ROM, Switch ~199 files/ROM), this
made /api/roms time out at 120s even with a rom_id index.

Cards never displayed individual files — only the has_simple_single_file
/ has_nested_single_file / has_multiple_files booleans that derive from
the file list. Denormalize the underlying state onto roms as multi_file
(folder-based vs single-file) and top_level_file_count, recompute the
booleans from those columns, drop the selectinload from filter_roms, and
move the files field from SimpleRomSchema to DetailedRomSchema so the
gallery payload no longer ships file rows.

Also drop the redundant joinedload(RomFile.rom) and switch the relation
to lazy="select" so subsequent file.rom accesses resolve from the
session identity map instead of re-JOINing the parent rom per file row.

ShowQRCode.vue's folder-based DS/3DS fallback now fetches the detailed
rom on demand, since SimpleRom no longer carries files.
2026-05-24 15:02:02 +00:00
Claude 26bdc11e13 refactor(filesystem): lazy-init launchbox + sync handlers, drop tolerate_missing_base
Apply the same lazy-factory pattern to FSLaunchboxHandler and FSSyncHandler
that ssh_sync_handler now uses. With both opt-in features deferred to
first-use, the tolerate_missing_base escape hatch on FSHandler is no longer
needed — every handler now fails loudly on mkdir failure, which is the
right behavior for the always-on core paths (assets, library, resources).

Touched call sites:
  - resources_handler._resolve_local_file_uri (launchbox)
  - sync_watcher.py, endpoints/device.py, tasks/manual/sync_folder_scan.py
    (fs_sync)

Net effect:
  - Default installs never poke /romm/launchbox or /romm/sync at startup.
  - Misconfigured opt-in users get a clear, actionable PermissionError at
    the call site instead of a silent warning followed by mystery failures.
  - tolerate_missing_base, its tests, and one stale log import are gone.
2026-05-24 14:59:03 +00:00
Claude 1890958ff2 refactor(sync): make SSH sync handler lazy-initialized
The module-level SSHSyncHandler() singleton ran filesystem side effects
(mkdir on SYNC_SSH_KEYS_PATH) at import time, which meant even default
installs with push-pull sync disabled would touch /romm/sync — and the
previous tolerate-and-warn fallback could leave users wondering why
sync silently does nothing.

Replace the eager singleton with a functools.cache'd factory. The
handler is now constructed on first use, so:

  - Default-install users (ENABLE_SYNC_PUSH_PULL=false, no manual sync
    triggered) never touch /romm/sync.
  - Users opting in get a clear, actionable RuntimeError pointing at
    the unwritable path and the env var to override, at the call site
    rather than buried in a startup stack trace.

Also document in env.template that enabling either sync mode requires
a writable volume at $ROMM_BASE_PATH/sync.
2026-05-24 14:49:53 +00:00
Claude 358e91d33c fix(sync): tolerate unwritable SSH keys directory at startup
The SSH sync handler is instantiated at module import time, so any
PermissionError on mkdir would crash the entire app rather than just
disabling the push-pull sync feature. This affected users whose /romm
mount didn't include a writable sync subdirectory (common on Unraid
and similar setups that mount specific subpaths).

Mirror the FSHandler pattern: log a warning and continue. Keys are
expected to be pre-mounted per the module docstring, and
_resolve_key_path already handles a missing directory gracefully.

Fixes #3419
2026-05-24 14:36:14 +00:00
Claude 4f3d75ae30 test: add property-based tests for SSRF URL validation and CHD hashing
Extend Hypothesis coverage to the security-critical URL validator
(globally-routable IPs allowed; private/loopback/link-local/multicast and
internal TLDs always blocked; non-http schemes rejected; never leaks
non-ValidationError exceptions) and to CHD v5 header SHA1 extraction
(valid headers, version/signature/truncation rejection, robustness on
arbitrary bytes). Adds light property coverage for username/email validators.

https://claude.ai/code/session_01GCTFmzuhvqNbSAfw3rGxmj
2026-05-21 17:48:33 +00:00
Claude 95958b6687 test: integrate Hypothesis property-based testing for backend
Add Hypothesis to the test dependency group with CI/dev profiles, and
introduce property-based tests for high-value pure functions: crc32_to_hex,
filesystem and LaunchBox filename sanitization, LaunchBox metadata parsers,
and ROM filename tag parsing.

https://claude.ai/code/session_01GCTFmzuhvqNbSAfw3rGxmj
2026-05-21 16:40:04 +00:00
Claude 227ae0fa41 feat: real-time user game activity monitoring
Add live tracking of which users are actively playing which games, covering
both browser-based EmulatorJS sessions and external devices via a heartbeat
endpoint. Surfaces activity on a dedicated /activity page and as an indicator
on individual game detail pages.

Backend
- New handler/activity_handler.py stores ephemeral "now playing" state in
  Redis with a 90s TTL keyed by (user_id, device_id), plus a per-ROM reverse
  index for fast lookup.
- New endpoints/activity.py exposes GET /api/activity, GET /api/activity/rom/{id},
  and POST /api/activity/heartbeat (for external devices).
- New endpoints/sockets/activity.py handles browser activity:start /
  activity:heartbeat / activity:stop events, broadcasts activity:update /
  activity:clear to all connected clients, and cleans up on socket disconnect.

Frontend
- Pinia activity store syncs via Socket.IO and provides per-ROM getters.
- ActivePlayers component on GameDetails shows who is currently playing a
  title, with avatar tooltips.
- New Activity view lists every live session with game cover, user, platform,
  and elapsed time.
- ActivityBtn in MainAppBar with a live-count badge routes to the page.
- EmulatorJS Player emits start/heartbeat/stop events and tears down the
  heartbeat interval on exit.

No DB migration required; all state is Redis-resident.

https://claude.ai/code/session_01WzWu5XEEYcAc3EJcfteiFd
2026-04-16 12:18:36 +00:00
Claude 1398aa09cf Add server-side ROM patching endpoint using RomPatcher.js
Introduces POST /api/roms/{id}/patch that applies patch files to ROM files
server-side, enabling third-party apps to request ROM patching using games
and patches already in the library without needing client-side JS support.

- backend/utils/patcher.js: Node.js helper that loads RomPatcher.js and
  applies a patch file to a ROM, writing the result to an output path
- backend/endpoints/roms/patch.py: FastAPI endpoint that looks up ROM and
  patch files by ID, invokes the Node.js patcher via subprocess, and
  streams the patched ROM back as a download
- Supports all 9 patch formats: IPS, UPS, BPS, PPF, RUP, APS, BDF, PMSR, VCDIFF
- Requires roms.read scope for authentication
- Temp files are cleaned up via Starlette BackgroundTask after response

https://claude.ai/code/session_01HS6ZvAiBjmLPVB3gGw8eEt
2026-04-06 22:22:40 +00:00