Commit Graph

9642 Commits

Author SHA1 Message Date
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
Georges-Antoine Assi
36c8f388f9 run fmt 2026-06-02 08:40:05 -04:00
Georges-Antoine Assi
f680dd6ca8 remove 2026-06-02 08:37:54 -04: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
Georges-Antoine Assi
895f495ee6 cleanup 2026-06-02 08:02:54 -04: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
Georges-Antoine Assi
698e930e74 Merge pull request #3463 from Spinnich/fix/igdb-regional-twin-platforms
Match IGDB regional-twin platforms in scans (#3462)
2026-06-01 17:39:16 -04:00
Georges-Antoine Assi
6bfa5c4b59 cleanup IDs 2026-06-01 17:19:57 -04:00
Spinnich
9ba4e12fa8 Match IGDB regional-twin platforms in scans (#3462)
IGDB catalogues a console and its regional twin as two separate
platforms (SNES/Super Famicom, NES/Famicom). RomM locked each IGDB
search to a single platform id, so a region-exclusive title catalogued
under only the twin — e.g. the Japan-only Super Famicom game
"Rudra no Hihou" (platform 58) scanned from an `snes` folder
(platform 19) — was filtered out before name matching ran and never
matched.

Include a platform's regional twin in the IGDB platform filter so both
are searched. A non-twin platform keeps the exact existing query
(`platforms=[19]`); a twin produces an OR group
(`(platforms=[19] | platforms=[58])`), leaving all other platforms and
recorded cassettes unchanged.

Written primarily by Claude Code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 13:40:42 +00:00
Georges-Antoine Assi
85789466d1 Merge pull request #3461 from Spinnich/fix/igdb-localized-name-match
fix(igdb): match ROMs by localized/alternative titles in scan
2026-05-31 13:05:58 -04:00
Georges-Antoine Assi
588c784d62 Merge pull request #3459 from rommapp/romm-3457
[ROMM-3457] Add tgcd and supergrafx to emulatorjs platform list
2026-05-31 09:26:51 -04:00
Georges-Antoine Assi
3ca45fafc1 [ROMM-3457] Add tgcd and supergrafx to emulatorjs platform list 2026-05-31 09:23:53 -04:00
Spinnich
7e08a43e12 fix(igdb): match ROMs by localized/alternative titles in scan
IGDB scans dropped games whose filename uses a localized (non-English)
title even when that title exists in IGDB's alternative_names. The
alternative_name wildcard search surfaced the correct game, but
_search_rom() rebuilt its name->game candidate dict using only the
primary English name, so the Jaro-Winkler re-check scored the localized
term below threshold and discarded the match (issue #3435).

Add _index_games_by_searchable_name(), which indexes each game by its
primary name plus alternative_names and game_localizations titles, and
use it for both candidate-building passes in _search_rom(). Primary
names keep precedence (lowest-igdb-id tiebreak); alternative/
localization titles fill in only names not already claimed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 02:31:46 +00:00
Georges-Antoine Assi
2137adc429 Merge pull request #3454 from rommapp/feat/emulatorjs-new-cores
feat(player): add genesis_plus_gx_wide, bsnes, freeintv, azahar EJS cores
4.9.0-alpha.8
2026-05-30 21:58:22 -04:00
Georges-Antoine Assi
9e5f49a6fc update jsdoc 2026-05-30 21:53:44 -04:00
Georges-Antoine Assi
96ac7ac5e8 only add new cores in nightly 2026-05-30 20:53:52 -04:00
Georges-Antoine Assi
ff2a71e114 Merge pull request #3455 from Spinnich/fix/ra-hash-missing-for-archives
fix(hashing): compute RA hash for archive ROMs on cartridge platforms
2026-05-30 20:38:30 -04:00
Georges-Antoine Assi
ad576909d3 changes from bot review 2026-05-30 20:26:29 -04:00
Georges-Antoine Assi
5144e78767 Merge branch 'master' into fix/ra-hash-missing-for-archives 2026-05-30 20:24:29 -04:00
Georges-Antoine Assi
f6e38859d7 Merge pull request #3453 from tmgast/fix/savesync-negotiate-thrash
Fix save-sync negotiate thrash and unblock content_hash recompute
2026-05-30 16:44:10 -04:00
Georges-Antoine Assi
368281a53f Merge pull request #3451 from Spinnich/fix/romnom-single-file-archive
fix(screenscraper): use internal filename as romnom for single-file archive games
2026-05-30 16:34:09 -04:00
Spinnich
1d9963ac63 fix(hashing): compute RA hash for archive ROMs on cartridge platforms
The archive branch of get_rom_files (introduced in #3412) was missing
the RAHasherService.calculate_hash call that exists in the non-archive
branch, causing all archive-format ROMs to produce an empty ra_hash
during scanning regardless of platform.

The RA hash call is now made for archive ROMs, mirroring the existing
non-archive behaviour. The RA_BUFFER_HASH_UNSUPPORTED skip logic in
RAHasherService already handles disc-based platforms (PSX, PS2, PSP,
Saturn, Dreamcast, etc.) so those continue to be excluded automatically.

Also improves handling of folder-based multi-file ROMs whose directories
contain compressed files. RAHasher cannot process archives via the /*
glob and fails with "Could not open file". The fix mirrors the existing
CHD folder logic: for cartridge platforms the largest archive in the
folder is passed directly to RAHasher for buffer hashing; for disc
platforms the call is skipped as buffer hashing is unsupported.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:55:42 +00:00
Georges-Antoine Assi
77de623834 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-30 04:34:15 -04:00
Georges-Antoine Assi
be15ab4513 feat(player): add genesis_plus_gx_wide, bsnes, freeintv, azahar EJS cores
- genesis_plus_gx_wide added alongside genesis_plus_gx for all Sega
  MD/MS/GG/CD platforms
- bsnes added alongside snes9x for SNES platforms
- freeintv added for Intellivision
- azahar added for Nintendo 3DS (3ds, new-nintendo-3ds) and marked as
  requiring threads

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 04:30:47 -04:00
nendo
ca036e78fe SaveSync: guard recompute Job.exists() in try; assert full job_id contract 2026-05-30 10:11:45 +09:00
Georges-Antoine Assi
69e2373453 cleanup archive member 2026-05-29 20:54:32 -04:00
nendo
771a8084b7 SaveSync: fix recompute backfill enqueue (colon job_id, bogus unique kwarg)
RQ 2.x Job.set_id rejects ':' in a job_id, so the bootstrap enqueue raised
ValueError that the broad except swallowed -- the content_hash recompute task
never ran, leaving legacy saves with stale/NULL hashes. Drop the colon, remove
the unsupported unique=True (would TypeError the worker once enqueued), and
replace the dead DuplicateJobError branch with a real Job.exists guard.
2026-05-30 07:39:52 +09:00
nendo
f1d44c3321 SaveSync: pair negotiate on (rom_id, slot), not tagged file_name
The server datetime-tags every slot upload's filename (archival spec), so a
slot accrues many rows and the stored file_name never equals the client's
untagged canonical name. Keying negotiate's server-save map on file_name meant
every client save missed -> perpetual "upload", and every tagged server row
went unmatched -> perpetual "download", with save rows growing unbounded.

Pair on (rom_id, slot), collapsing each slot to its newest row, so
compare_save_state actually runs and content hashes decide the action.

Tests: real upload->negotiate round-trip (lets _apply_datetime_tag run, client
reports the untagged name) and a 3-device convergence test; both fail against
the old file_name keying.
2026-05-30 07:39:51 +09:00
Spinnich
19d50e86b9 fix(screenscraper): use internal filename as romnom for single-file archives
When sending a hash lookup to ScreenScraper, romnom was always set to the
archive filename on disk (e.g. Mario.zip). For single-file archives, the hash
is computed from the internal file (e.g. mario.n64), so sending the archive
name sends slightly incorrect info to ss.fr during a KO scrape.

When archive_members has exactly one entry, romnom now uses that member's
name. Multi-file archives and non-archive files continue to use the filesystem
filename unchanged.

Closes #3444

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 20:34:40 +00:00
Georges-Antoine Assi
f58fdfe1e1 Merge pull request #3412 from Spinnich/feat/composite-hashing-archives
Composite hashing for compressed archives
4.9.0-alpha.7
2026-05-29 13:36:23 -04:00
Georges-Antoine Assi
10d731d823 cleanup 2026-05-29 11:58:53 -04:00
Georges-Antoine Assi
ae60d14f81 Merge branch 'master' into feat/composite-hashing-archives 2026-05-29 11:50:17 -04:00
Georges-Antoine Assi
29f90c027f Merge pull request #3448 from tmgast/fix-save-sync-hash-and-archival
Fix save-sync hash drift, archival save leak, and dedupe scoping
2026-05-29 11:47:52 -04:00
Georges-Antoine Assi
04a9c95df1 Merge pull request #3450 from rommapp/copilot/bugfix-error-500-fetching-roms
Fix HTTP 500 on ROM list when platform has ROMs with NULL names
2026-05-29 07:43:58 -04:00
copilot-swe-agent[bot]
54dc059e15 Fix 500 error when char_index contains None key from NULL ROM names
Co-authored-by: gantoine <3247106+gantoine@users.noreply.github.com>
2026-05-29 11:23:55 +00:00
copilot-swe-agent[bot]
10bcd12183 Initial plan 2026-05-29 11:18:58 +00:00
nendo
db0f714b4f SaveSync: use pathlib joins for asset content-hash paths
FSAssetsHandler.compute_content_hash and _compute_zip_hash were
building full paths via f"{self.base_path}/{file_path}". self.base_path
is already a pathlib.Path (resolved by FSHandler.__init__), so the
f-string forced it to str, hard-coded the separator, and re-parsed --
fine on Linux but a footgun if a caller ever sneaks a leading slash or
the path needs Path semantics elsewhere.

Switch both spots to self.base_path / file_path, which is what every
other FSHandler subclass in this module already does (e.g.
FSRomsHandler, FSResourcesHandler, FSSyncHandler all join Path objects
directly).
2026-05-29 17:40:56 +09:00
nendo
41c91fdd5b SaveSync: push null-slot exclusion into the SQL query
Three sync callsites (endpoints/sync.py, sync_watcher.py, and both
branches of tasks/sync_push_pull_task.py) ran get_saves(...) and then
discarded archival null-slot rows in a Python list comprehension. On
libraries with many archival/web-UI uploads that's a strict waste:
those rows are pulled from MariaDB, hydrated into Save model instances,
and then immediately filtered out.

Add a slot_not_null bool kwarg to DBSavesHandler.get_saves and apply
the filter in the SQL query. Update all four callsites to use it and
drop the Python-side comprehension. Default stays False so unrelated
callers keep the current behavior.
2026-05-29 17:40:18 +09:00
nendo
5bb10dacd1 SaveSync: paginate recompute task scan by primary key
get_all_saves() materialized every Save row across all users into a
single .all() list. On instances with very large libraries that's a
real RAM ceiling and pins every row for the lifetime of the recompute
run.

Replace it with get_saves_after_id(after_id, limit) and have the
recompute task drive keyset pagination in PAGE_SIZE-row chunks. SQLAlchemy
streaming via .execution_options(yield_per=...) is incompatible with the
per-call session lifetime that @begin_session enforces (the session
exits before the consumer iterates), so keyset paging from the caller is
the cleanest fit.

Behavior is unchanged: same row coverage, same idempotency, same
counters. Memory usage drops from O(all saves) to O(PAGE_SIZE).
2026-05-29 17:38:49 +09:00
nendo
ec50f75d77 SaveSync: dedupe bootstrap recompute job across API restarts
Pass a deterministic job_id and unique=True to low_prio_queue.enqueue
so a restart while a previous bootstrap recompute is still queued or
running no-ops the second enqueue. Without this, every API restart with
a NULL-hash row left would push another duplicate job onto the
low-priority queue; RQ would happily run both back-to-back, redoing the
same scans and content-hash reads against the filesystem.

RQ raises rq.exceptions.DuplicateJobError when unique=True hits an
existing job ID. Swallow it with a log line and let other enqueue
failures fall through to the generic exception path so they still get
logged with a traceback.
2026-05-29 17:35:32 +09:00
nendo
edb5d15420 Fix save-sync hash drift, archival save leak, and dedupe scoping
Cleanup pass on save-sync addressing three independent failure modes
that interact in production data: content_hash drift between client
and server, null-slot archival saves leaking into sync flows, and
content-hash dedupe collapsing legitimately-distinct slots.

Bug fixes
- compute_content_hash dispatched on zipfile.is_zipfile(relative_path),
  which silently returned False whenever the process's CWD wasn't
  ASSETS_BASE_PATH. Every zip save fell through to the raw-MD5 branch,
  persisting hashes that disagreed with clients computing the intended
  per-entry zip-hash. Resolve to a full path before the dispatch.
- _build_negotiate_plan, sync_push_pull_task, and sync_watcher all
  treated null-slot saves as sync-eligible. Null-slot saves represent
  web-UI / archival uploads; including them in negotiate plans matched
  them against device pushes by filename and overwrote archival data.
  Filter null-slot saves at all three call sites.
- get_save_by_content_hash matched on (rom_id, user_id, content_hash)
  only, so identical bytes uploaded to different slots collapsed into
  one record. Scope the lookup by slot when provided so clone-save-
  to-new-slot creates a distinct row per slot.
- get_save_by_filename matched on (rom_id, user_id, file_name) only.
  When two uploads to different slots happened in the same wall-clock
  second (the datetime tag is per-second), the second upload UPDATED
  the first record's slot instead of creating a distinct row. Scope
  the filename lookup by slot too.

One-shot recovery
- New recompute_save_content_hashes manual task walks every Save row,
  recomputes via the fixed dispatch, and updates rows whose values
  differ. Idempotent; safe to re-run.
- Backend startup runs a COUNT(content_hash IS NULL) query and, if
  any rows exist, enqueues the recompute task on the low-priority
  RQ queue. The API process moves on; the worker handles the
  recompute out-of-band. Subsequent restarts find zero NULL hashes
  and skip. Admins can also trigger the task manually.

Test infrastructure
- Added tests/_zipfile_shim.reload_zipfile() mirroring the pattern
  from utils/zip_cache.py for the same zipfile-inflate64 + CPython
  3.13.5 incompatibility. Test fixtures that build ZIPs call it
  immediately before opening the archive.
2026-05-29 17:00:01 +09:00
Georges-Antoine Assi
b39feaf7c6 Merge pull request #3446 from rommapp/copilot/feature-allow-selecting-miximage-version
feat: add `miximage_v2` media type to explicitly select SS.fr mixrbv2
4.9.0-alpha.6
2026-05-28 20:13:50 -04:00
Georges-Antoine Assi
8f08769670 run fmt 2026-05-28 20:05:24 -04:00
copilot-swe-agent[bot]
d29ed39a6a Add miximage_v2 media type mapping to SS.fr mixrbv2
Co-authored-by: gantoine <3247106+gantoine@users.noreply.github.com>
2026-05-28 20:15:40 +00:00
copilot-swe-agent[bot]
71cb3e4f04 Initial plan 2026-05-28 20:06:17 +00:00
Georges-Antoine Assi
0ee32ef4b3 add generated files 2026-05-28 09:43:47 -04:00
Georges-Antoine Assi
207d0dc4c6 feat(hashing): persist per-member hashes on archive RomFile
Internal members of multi-file archives (zip/tar/7z/rar) are now hashed
individually (crc/md5/sha1) and stored in a new `archive_members` JSON
column on the archive's RomFile, alongside the existing composite hash
used for hash-database matching. Only the archive itself is surfaced as
a RomFile so full_path keeps pointing at a file that exists on disk,
which is the constraint that previously forced us to choose between
composite-only or broken downloads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:41:04 -04:00
Georges-Antoine Assi
9111f70d0a refactor(filesystem): merge archive_7zip.py into archives.py
Consolidate all archive readers (zip/tar/7z/rar) and 7z-internal helpers
into a single utils/archives.py module to keep the archive surface area
in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:10:01 -04:00