Sixty seconds cut off extraction of mid-sized disc images, so their scan
fell back to hashing the archive itself.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
When extracting the largest member of a .7z failed partway, the bytes already
streamed stayed in the file and ROM-level hashers, and the raw-archive
fallback was appended on top, giving a hash that matched neither. The hashers
are now restored to their pre-extraction state before the fallback runs.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* chore(deps): bump vue-router to 5.3.1 and vue to 3.5.43
vue-router 5 needs vue ^3.5.34. The runtime API RomM uses is unchanged:
typecheck, the unit suite and the production build pass without code
changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* fix(v2): stop URL query writes from dropping history entries
Query-only router.replace calls resolved against currentRoute, which is
still the page being left while a navigation waits on async guards (the
view-transition capture, the ROM fetch in /rom/:id's beforeEnter). A
write landing in that window cancelled a pending push, so the click was
swallowed, or, on Back/Forward, replaceState'd the entry the browser had
already moved to with the old page's URL, deleting it from history.
patchQuery now binds each write to the path it was made on, waits for an
in-flight navigation to settle (tracked by installQueryNavigationGuard,
installed in AppLayout) and drops the write if the navigation left that
page. The gallery filter sync, the platform, collection, admin, library
management and notification tabs, and the notes selection now write
through it, and the gallery's debounced write is cancelled on unmount.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* style(v2): replace em-dashes in comments of files this PR touches
trunk check reports every no-emdash-in-comment hit in a modified file,
not only on changed lines. Comment text only.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* fix(v2): rewrite a ?subtab= the details tab can't show
GameDetails swaps currentRom before a rom-to-rom navigation commits, so a
tab that resets its subtab for the new game writes into the pending
navigation and that write is dropped. The URL then kept a subtab the new
game doesn't have. useSubtabQuery now rewrites an invalid ?subtab= to the
subtab on screen on mount and after every path change, which also fixes
stale deep links.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* refactor(v2): key the details ROM by the route's :rom param
GameDetails read a single currentRom slot that the route guards filled
before the navigation committed, so the page being left briefly
rendered the next game under its own URL, and anything reacting to that
wrote into a navigation still in flight.
stores/roms now keeps a bounded, id-keyed cache of DetailedRoms.
setCurrentRom and update() feed it, so the shared /rom/:rom guard and
every SimpleRom write keep it current, and v1 keeps reading currentRom
unchanged. v2 reads through useRouteRom(), which looks the record up by
the committed route's :rom param: a guard can prefetch the next game
without the current page seeing it. The dialogs, useRomSync, the scan
and pending-asset refreshes, FilesTab and the player seeds all go
through the cache or the route id. useSubtabQuery's rewrite is narrowed
to stale links, the case it still covers.
Also replaces em-dashes in comments of the files this touches, which
trunk check flags per modified file.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* style(v2): sort useRomScanRefresh imports for trunk's prettier
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* fix(v2): keep the shown game cached through a burst of rom hops (code-review)
Each superseded rom-to-rom navigation still cached its game, so ten
quick hops could evict the record the committed page renders and blank
it until the last hop landed. Re-cache the shown game before the next.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* refactor(v2): simplify the query-write and ROM cache plumbing (simplify)
- routeQuery: track the in-flight navigation as a single marker and
schedule one flush per router instead of a waiter queue; add
replaceQuery for whole-query writes and route Logs through it, so no
v2 query writer bypasses the guard.
- Tab, subtab and gallery-filter writers use syncQueryParam, which only
writes keys that changed.
- useSubtabQuery shares one owned-subtab helper and drops the route.path
trigger the id-keyed cache made redundant.
- The ROM cache holds 3 records (nothing reads it to skip a fetch) with a
single-step eviction; GameDetails re-caches the shown game via
useRouteRom; FilesTab uses refetchRom's result; Stream parses :rom
with romIdFromRoute.
- Trim comments to the two-line rule.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* style(v2): trim the roms.update merge comment (review-polish)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* style(v2): tidy comment grammar left by the em-dash pass (humanizer)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* fix(v2): address review findings on query writes and the ROM cache
- syncQueryParam compares against the queued write, so a control
switched back before the first write lands still gets written.
- Only the tracked navigation (or an untracked duplicate) settles the
query guard, so an older navigation aborting after a newer one
started can't release writes into it.
- roms.reset() (run on logout) clears the detailed ROM cache, and
deleting a game drops its record once the page has moved off it.
- Shared makeDetailedRom fixture beside makeRom; the CollectionsIndex
mock carries a route path.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* fix(v2): settle query writes only for the tracked redirect chain
- A duplicate navigation settles the query guard only when it began as
the tracked navigation (a guard redirect back onto this page, followed
through redirect chains via redirectedFrom). An unrelated duplicate no
longer releases a held write while the tracked navigation is pending;
that navigation settles itself when vue-router cancels it.
- DeleteRomDialog logs a failed post-delete redirect instead of
reporting the delete as failed, and keeps the record of a game the
page still shows so an aborted redirect can't blank it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
* style(v2): drop the routeQuery header paragraph the guard docs already cover
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CB3a8xbmnmuMeddGnRgVse
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* perf(backend): pipeline multi-command Redis writes
- upload_chunk records a chunk with SADD, EXPIRE and SCARD in one
transaction instead of three round trips, so a failure between them
can no longer leave the chunk set without a TTL.
- bind_to_login_session does its SADD and EXPIRE in one transaction for
the same reason.
- close_login_sessions reads and deletes a session's socket set in one
transaction, so a socket binding in between is not dropped unseen.
- Logout deletes the session and removes it from the user's set in one
pipeline.
- complete_chunked_upload reads the chunk set once instead of SCARD then
SMEMBERS; cancel and invalidate_filter_values_cache delete their keys
in one call.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T2WxB8t3Shv4TZmHFdSMHv
* fix(backend): refuse a socket bind that lands after its session's revocation
- bind_to_login_session checks the login session still exists in the
same transaction as its SADD. Revocation deletes the session before it
reads the socket set, so a bind that finds it gone removes itself and
authenticate treats the socket as anonymous.
- complete_chunked_upload keeps its SCARD fast path and reads the
members only to name the missing chunks.
- cancel_chunked_upload removes the temporary files before touching
Redis.
- Tests check each pipelined write executes as one MULTI/EXEC.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T2WxB8t3Shv4TZmHFdSMHv
* fix(backend): close an upload session before removing its files on cancel
A chunk PUT that passes the session check after the temporary directory
is gone fails with a 500; deleting the session first turns it into a 404.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T2WxB8t3Shv4TZmHFdSMHv
* fix(backend): answer a chunk write cut short by a cancel with 404
A PUT that passed the session check before the cancel removed its
directory fails writing; it now re-reads the session and returns the
same 404 as a PUT that arrives after the cancel, instead of a 500.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T2WxB8t3Shv4TZmHFdSMHv
* chore(backend): remove a stray Redis snapshot
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T2WxB8t3Shv4TZmHFdSMHv
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Recompute the folder category for rows a Quick scan reuses, so files
categorized before the anchor fix are corrected without a Complete scan,
and annotate the new tests' parameters.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Hold the pdf.js app until its container is laid out, so a viewer that
loads while its subtab is hidden no longer sizes to zero. Build the
MediaTab ROM through a typed makeDetailedRom factory, and replace the
em-dashes in PdfViewer's comments.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Retry the exit once when a broker boots the wrong core and the first exit
fails: the broker refuses every activate while that game runs, and the
released claim leaves nothing behind to stop it.
- Stamp the launch's core tier on the session and report it through the
status poll, so a tab that missed launch-ready still warns about an
untested or broken core, once.
- Translate the core-untested and core-blocked warnings in every locale.
- endpoints/device_auth.py -> endpoints/device/auth.py (and its response module)
- Device and install handler tests mirror the new package layout
- The install router is included by the device router, so main.py mounts one
/devices router plus the /auth/device one
- GET /api/devices/online moves to the install routes and 404s while
DEVICE_INSTALL_ENABLED is off, like the rest of them
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Replace the inline bytes-to-text decodes with redis_handler.as_text
- Add utils.auth.token_device_id for a request's token-bound device
- Look up a group's member tokens in one query and close them in one pass
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The visually hidden native input is absolutely positioned, but the label
had no positioning, so its containing block was the nearest positioned
ancestor. In the setup wizard that is the backdrop-filtered card, which
sits outside the platform list's scroll box. Clicking a checkbox far down
the list (NES in the expanded Nintendo group) focused an input laid out
thousands of pixels below the card, and the browser scrolled the card's
overflow:hidden body to reveal it, blanking the whole step.
Positioning the label contains the input, so focus never scrolls anything
beyond the list itself.
Fixes#4223
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Share one watched-transaction retry loop and live/gone split in the install handler
- Emit independent socket events concurrently
- Drop the dialog's redundant close watcher and repeated current-rom checks
- Reuse the add_device_token fixture and shared header fixtures in tests
- Fix mypy errors in the socket and handler tests
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
category_for_path_parts matched every segment of the library-relative
path and returned the first hit in enum order, so inner dump folders
(content/Game, content/CMCmn/demo), the rom folder itself, or even the
platform folder could set a file's category, and a bare `game` segment
outranked an `update` or `dlc` folder above it.
The scanner now passes the path relative to the ROM root, and only the
top folder there decides the category; deeper files inherit it.
Fixes#4581
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
vue3-pdf-app wraps a single global pdf.js application and looks up its
containers by fixed element ids. The Manual and Walkthrough subtabs stay
mounted via v-show, so with a PDF in each, both PdfViewers ran against the
same app: the walkthrough rendered into the manual's container (or not at
all), and pages laid out while hidden came up blank.
Only the last shown PDF subtab now mounts a PdfViewer, and only once it has
been shown.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
POST /api/saves and POST /api/states joined the `emulator` query parameter
straight into the asset path, so a value like `a/b` created nested
directories and landed in file_path. Both endpoints now return 400 unless
the name survives sanitize_filename unchanged, and cap it at the column
length so an over-long value is a 422 instead of a database error.
Fixes#4839
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
ScreenScraper's dedicated "PC Win9X" system (137) holds very few games;
Win9x titles are filed under the generic "PC Windows" system (138), which
acts as a fall-through like MAME does for arcade. Searching 137 meant
Win9x games only matched when scraped by ID.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(hasheous): match multi-file roms when one file is unknown
Hasheous' /Lookup/ByHash returns 404 for a batch as soon as any one SHA-1
in it is unknown, even when the rest match. A multi-disc CHD set with an
.m3u, a readme or an off-set disc beside it therefore never matched, e.g.
Final Fantasy VII (USA), whose three MAMERedump CHD hashes match on their
own.
- Leave .m3u playlists out of the lookup.
- When a multi-file batch misses, retry each file on its own, largest
first, and take the first match.
- Read the TheGamesDB id from "TheGamesDb", the spelling Hasheous returns,
so tgdb_id is no longer always dropped.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TZeLXgA178Biwj7eUReck4
* fix(hasheous): require per-file matches to agree on one game
The per-file retry took the first file Hasheous knew, so a folder holding
an unknown game next to a known file from another game (a demo disc, a
bonus CD) could take on that other game's ids. Every file is now asked
and the match stands only when all known files name the same game;
conflicting answers match nothing and leave the lookup inconclusive.
A failed per-file request still ends the lookup as inconclusive, since
agreement can't be vouched for without every answer. The largest-first
ordering is gone, as nothing depends on the order any more.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TZeLXgA178Biwj7eUReck4
* test(hasheous): expect the per-file retry after a missed batch
test_lookup_rom_sends_all_top_level_file_hashes mocks a miss for two
files, which now also asks Hasheous about each file alone.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TZeLXgA178Biwj7eUReck4
* test(hasheous): build lookup fixtures as real RomFiles
The lookup tests cast MagicMocks to RomFile, which would hand lookup_rom
attributes a real RomFile lacks. They now build RomFiles, so file_extension
and lookup_hashes come from the model itself.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TZeLXgA178Biwj7eUReck4
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
The EmulatorJS player defaulted to the first file of a multi-file rom, so a
BIN/CUE (or GDI, CloneCD, Alcohol) set booted either the sheet or a single
track alone and never reached the game. Default such sets to the whole-rom
download, which already ships every track with a generated playlist of the
sheets. A remembered per-rom choice still wins. The v1 player reuses the
same helper.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Reject installs of a rom missing from the filesystem
- Keep a request created during a device discard indexed
- Hold a strong reference to the device socket keepalive task
- Never raise from emit_permissions_changed after a committed change
- Map only a missing install request to 404, not any KeyError
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
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
* perf(scan): download a rom's media files concurrently
download_rom_resources fetched the cover, manual, each screenshot, each
provider media type and every RetroAchievements badge one after another,
so a single ROM never used more than one of the ScreenScraper account's
request slots.
They now download together. ScreenScraper media still goes through its
existing thread and rate limiters, and badges share a new cap of 8 in
flight across all scans. The path columns and changed provider media
dicts are written back in one update.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DAQNWjH7teMsMkFMuUrLTf
* style(tests): sort imports with the Trunk isort config
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DAQNWjH7teMsMkFMuUrLTf
* fix(scan): code-review fixes for concurrent media downloads
- Run provider media dicts one after another, since they share paths
- Await every download before raising the first failure (gather_all)
- Fetch each duplicate media or badge path once (store_ra_badges)
- Reuse store_ra_badges in update_rom
- Share the in-flight test helper and trim comments
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DAQNWjH7teMsMkFMuUrLTf
* refactor(scan): simplify pass on concurrent media downloads
- Move gather_all to utils/concurrency and unpack its results directly
- Let write_file_streamed create the badge directory instead of every badge
- Share the in-flight test helper through tests/concurrency_stubs
- Stub downloads with AsyncMock through one fixture, drop a redundant test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DAQNWjH7teMsMkFMuUrLTf
* fix(scan): save the paths that landed when a provider download fails
Cover, manual and screenshot paths are written before a provider media or
badge failure is re-raised, so a failure there no longer loses them.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DAQNWjH7teMsMkFMuUrLTf
* fix(scan): save each download group's results independently
Paths, provider media and badges each keep their own outcome, and every
change that landed is written before the first failure is re-raised. The
test builds a real Rom instead of a namespace.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DAQNWjH7teMsMkFMuUrLTf
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* chore(deps): bump redis to 8.1 and fakeredis to 2.38
redis-py 6.2.0 -> 8.1.0 and fakeredis 2.30.1 -> 2.38.0. RQ 2.12
(redis!=6,>=3.5) and fakeredis (redis>=4.3) both accept redis 8.
fakeredis 2.38 types FakeServer's version argument as a tuple, so pass
(7,) instead of 7. Behaviour is unchanged since fakeredis normalises an
int to a one-element tuple.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014wzuPg8vDEjsQ8xqvZgFwx
* chore(deps): drop types-redis in favour of redis-py's own types
types-redis only covers redis 4.6 and shadowed the inline types that
redis 8 ships, so mypy was checking calls against a stale API. Removing
it also drops its transitive stub packages (types-cffi, types-pyopenssl,
types-setuptools), which nothing else uses.
redis-py's types differ from the stubs in three ways:
- Redis, AsyncRedis and Pipeline are not generic, so drop the [str] and
[bytes] parameters.
- Responses are typed bytes | str regardless of decode_responses, so cast
at the few call sites that rely on the decoded (or raw) type.
- watch, unwatch, multi, aclose and execute_command are unannotated, so
add redis to untyped_calls_exclude, as is done for rq and apprise.
Also replace setex, deprecated in redis 8, with set(..., ex=...).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014wzuPg8vDEjsQ8xqvZgFwx
* fix(device-auth): replace deprecated GETSET with SET ... GET
code-review step. GETSET is deprecated since Redis 6.2 in favour of
SET with GET. Using it with ex= also sets the poll key's TTL in the same
command, instead of a separate EXPIRE that a crash could skip.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014wzuPg8vDEjsQ8xqvZgFwx
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>