* fix(platforms): give platforms missing from IGDB a family
Platform families only came from the IGDB platform list, so platforms
IGDB doesn't list (win9x, win3x, Sega arcade boards, TI computers, ...)
landed in "Other" when grouping the platforms page by family.
Platforms IGDB doesn't list now either share the family and category of
an IGDB sibling (win9x -> Windows), or get an explicit family, which can
override the sibling's (Sega Model 2 -> Sega, Arcade). 108 of the 242
unlisted platforms get a family; the rest are engines, generic groups,
single-member makers or ambiguous.
Fixes#5178
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(platforms): satisfy mypy when applying a platform family
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* test(platforms): check every unlisted platform mapping resolves a family
Addresses Greptile review on #5187.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* perf(frontend): serve only the mdi icons the app uses
@mdi/font ships a CSS rule and four font formats for every icon. Generate
a trimmed stylesheet from the icons named in the source and alias the
package import to it.
- scripts/build-icons.ts writes src/plugins/materialdesignicons-trimmed.css
(woff2 only, LF line endings, no source map pointer). predev and prebuild
run it through build:icons, and CI checks the committed file is current.
- scripts/trim-mdi-fonts.ts builds the allow list from src/, Vuetify's own
icon set and the icons the backend names. Only a plain
.mdi-name::before rule can be dropped; anything unsure is kept.
- Custom notification icons the app does not draw fall back to the level
icon instead of rendering blank.
- An "All icons (mdi)" story under Primitives/RIcon shows every shipped
icon with search, plus the icons that are available but not shipped.
- Vitest keeps ?raw CSS imports instead of emptying them.
- build:tokens leaves tokens.css alone when only formatting differs.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* perf(frontend): trim the mdi icon css in a Vite plugin
Replace the generated, committed icon CSS with a transform plugin that
trims @mdi/font in memory, in dev, Storybook and build. A watcher
reloads dev when the source names a new icon. Drops build-icons.ts, the
alias, the CI staleness check and the checked-in CSS.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* PR feedback
* style(frontend): sort imports in notifications test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* perf(frontend): reduce the mdi icon trim to a build-only plugin
Drop the icon browser story, the notification glyph probe, the
build:tokens compare and the woff2-only font rewrite. The plugin now
only removes unused icon rules in production builds, so dev serves the
full set and needs no file watcher.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Georges-Antoine Assi <[email protected]>
* fix(streaming): keep foreign saves offered when the import-spec check fails
A broker that misses the 5 s import-spec deadline used to read as "imports
nothing": the Saves tab vanished and foreign picks were refused with
"Save is not a restorable archive". Keep the last good answer in Redis so
every worker can fall back on it, cache 404s for five minutes instead of
forever, answer a pick that can't be checked with a retryable 503, and
refetch the streaming config whenever the Stream page mounts.
* fix(streaming): code-review fixes for the import-spec fallback
Hold a fallback answer for 10 s so one claim's checks don't each wait out
the broker timeout, read the stored answer back through the broker
response parser, and warn about a pre-import broker once per worker
instead of at every cache expiry.
* refactor(streaming): simplify pass on the import-spec fallback
Make ImportSpecUnavailable the 503 itself so the save and state pickers
no longer convert it by hand, and store the broker's own response body
as the last answer instead of rebuilding its shape.
* chore(streaming): review-polish on the import-spec fallback
Trim the cache comments and the exception docstring, fit the 503 detail
within the line limit, and annotate the fresh-worker test dict for mypy.
* docs(streaming): humanizer pass on import-spec comments and test docstrings
* fix(streaming): tell lasting import-spec failures from passing ones
Only timeouts, connection errors, 5xx and 429 fall back to the broker's
last answer. A refused request (401, other 4xx) or a reply RomM can't read
caches no imports for 30 s and logs an error, so a pick gets a refusal
instead of an endless retry. A 404 from a broker that answered before
uses that answer, since it is likelier a restart behind a proxy than a
downgrade.
* fix(streaming): ask for a retry when a picked state can't be checked
An older save with the newest state resumes the pick only through an
import, so an unanswered import-spec check now returns the 503 instead of
launching from the older archive's own state.
* fix(streaming): share an in-flight config request
App load and the Stream page both fetch the config; two requests could land
out of order and leave the older answer in place. (Stability item 5.)
* fix(streaming): hold the resume picker until the fresh config lands
What the broker imports decides which saves and states are on offer, and the
app-load config can predate its answer. The panel shows a spinner until the
Stream page's own refetch settles, so a pick can't vanish under the player.
(Stability item 4.)
* fix(streaming): ask every platform's broker at once on /config
The checks ran on the default thread pool (cpu+4 threads), so a broker
serving more platforms than that cost one timeout per wave: 10.7 s for 14
platforms on a 6-CPU dev box with a silent broker. The Stream page's picker
now waits on /config, so the checks get a pool of their own.
* fix(streaming): address Greptile review on the import-spec fallback
- Play waits on the fresh config with the resume picker, so it can't send a
preselected state the player can't see yet.
- The streaming store gets a reset() for logout, and a config reply asked for
as the previous account is dropped instead of landing in the next one.
- The configFresh write after the refetch checks useIsAlive().
- Trimmed the require_import_spec docstring, the configFresh comment and the
/config pool comment to the house length.
- Declined: a worker-wide pool for the /config checks. Each request's threads
end within the broker timeout, and a shared bounded pool would bring back
timeout waves under concurrent loads.
* fix(streaming): share /config broker checks across requests
Addresses Greptile's re-raised finding on #5174 that each /config request
opened its own pool of up to 64 threads, so overlapping page loads during
a silent broker multiplied the worker's threads.
- The checks run in one worker-wide pool, and a check already in flight
for the same container and platform is joined rather than asked again,
so concurrent page loads hold no more threads than one does, with no
queued-timeout waves.
- Callers await the shared run through asyncio.shield, so a request that
disconnects doesn't cancel the check others are waiting on.
* fix(streaming): file a RetroArch exit's save raw and boot the newest save
A RetroArch exit holding one .srm and its states is no longer stored as a
.saves.zip: the .srm is filed bare under the core's emulator name with a
timestamped name, and each state goes through the state store. Exits with
more than one save, a memory card, or unlabelled members still keep the zip.
A launch with no pick now boots the newest save, raw or zip. A newer bare
.srm goes through the broker's save import when the broker accepts one,
and the launch screen defaults to the same save.
* fix(streaming): keep the exit zip when a raw save can't be filed safely
pr-ready step 2 (code review).
- Keep the zip for a core that isn't one plain folder name, a malformed
manifest kind, a member that fails to unpack, or a broker that doesn't
import saves.
- Fall back to the newest archive when the default raw save fails to import.
- File exit states oldest first; one unreadable stored state no longer
drops the rest.
- Rename locals that shadowed the archive module; fix a test return type
and a stale comment.
* refactor(streaming): simplify the default save comparison
pr-ready step 3 (simplify): default_save reuses _newest's ordering, and newestSave takes the first match in the newest-first list.
* docs(streaming): trim the raw-save docstrings
pr-ready step 4 (review-polish).
* fix(streaming): rank saves by their last write and read .ZIP as an archive
The web player writes into its existing save row, which only moves
updated_at, so ranking by created_at let a stream with no pick boot an
older streaming zip over newer web-player progress. The backend default
and the launch screen now rank by updated_at, and the picker dates rows
by it. Upper-case .ZIP saves are now read as archives on both sides, to
match the web player's case-insensitive guard.
* feat(states): hash states so duplicate checks skip reading files
Adds states.content_hash (migration 0149), filled on every state write:
streaming capture, the states upload and update endpoints, the scan and
the RetroArch sync push. The streaming duplicate check compares hashes
and falls back to reading the file for rows stored before this, which
are not backfilled.
* fix(streaming): leave an untouched default off the claim and skip missing saves
Review items 1 and 2: a default sent as a pick bypassed the zip fallback, and a save marked missing could become the newest.
* fix(streaming): code-review fixes for raw exit filing and state hashing
pr-ready step 2: keep the zip when a state can't be filed, don't treat a missing state file as a duplicate, fall back to the native archive when a defaulted save fails, hash off the event loop, count filed states as new.
* docs(streaming): review-polish, trim the newest-save comment
* fix(streaming): bind the background exit pull's states to the swapped disc
Decision 1: the exit save job filed states without the session's disc id, so on a multi-disc game its copy could lose the disc binding. The second copy is still dropped by the hash dedupe.
* fix(streaming): keep the exit zip when one of its states fails to store
Decision 2: a state that threw on store was logged and lost, since the zip holding it was dropped. The zip holds the same .srm, so booting it as the default loses nothing.
* fix(sync): keep updated_at when RetroArch sync flags a vanished save or state
* fix(streaming): record a legacy state's hash when dedupe reads its bytes
* fix(streaming): file a RetroArch exit's auto state raw instead of keeping the zip
The auto state now goes into the state history under its own capture
stamp, so an exit carrying one files its .srm raw like any other. It is
never hydrated, and a resume pick of it is refused, since the broker
would file it into its numbered working slot.
* fix(streaming): hydrate the newest state still on disk
A newer row sync flagged as gone from disk made hydrate push nothing.
It now skips such rows the way the save picks already do.
* docs(streaming): note the auto-state refusal in resolve_resume_state
* fix(streaming): resume a RetroArch auto state like any other state
The broker files any pushed state into its working slot, so .state.auto
reads as slot 0 like a bare .state. Hydrate and the resume pick now push
it instead of skipping or refusing it.
* fix(streaming): address Greptile review on raw RetroArch exits
- A default .srm the broker refuses at activate now boots the newest
native archive instead of failing the launch. Only a default save
that rode the import alone falls back; a hand pick still fails.
- Exit states the history hasn't seen are filed just under the next
state it already holds, so an older slot no longer heads the history
over the exit capture teardown filed first.
- A raw exit save is skipped only when it matches the newest usable
save, so returning to an older save records the play, and a match on
a row whose file is gone no longer deletes the new copy.
- Stream.test.ts clicks the rendered play button instead of casting
the view model to call onPlay.
- Trim the raw-exit docstrings to one sentence.
* fix(streaming): reserve a free version name for a raw RetroArch exit save
Addresses Greptile: the exit's .srm took a second-resolution tag without
checking whether another save held that name, so it could overwrite one.
Also compares updated_at naively in the mark-missing test, which failed on
PostgreSQL's UTC-aware column.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(streaming): file each RetroArch exit state on the disc it was saved on
Addresses Greptile: every state in the exit archive took the session's
final disc, so a hotkey slot saved before a swap resumed on the wrong one.
The session now keeps each swap with its time, and a session that ran more
than one disc files each state by its archive timestamp.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Georges-Antoine Assi <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* perf(tasks): batch RetroAchievements progress sync reads
Add get_roms_by_ra_ids and get_rom_users_by_rom_ids (single index-backed
IN queries, no eager joins) and fold the per-user feed to {ra_id: status}
so each user costs 2 reads plus writes instead of 2 reads per game.
* fix(tasks): skip joined eager-loads in RA sync batch reads
* style(tasks): trim batch-read comments to house style
* fix(tasks): sync every ROM sharing an ra_id, skip empty rom set early
* fix(tasks): raiseload instead of deprecated noload, fix CI failures
* fix(tasks): apply code-review fixes to RA progress sync batching
pr-ready step 2 (code-review xhigh --fix): load only id/ra_id for the
batched ROM read, flatten the sync loop, isolate per-user status sync
failures, log the ROM id, add a real-row test, and move the batched
RomUser getter next to its siblings.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(tasks): simplify RA progress sync batching and its tests
pr-ready step 3 (simplify): select (id, ra_id) columns instead of Rom
entities, drop early returns the getters already short-circuit, and
fold the mocked sync tests into shared helpers and one parametrized
status-transition test.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* style(tasks): trim batched RomUser getter comment
pr-ready step 4 (review-polish).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* docs(tasks): tighten unknown-award test docstring
pr-ready step 5 (humanizer).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* perf(tasks): batch RA status writes and make add_rom_user race-safe
set_rom_user_statuses writes one user's statuses in a single
transaction: it inserts missing RomUser rows, then runs one conditional
UPDATE per distinct status. The RETIRED/NEVER_PLAYING guard lives in the
WHERE clause, so a status the user sets mid-sync is never overwritten,
and the per-user cache bumps are queued once.
add_rom_user now inserts inside a savepoint and returns the existing row
when a concurrent writer won the unique (rom_id, user_id) insert, so the
sync no longer needs a catch-all around each user.
A rolled-back savepoint fires after_rollback, which drops every queued
user cache bump, so bumps are queued only after the savepoints finish.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(tasks): skip existing rom_user rows in the batch insert
Addresses Greptile review on #5179. The batch read existing rows before
inserting, so under MariaDB's REPEATABLE READ a row another writer
committed in between raised IntegrityError, and the fallback SELECT (or a
FOR UPDATE read, which MariaDB's snapshot isolation refuses) could not see
it. The insert now skips duplicates itself (ON CONFLICT DO NOTHING /
INSERT IGNORE) with no prior read. The race test commits the competing row
right before the INSERT instead of mocking the lookup. Also trims the
set_rom_user_statuses docstring to one sentence.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(tasks): apply code-review fixes to batched RA status writes
Batch set_rom_user_statuses to stay under PostgreSQL's bind parameter
cap, keep queued cache bumps across a rolled-back savepoint, fold the
single-caller insert helper into add_rom_user, and fix stale comments.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(tasks): apply simplify pass to batched RA status writes
Group by status before batching to drop a loop level, name the
user-owned statuses, split the dialect insert out of the execute call,
and share a status lookup across the write tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(tasks): type the dialect insert for mypy (review-polish)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Shreeniwas <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* feat(sync): sync RetroArch Cloud Sync saves through save slots
RetroArch saves were unslotted and overwritten in place, so they never
reached the slot-based sync the native clients use, and a bad upload
replaced the only copy.
A save path now serves the newest slotted version for its ROM, core and
extension, from any slot, under RetroArch's own file name. An upload adds
a version to that slot (or autosave), skips unchanged bytes, and prunes to
MAX_SAVES_PER_SLOT. A delete removes every save the path covers, so none
resurfaces on the next sync. Unslotted saves keep serving until the first
slotted upload; PSP bundles and states are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(sync): lock RetroArch save uploads and prune per core and extension
Addresses Greptile's review on #5170:
- Move the RetroArch save upload into sync_handler.store_save_version,
leaving the endpoint to validate and map the result to a status.
- Hold a Redis lock per (user, rom, core, extension) across the lookup,
write, scan and row update, so concurrent same-second uploads never file
two rows over one tagged file. A lock timeout answers 503.
- Prune only the uploaded path's core and extension in the slot, so
another core's or a .rtc's current save is never dropped.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(sync): pr-ready code-review fixes for RetroArch save slots
- Scope an extensionless prune to dotless file names, since every name
ends in an empty extension.
- Treat only NULL slots as unslotted in the manifest, matching the query
it replaced and the PSP bundle lookup.
- Share one helper for the slot versions a RetroArch path covers.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(sync): pr-ready simplify pass for RetroArch save slots
- Scope a slot prune by save ids, so the versions a RetroArch path covers
are decided once in Python instead of again in SQL.
- One newest-per-key helper for states and save versions, and
resolve_save_by_slot reuses it.
- A canonical_save_file_name helper next to the state one.
- The upload builds its tagged name once, checks its length in the
handler, and finds a same-second version in the list it already loaded.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* chore(sync): pr-ready review-polish pass
Drop a comment the canonical_save_file_name helper now says.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(sync): map RetroArch saves onto autosave lineages with unique version names
- Only RetroArch's .srm goes through slots, as the autosave versions of
its ROM and core. Companion files such as .rtc stay single unslotted
saves, so a slot never offers a clock file as the save.
- RetroArch reads, writes and deletes in autosave only, leaving named
slots alone.
- Every slot prune, web uploads included, is scoped to the uploaded save's
lineage (emulator and extension), so one core's uploads never drop
another's only save.
- The manifest lists the newest version under the client's spelling of
the game when it still names the ROM.
- reserve_version_name moves a taken tag on a second and claims it in
Redis, so same-second, concurrent or repeated-hour uploads from either
endpoint never share a file. The RetroArch lock and its 503 go.
- The manifest reads only the newest version per lineage, through one
window query.
- An upload is hashed before it is written, so an unchanged re-send
writes nothing.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* test(sync): seed the client-spelled save instead of uploading it
Uploading TEST_ROM.srm only resolves to the test_rom ROM under MariaDB's
case-insensitive collation, so the test 409'd on PostgreSQL. Seeding the
version directly still checks that the manifest keeps the client's
spelling of the game.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(sync): resolve RetroArch saves to ROMs case-insensitively on PostgreSQL
Cloud Sync matches a save's game name to a ROM's fs_name_no_ext. MariaDB's
collation ignores case, but PostgreSQL compared exactly, so TEST_ROM.srm
never reached the test_rom ROM. PostgreSQL now compares lowercased names,
served by a new expression index (0150), and the endpoint test uploads the
differently-cased name again.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(sync): address Greptile review on RetroArch save slots
- add_save derives the prune lineage from the tagged name it stores, so a
dotted upload such as game.rev.srm is counted against its own versions.
- Deleting a RetroArch .srm path also removes unslotted saves in that
lineage spelled in another case, which the manifest hid behind the
autosave head and PostgreSQL's exact match left behind.
- reserve_version_name looks up existing versions off the event loop.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* ci: bound apt network timeouts for Playwright's dependency install
`npx playwright install --with-deps` runs its own `apt-get update`, so the
`-o` flags from #5181 could not reach it, and a stalled Ubuntu mirror hung
the frontend test job until the 15 minute limit cancelled it. Move the
retry/timeout options into an apt.conf.d file written by a small composite
action, used by setup-backend and the frontend test job.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* ci: install Playwright's headless shell without apt
The apt.conf.d timeouts did not stop the hang: on a runner whose Azure
mirror is unreachable, apt falls back to archive.ubuntu.com and stalls
while downloading the package indexes, slowly enough that no timeout
fires. The ubuntu-latest image already ships Chrome's system libraries,
so drop --with-deps from the frontend and e2e jobs and keep apt out of
their path. Revert the bound-apt-timeouts action, and run the frontend
checks when frontend.yml itself changes.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* ci: install setup-backend's apt packages from a cache
The MariaDB connector .debs (plus each job's extra apt packages) were
fetched from an Ubuntu mirror on every backend job, so a stalled mirror
could hang any of them until the 15 minute limit, the same way it hung
Playwright's dependency install. Cache the downloaded .debs keyed on the
runner image OS and package list, saved from master only, and install
from them without apt-get update on a hit. A miss still downloads them
with the #5181 retry and timeout options.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* ci: install cached apt packages without re-downloading them
With package lists present, apt fetched the mirror's copy of each local
.deb instead of installing the file, so even the cached path reached the
mirror. Point apt's archives dir at the .deb directory and pass
--no-download. Also read ImageOS in a shell step: it is a runner shell
variable, not in the `env` context, so the cache key came out empty.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* ci: pin workflow runners to ubuntu-24.04
The apt .deb cache in setup-backend only holds packages built for one
Ubuntu release. Pin every job to ubuntu-24.04 so a GitHub move of
ubuntu-latest can't restore noble packages onto a newer image, and
hardcode the release in the cache key instead of reading ImageOS.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* fix(sync): keep RetroArch Cloud Sync from stalling the event loop
Basic auth ran its bcrypt check (~250ms) on the event loop for every
WebDAV request, and the manifest ran its queries there too, so parallel
RetroArch requests queued behind each other. Both now run in a thread.
Cloud Sync resolves every save and state to a ROM by fs_name_no_ext,
which no index led with; 0149 adds one so unmatched uploads fail fast.
The upload dialog now offers a core for saves as well as states, so a
manually uploaded save lands in the core folder RetroArch reads, and its
slot hint says only unslotted saves reach RetroArch.
Refs #5158
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* test(sync): cover manifest queries running off the event loop
Addresses Greptile review on #5171: trim the 0149 migration docstring to
its summary, annotate the new MockConnection constructor, and add
regression tests that build_manifest and list_manifest_paths query from a
worker thread.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(auth): mark Basic-auth users active off the event loop
pr-ready step 2 (code-review): run the user lookup, password check and
set_last_active in one worker-thread hop, and test that SaveDataTab offers
save cores and forwards the picked core on upload.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* test(v2): build SaveDataTab test saves and states from shared fixtures
pr-ready step 3 (simplify): replace the hand-cast UserSaveSchema and
UserStateSchema stubs with saveFixture/stateFixture plus toUserSave/toUserState.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* chore(sync): drop restating index comment and type the test spy
pr-ready step 4 (review-polish): remove the comment that only restated
idx_roms_fs_name_no_ext, and type the manifest test's get_saves spy so mypy
passes across the backend.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(auth): check the password grant's credentials off the event loop
The OAuth password grant ran bcrypt (~250ms) on the event loop, stalling
every other request the worker was serving.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(sync): run RetroArch WebDAV database work off the event loop
GET, PUT, DELETE and PROPFIND resolved ROMs, looked up and wrote save,
state and screenshot rows, and listed whole platforms synchronously inside
async handlers; the PSP bundle helpers did the same. Each now runs that
work in a worker thread.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(saves): keep another core's same-named save on upload
A slot-less upload matched an existing save by file name alone, so
uploading game.srm for a second core rewrote the first core's row to the
new folder and deleted its file. The lookup now only matches a save made
with the requested core. Clients that send no core still match any, so
device conflict detection is unchanged, preferring a save filed under no
core.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* feat(v2): file a save picked with a core out of the slots
RetroArch Cloud Sync only lists unslotted saves, so picking a core in the
upload dialog moves an untouched slot from autosave to "No slot", and
clearing the core moves it back. A slot the user picked is kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* revert(i18n): restore the original upload slot hint
Picking a core now files a save out of the slots, so the hint no longer
needs to explain RetroArch Cloud Sync.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
apt-get has no network timeout by default, so a stalled Ubuntu mirror
blocked a migrate-mariadb job in `apt-get update` until the 15 minute
job limit cancelled it. Retry up to 3 times with a 30s timeout instead.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Add RomMulus, RetroVault, Gameflow Deck and RomMix, drop SwitchRomM
(no push in over 180 days), and point DeckyRommSync at its new name,
Tender. Regroup the list into Mobile and TV, Desktop, Handhelds and
Steam Deck, Sync, and Integrations.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* fix(streaming): keep foreign saves offered when the import-spec check fails
A broker that misses the 5 s import-spec deadline used to read as "imports
nothing": the Saves tab vanished and foreign picks were refused with
"Save is not a restorable archive". Keep the last good answer in Redis so
every worker can fall back on it, cache 404s for five minutes instead of
forever, answer a pick that can't be checked with a retryable 503, and
refetch the streaming config whenever the Stream page mounts.
* fix(streaming): code-review fixes for the import-spec fallback
Hold a fallback answer for 10 s so one claim's checks don't each wait out
the broker timeout, read the stored answer back through the broker
response parser, and warn about a pre-import broker once per worker
instead of at every cache expiry.
* refactor(streaming): simplify pass on the import-spec fallback
Make ImportSpecUnavailable the 503 itself so the save and state pickers
no longer convert it by hand, and store the broker's own response body
as the last answer instead of rebuilding its shape.
* chore(streaming): review-polish on the import-spec fallback
Trim the cache comments and the exception docstring, fit the 503 detail
within the line limit, and annotate the fresh-worker test dict for mypy.
* docs(streaming): humanizer pass on import-spec comments and test docstrings
* fix(streaming): tell lasting import-spec failures from passing ones
Only timeouts, connection errors, 5xx and 429 fall back to the broker's
last answer. A refused request (401, other 4xx) or a reply RomM can't read
caches no imports for 30 s and logs an error, so a pick gets a refusal
instead of an endless retry. A 404 from a broker that answered before
uses that answer, since it is likelier a restart behind a proxy than a
downgrade.
* fix(streaming): ask for a retry when a picked state can't be checked
An older save with the newest state resumes the pick only through an
import, so an unanswered import-spec check now returns the 503 instead of
launching from the older archive's own state.
* fix(streaming): share an in-flight config request
App load and the Stream page both fetch the config; two requests could land
out of order and leave the older answer in place. (Stability item 5.)
* fix(streaming): hold the resume picker until the fresh config lands
What the broker imports decides which saves and states are on offer, and the
app-load config can predate its answer. The panel shows a spinner until the
Stream page's own refetch settles, so a pick can't vanish under the player.
(Stability item 4.)
* fix(streaming): ask every platform's broker at once on /config
The checks ran on the default thread pool (cpu+4 threads), so a broker
serving more platforms than that cost one timeout per wave: 10.7 s for 14
platforms on a 6-CPU dev box with a silent broker. The Stream page's picker
now waits on /config, so the checks get a pool of their own.
* fix(streaming): address Greptile review on the import-spec fallback
- Play waits on the fresh config with the resume picker, so it can't send a
preselected state the player can't see yet.
- The streaming store gets a reset() for logout, and a config reply asked for
as the previous account is dropped instead of landing in the next one.
- The configFresh write after the refetch checks useIsAlive().
- Trimmed the require_import_spec docstring, the configFresh comment and the
/config pool comment to the house length.
- Declined: a worker-wide pool for the /config checks. Each request's threads
end within the broker timeout, and a shared bounded pool would bring back
timeout waves under concurrent loads.
* fix(streaming): share /config broker checks across requests
Addresses Greptile's re-raised finding on #5174 that each /config request
opened its own pool of up to 64 threads, so overlapping page loads during
a silent broker multiplied the worker's threads.
- The checks run in one worker-wide pool, and a check already in flight
for the same container and platform is joined rather than asked again,
so concurrent page loads hold no more threads than one does, with no
queued-timeout waves.
- Callers await the shared run through asyncio.shield, so a request that
disconnects doesn't cancel the check others are waiting on.
* perf(tasks): batch RetroAchievements progress sync reads
Add get_roms_by_ra_ids and get_rom_users_by_rom_ids (single index-backed
IN queries, no eager joins) and fold the per-user feed to {ra_id: status}
so each user costs 2 reads plus writes instead of 2 reads per game.
* fix(tasks): skip joined eager-loads in RA sync batch reads
* style(tasks): trim batch-read comments to house style
* fix(tasks): sync every ROM sharing an ra_id, skip empty rom set early
* fix(tasks): raiseload instead of deprecated noload, fix CI failures
* fix(tasks): apply code-review fixes to RA progress sync batching
pr-ready step 2 (code-review xhigh --fix): load only id/ra_id for the
batched ROM read, flatten the sync loop, isolate per-user status sync
failures, log the ROM id, add a real-row test, and move the batched
RomUser getter next to its siblings.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(tasks): simplify RA progress sync batching and its tests
pr-ready step 3 (simplify): select (id, ra_id) columns instead of Rom
entities, drop early returns the getters already short-circuit, and
fold the mocked sync tests into shared helpers and one parametrized
status-transition test.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* style(tasks): trim batched RomUser getter comment
pr-ready step 4 (review-polish).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* docs(tasks): tighten unknown-award test docstring
pr-ready step 5 (humanizer).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Georges-Antoine Assi <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* feat(v2): show a top-bar indicator while a "Download as" conversion runs
A format download that needs a conversion only flashed a toast, then left the
user waiting with nothing on screen until the browser download began. Pending
conversions now live in a store, and a "Converting" pill in the top bar lists
them until each download starts. The pill shares its look with the scan
indicator through a new NavStatusPill.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* chore(v2): shorten converting indicator comments
Addresses Greptile review on #5157.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(v2): address code-review findings on the converting indicator
Give the Czech converting-downloads string the four plural forms the
cs_CZ rule picks from, use a count-agnostic Russian wording, wrap the
status pills in an element so their Transition animates, and tighten
the format conversions store and its tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(v2): simplify the nav status pills
Move the pill enter/leave transition into a NavStatusTransition wrapper
with scoped styles, show the conversion format with RTag, pass a null
scan progress for the indeterminate bar, and trim the format
conversions store to what downloadAs uses.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* chore(v2): review-polish pass on the converting indicator
Drop CSS comments that restate their rules and use the object props
form in the indicator's component mocks.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* perf(scan): replace superseded-folder prefix scans with ancestor set
Build one ancestor set from discovered fs paths, then flag each missing ROM with an O(1) set lookup. Same flagged set as the old any() prefix scan. Empty paths are skipped with an explicit guard; trailing slashes are normalized on both sides.
* fix(scan): rename loop var shadowing Rom-typed rom
* refactor(scan): simplify superseded lookup per review
* fix(scan): pr-ready code-review pass on superseded-folder check
Drop the no-op rstrip, inline the superseded flag, and make the test
build real Rom rows plus a shorter-prefix case (Saga vs Saga Deluxe)
that a naive startswith check fails.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(scan): pr-ready simplify pass, extract _ancestor_paths
Pull the ancestor walk into a pure helper with its own unit test, and
assert the superseded warnings as exact strings instead of parsing them.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Georges-Antoine Assi <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* perf(scan): batch restored-ROM hydration reads
Replace one get_rom_simple read per restored ROM with a single get_roms_simple_by_ids call. Same load shape, emit order, and None-skip preserved.
* test(scan): assert emitted ROM ids in visit order
* fix(scan): return per-ROM ids from SimpleRomSchema mock
* refactor(scan): extract restored-ROM emit helper, tighten order test
pr-ready step 2 (code-review --fix): scope the hydrated batch to a helper so it
is freed before the scan, and make the test catch visit-order and skip bugs.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* test(scan): reuse make_fs_rom and drop dead get_rom mock
pr-ready step 3 (simplify).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Georges-Antoine Assi <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* ci: check that PR titles follow Conventional Commits
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* ci: address Greptile review on the PR title check
Give the HLTB updater a Conventional Commits title so its PRs pass the
check, and strip line breaks from the title before echoing it so it
cannot start a workflow command.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The config/, thumbnails/ and system/ blobs lived at /romm/retroarch_sync,
which no documented mount covers, so they sat in the image's anonymous
/romm volume and were lost on `compose down`/`up`, `-V`, or a
docker rm/run upgrade. RetroArch can then treat the missing files as
deleted on the server and drop its local copies.
They now live at assets/users/<user>/retroarch/<category>/, beside the
user's saves and states. The old path never shipped in a release, so
no data migration is needed.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* chore(trunk): fix the scheduled trunk check-all failures
- Ignore hadolint DL3008 on the runtime stage apt-get, matching the
emulator-download stage
- Build alias-search test ROMs with tests.factories.make_rom instead of
the banned add_rom
- Drop a bandit B405 ignore that suppresses nothing
- Bump fast-uri to 3.1.8 for GHSA-hrr3-gc8f-f4qj
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(tests): type the alias-search rom helper kwargs as Any
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* chore(trunk): Upgrade trunk
* fix(trunk): keep pinact on 4.1.1 until the plugin passes --format
Addresses Greptile's review on #5137: pinact 5 rejects the plugin's
-format flag, so every workflow check reported a config error.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Georges-Antoine Assi <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The Trunk launcher verifies the CLI tarball against cli.sha256 in
.trunk/trunk.yaml, but only when those hashes are present. Add the
published 1.25.0 hashes so every launcher path (trunk-action, the
Copilot setup workflow, dev-setup.sh) refuses a tampered download.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* chore(e2e): Update playwright #4772, smaller focus
* chore(e2e) address greptile feedback
* chore(e2e): Add Lighthouse and Axe
* chore(e2e): replace the Lighthouse and axe projects with an axe check per page
- Move PAGES and the page-opening wait into support/pages.ts, so loads.spec.ts
and the new a11y.spec.ts share one list of every route.
- a11y.spec.ts runs axe on each page as the least privileged role allowed on
it, and login.spec.ts on the login page. Critical violations fail; every
violation is attached to the report as JSON. Serious ones (mostly color
contrast) still fire on several pages, so they stay report-only for now.
- Drop the Lighthouse suite and its dependency tree, the separate axe project,
the @smoke tag, the sitemap, and build:e2e.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(e2e): wait for skeletons to clear before checking a page
Addresses Greptile's review: openPage returned once the view mounted, so axe
(and the load check) ran while seven data-backed pages still showed
RSkeletonBlock placeholders. Wait until none are in the viewport; virtualized
grids keep them below the fold.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Georges-Antoine Assi <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* chore(e2e): Update playwright #4772, smaller focus
* chore(e2e) address greptile feedback
* chore(e2e): keep static analysis out of e2e/.output
Everything the suites write (reports, traces, saved sessions) lands in
e2e/.output. It was already gitignored and ESLint skipped it, but the
e2e tsconfig included it through ./**/*.ts, so any .ts file written
there was type-checked, and Trunk skipped it only through the
.gitignore default. Exclude it from the tsconfig and list it in
Trunk's ignore paths explicitly.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* chore(e2e): run with zero config locally and typecheck the suite in CI
- Every E2E_* variable now falls back to a default matching
seed_e2e_users.py, and the shell wins over e2e/.env, which is optional.
- With E2E_BASE_URL unset, the config starts (or reuses) `npm run dev`
through webServer; CI still sets the URL and serves the static build.
- Drop the migration table for removed variables, the unknown-key checks,
and the gitignore entries for suite versions that never shipped.
- Add e2e/tsconfig.json to the root project references, so
`npm run typecheck` (run by Frontend Checks) covers specs/ and setup/.
- Restore `test:e2e:ui`.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(e2e): make seedUiState win over the user's stored preferences
Preferences are now stored per user (#5098), and the app only adopts an
unscoped value when the user has no `user:<id>:` copy. Saved sessions
carry one, so seedUiState(page, "light") was silently ignored. Drop the
scoped copies before seeding, and assert the light theme in the spec
that relies on it.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* build(frontend): add build:preview, which copies frontend/assets into dist
Vite leaves frontend/assets out of the build and only the Docker image
copies it in, so a bare `vite preview` serves the SPA fallback for every
icon, logo, font and default cover. Give that copy one home: the e2e
workflow and the local production-build recipe both use the script. It
copies with fs.cpSync so it also runs from cmd.exe and PowerShell.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* ci(e2e): let the Playwright webServer serve CI again
The suite starts its own server once more, so the workflow no longer
needs its own build, serve, wait and teardown steps, nor the E2E_*
variables that now default to the seeded accounts. In CI the webServer
runs `build:preview` and `vite preview` as it did before this PR; locally
it runs (or reuses) `npm run dev`. The default URL is 127.0.0.1, which
the preview binds, rather than localhost, which can resolve to ::1.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(e2e): simplify the setup files
- auth.setup.ts signs each account in afresh every run, instead of
validating saved sessions in a separate browser context. Two sign-ins
cost about 3s.
- The hand-rolled login retry loop and its CI-specific attempt counts
give way to `retries: 2` on the setup project, and the timeout
arithmetic to `setup.slow()`.
- global-setup.ts folds its four check functions into one, and reads the
library total from the admin's account check instead of fetching it
twice.
- Drop isSessionValid(), login() and the ESLint exception that only the
old setup needed.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(e2e): drop the global-setup preflight
The suite already reports what it checked: a downed backend fails
sign-in through the app-error guard (`GET /api/heartbeat returned 502`),
and a wrong E2E_BASE_URL fails page.goto with the URL. The one case it
explained better, a rejected account, is now an assertion on the
`POST /api/login` status in auth.setup.ts. With no preflight, the suite
never calls the API directly, so its ESLint exception goes too.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* test(e2e): cover every route for both roles, and stop the theme leaking
loads.spec.ts:
- Key PAGES by RouteName from src/plugins/routeNames.ts, with a `skip`
reason for routes it can't open, so a new route fails the typecheck
until it is listed. That adds devices and conversion-settings, which
had drifted out.
- Load each page as every role allowed on it, not only as admin, so a
viewer-only 403 shows up.
- Drop the wait for in-flight requests, which timed out on pages that
load slowly on a large library.
Tags and folders now use route names (@page:rom, specs/rom/,
specs/user-profile/).
The app syncs UI settings to the account and reapplies them on sign-in,
so a theme a spec wrote leaked into later sessions: admin ended up on
"auto", and every "dark" spec rendered light. seedUiState now always
stores "auto", the config sets `colorScheme: "dark"`, and the light
spec uses emulateMedia. The saved sessions carry the seeded settings, so
specs no longer call seedUiState themselves.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(e2e): keep support/auth.ts to accounts and sign-in
- Navigation helpers (gotoHydrated, gotoFirstRom, gotoOwnProfile) move
to support/navigation.ts, with a gotoFirstPlatform that loads.spec.ts
and gotoFirstRom now share.
- openMoreMenu and menuLabels move into rom/actions-menu.spec.ts, their
only user.
- Role derives from ROLES, and accountFor's return type is inferred, so
the Account interface goes.
- watchAppErrors folds into the app-error guard, its last caller.
- output.ts exports the results and report paths instead of a
suiteOutput() that only ever took "specs".
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(e2e): don't count the page load against gotoHydrated's waits
The /api/permissions/me and login-form waits are armed before page.goto,
so their 5s local action timeout also covered the navigation, and a slow
page timed out before the request was sent. The test's own timeout now
bounds them.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(e2e): replace the environment module with plain reads
With every variable defaulted, the parser, its aggregated error and the
e2eEnv fixture guarded against mistakes that can barely happen, and
Playwright reports the rest itself.
- playwright.config.ts loads e2e/.env with process.loadEnvFile (the
shell still wins) and reads E2E_BASE_URL; support/auth.ts reads the
accounts into ACCOUNTS. Specs that need the URL use the baseURL
fixture.
- E2E_WORKERS goes: `--workers N` already does that.
- Drop the e2eEnv fixture, its ESLint import ban and the empty-pattern
exception it needed, and trim .env.example.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* build(e2e): typecheck the suite with tsconfig.node.json
e2e/tsconfig.json was a looser copy of the Node config: it lacked
noUncheckedIndexedAccess and exactOptionalPropertyTypes, so specs/ and
setup/ escaped flags the rest of the frontend, and the e2e support files
through playwright.config.ts, already obey. The suite runs in Node, so
it joins tsconfig.node.json, and typecheck:e2e goes with its config.
The specs needed no changes under the stricter flags.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(e2e): trim the ESLint config and the app-error guard's opt-out
- Drop the Node globals block: typescript-eslint turns no-undef off for
.ts files, so it changed nothing.
- Drop no-skipped-test's allowConditional: nothing in the suite skips.
- Fold the type-info, floating-promise and no-API blocks into one, and
move the e2e/.output ignore to the base config's generated-output list.
- Remove failOnAppErrors and E2EOptions: nothing opts out of the guard.
Every remaining rule still fires on a probe spec.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* docs(frontend): shorten the test:e2e command note
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* build(frontend): copy assets with cp -R in build:preview
Also puts a comma in the test:e2e command note.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(e2e): one set of timeouts, and treat empty variables as unset
- Local runs used 3-10s timeouts against CI's 10-45s, so slow pages on
a large dev library failed locally while CI passed. Both now use the
CI values; a debugger still turns them off.
- An empty E2E_BASE_URL left baseURL as "" while still starting the
server, and an empty account variable signed in as "". Both fall back
to their defaults now.
- Set the Desktop Chrome device once, point the slow-test comment at
test.slow(), and anchor the setup project's testMatch.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(e2e): apply code-review findings
pr-ready step 2 (code-review xhigh --fix):
- fall back to E2E_PASSWORD, which the seed script uses, for both accounts
- name the cause when the library has no platform or game to click
- read PWDEBUG=0/false as off, as Playwright does
- widen the API-call lint ban to any uncalled `.request` and its import
- compare the signed-in username case-insensitively
- only allow adminOnly with a URL, so the viewer 404 check always runs
- fix the README grep recipe and the recorded-spec location
- Object.hasOwn in seedUiState, ROLES in role.spec, shorter comments
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(e2e): apply simplify findings
pr-ready step 3 (simplify):
- one home for the login-form and signed-in-user locators
- derive ROLES and STORAGE_STATE from ACCOUNTS
- openMediaSubtab returns the panel; upload helpers take a scope
- menuLabels reads the panel openMoreMenu returns
- seed the UI state in login.spec's beforeEach
- one no-restricted-syntax entry for the API-call ban
- one port constant in playwright.config.ts
- drop .gitignore entries for paths nothing writes any more
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* docs(e2e): trim two long comments
pr-ready step 4 (review-polish).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* docs(e2e): plainer README opener and comments
pr-ready step 5 (humanizer).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor(e2e): role selectors, one first-game lookup, shared route scopes
- Select by role and label: the account menu's name ("Account menu for
<user>") replaces the user-name span, the Login button the login form's
class, the "Page not found" heading the 404 class, and the subtab rail's
tablist role the subtab button class.
- library.setup.ts clicks through to the first game once per run and saves
its URL; specs open it with gotoFirstRom() instead of clicking through
/platforms in every test. loads.spec keeps the click-through.
- Move the router's route scope table to routeNames.ts as ROUTE_SCOPES, so
loads.spec derives admin-only pages from it: a newly gated route gets the
viewer 404 check unless it is listed in VIEWER_OPENS.
- `vite preview` serves frontend/assets through a sirv middleware, as the
dev server and the Docker image do, so build:preview goes away.
- CI builds the frontend in the background while the backend is set up,
and the Playwright webServer only runs `vite preview` on that build.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(v2): menuitem role on button menu items, tabpanel role on Media panels
- RMenuItem sets role="menuitem" on its button form too, not only on links,
so every item in an RMenu is a menuitem. Stories give the items a
role="menu" parent.
- SubtabNav takes an optional idPrefix that gives each rail tab an id and
aria-controls. MediaTab uses it to make its sections tabpanels labelled
by their tab; the small-screen sheet variant has no tabs, so its sections
stay plain.
- The e2e specs read menu labels by the menuitem role and find a Media
panel by its tabpanel name.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Georges-Antoine Assi <[email protected]>
* feat: searchable alternative titles for ROMs
Add an editable "Alternative titles" field to manual metadata, folded
into the search titles alongside provider aliases. IGDB localization
titles now join the ROM's alternative names, and the canonical IGDB
name is kept as one when a regional title is displayed.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix: list hand-added titles in alternative_names, test the field
Address Greptile review on #5111: the response's alternative_names now
appends the hand-added titles to the first provider's, and the Alternative
titles field gets a component test.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* test: type the alternative titles field lookup
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix: code-review pass on alternative titles
Validate title lists from client-written metadata in one helper, list
hand-added titles first so the search text cap never drops them, and
let the titles combobox keep commas inside a title.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* refactor: simplify pass on alternative titles
Dedupe IGDB titles once in build_igdb_rom, inline the manual title
source, and reword the combobox comma comment.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* docs: review-polish pass on alternative titles
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* docs: humanizer pass on alternative titles
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix: hand-set alternative titles override, providers join
A non-empty manual list replaces every provider's titles, in the list
and in search; otherwise every provider's titles are joined. IGDB and
Hasheous store every title they know, the canonical name and regional
localizations included, and the ROM's displayed name is left out only
when listing them.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix: validate manual metadata on ROM update
A malformed raw_manual_metadata is rejected with a 422 instead of being
stored and failing every later response for that ROM.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix: list each alternative title once however it is spaced
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>