13814 Commits
Author SHA1 Message Date
Georges-Antoine AssiandClaude ca2a36ca54 Move corporate sponsors above friends in README (#5219)
Rename the "Supported By" section to "Corporate Sponsors" and place it
before "Our Friends".


Claude-Session: https://claude.ai/code/session_011ekeVc4SX98ituuWg5t9SF

Co-authored-by: Claude <[email protected]>
2026-10-11 08:17:06 -04:00
Georges-Antoine AssiandClaude Opus 5.5 68f71259f7 fix(config): migrate retired filesystem folder keys instead of exiting (#5211)
* fix(config): migrate retired filesystem folder keys instead of exiting

5.2 wrote filesystem.roms_folder and filesystem.firmware_folder into
config.yml on every settings save, so instances that never chose a layout
crash-looped on 5.3.1. Appliance installs (TrueNAS, Unraid, CasaOS) have
no way to edit the file.

Translate the keys at load time into the filesystem.structure templates
that reproduce the layout 5.2 resolved them to, using the same disk check
5.2 used to choose between `<folder>/{platform}` and `{platform}/<folder>`.
A declared structure template still wins. The migrated config is written
back when config.yml is writable; otherwise it applies in memory and logs
the replacement once.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(config): refuse folder names a template can't express before migrating

Addresses Greptile review on #5211: a retired folder name containing braces
produced an invalid template after config.yml had already been rewritten.
The migrated templates are now parsed first and startup exits with the
reason, leaving the file untouched. Also trims a test docstring.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(config): code-review fixes for the retired folder key migration

Keep an undetectable layout in memory, accept a '.' folder, honour a
differently cased declared key, stop retrying a failed write, and share
the layout detection with check_library_layout.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(config): simplify pass over the retired folder key migration

Build the templates in one pass with a parser per key, reuse get_nested
and the library fixture, skip the layout string on the in-memory path,
and memoize a detected layout across get_config() calls.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(config): review-polish, drop a docstring that restates the test name

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(config): migrate retired folder keys at startup and write config.yml atomically

Loading the config only translates the retired keys in memory now;
startup writes the migration once, before any worker runs, so processes
no longer race to rewrite config.yml.

config.yml is written to a sibling temp file and renamed over the
original, keeping a symlink and the file mode. When the rename fails
(a config.yml bind-mounted on its own), the in-place write puts the
previous content back if it fails, so a full disk no longer empties it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(config): drop the atomic config write and flatten the folder key migration

The temp-file rename, symlink/mode handling and in-place restore are a
separate concern from migrating the retired keys, so config.yml goes back
to the plain write. The translation validates its two templates directly
instead of through a table of parsers.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
5.4.0
2026-10-10 10:28:56 -04:00
88b3ebe49d refactor(streaming): let the backend pick the resume picker's states (#5210)
* refactor(streaming): let the backend pick the resume picker's states

The player decided which of a ROM's states the resume picker offers from
a core rule the config shipped, and so repeated the rules the claim
checks a pick against.

The player now asks `GET /api/streaming/states/{rom_id}` which states
load natively and which the broker can import, so the rules live only in
the backend. A native state whose filename carries no slot number is
offered only when the broker can import it, since it can't resume as is.
The player asks again only when the ROM's states, the emulator or the
import kinds change, keeps its last answer if a refresh fails, and holds
Play until the first answer for the ROM arrives, so a quick click can't
skip the state about to be preselected.

`StreamingContainerSchema.state_core` and `StateCoreSchema` are removed,
and the config no longer asks each broker for its core.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(streaming): address Greptile review on the state picks

Refetch the picks when a state is renamed, since the filename decides whether it resumes as it is, and fold the new docstrings to one sentence.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Zach Clendenen <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
5.4.0-beta.1
2026-10-09 17:24:36 -04:00
26d83db6ea fix(streaming): keep a stream capture Cloud Sync serves from its slot (#5209)
* fix(streaming): keep a stream capture Cloud Sync serves from its slot

A stream capture filed in a core's folder is the state Cloud Sync serves
for its slot while it is the newest there. Pruning the stream's state
history past its limit would delete it, and a device holding it as that
slot would lose the slot.

The prune now skips a capture a slot serves, until a newer state takes
that slot or the capture's file is gone from disk. History can go over
the limit by at most one capture per slot.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* docs(streaming): fold the prune_state_history docstring to one sentence

Greptile review on #5209.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Zach Clendenen <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-09 16:14:37 -04:00
5270c5d31e fix(sync): file a screenshot sent ahead of its state past a stream capture (#5208)
* fix(sync): file a screenshot sent ahead of its state past a stream capture

A Cloud Sync client sends a slot's screenshot before the state it belongs
to. With a stream capture serving the slot, the screenshot was filed under
the capture's name and wrote over the capture's own thumbnail.

The screenshot now goes to the state the slot's next upload writes to: the
slot's newest non-capture state, or the canonical name when the upload
will create one. A copy of the capture's own screenshot sent back is left
alone.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* refactor(sync): address Greptile review on the stream capture checks

Move the capture copy checks for a state and a screenshot upload out of the endpoint into the sync handler, and fold the core_library_name, prune_state_history and Stream.vue comments to two lines.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Zach Clendenen <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-09 16:06:48 -04:00
Zach ClendenenandClaude Fable 5.1 261178cb25 fix(streaming): share RetroArch stream saves and states with Cloud Sync (#5197)
A streamed RetroArch exit's save landed in "No slot", and the stream's
states were filed under a different emulator than the one Cloud Sync and
the other RetroArch clients use, so neither side could see the other's.

A streamed exit's save is now stored as an `autosave` version, like the
other clients' autosaves. Stream saves and states are filed under the
core's RetroArch folder, which the broker reports as `library_name`; a
broker that doesn't is filed under the core id. A stream resumes from any
state in the booted core's folder, including other clients' states and
ones filed under an alias of the folder, and the picker's core rule gains
the folder's names so it offers them too.

Cloud Sync shares the slot with stream captures but never writes over or
deletes one: an upload goes to the slot's newest non-capture state or
creates one, sending a capture's own bytes back is a no-op, and a delete
removes the slot's other states and leaves captures in place.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-09 15:50:39 -04:00
1f6b4d11ca feat(permissions): gate emulator streaming and in-browser play behind group permissions (#5185)
* feat(permissions): gate emulator streaming behind a group permission

Add a streaming permission entity. Read shows streaming and allows joining
a session; write allows starting one. Users without read get a disabled
streaming config, so every streaming surface hides.

Migration 0152 grants read and write to every existing group, so nobody
loses access on upgrade. Groups created afterwards start without it.
Owner-scoped session controls (save, exit, release) stay ungated so a
revoked user can still leave a running game without losing saves.

* fix(streaming): pr-ready code-review fixes for the streaming grant

Own-only read no longer joins other users' sessions, the config refetches on permissions:changed, the store skips the joinable request when streaming is off, and tests clean up the groups they create.

* refactor(streaming): pr-ready simplify pass on the streaming grant

Collapse the streaming permission helpers onto assert_can/can_access, return an empty joinable list instead of 403, skip the permission resolve when streaming is off, and add a stream.start action key so the UI hides Stream (but keeps Join) for users without the write grant.

* docs(streaming): pr-ready review-polish pass on the streaming grant

Trim the 0152 migration docstring to a single summary line, matching 0150 and 0151.

* docs(streaming): pr-ready humanizer pass on the streaming grant

Plainer wording for the stream.start action key comment and the Permission Groups paragraph, which now says an own-only read shows streaming without the join.

* feat(permissions): gate in-browser emulation behind a group permission

Adds an emulation entity next to streaming. Read plays in the browser
(EmulatorJS, Ruffle, js-dos, PICO-8, EasyRPG) and joins netplay rooms,
write hosts them. Migration 0152 now backfills both entities onto every
existing group.

* fix(streaming): address Greptile review on PR 5185

- Pin the claimed container in the stream player, so a permission refresh
  that turns streaming off mid-game keeps save, load and disc swap until
  the session ends.
- Empty the Join list when a refreshed config turns streaming off, and
  discard any joinable request still in flight.
- Update the installPermissionsHydration doc to name every layout that
  calls it.

* fix(migrations): renumber play permissions migration to 0153 after master's 0152

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* chore(permissions): drop redundant PermEntity comments

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(streaming): drop the live config refresh on permission changes

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]>
2026-10-09 14:47:11 -04:00
Georges-Antoine AssiandClaude Opus 5.5 0a54359ab4 refactor(config): parse every comma-separated env var with one helper (#5207)
* fix(oidc): match any of the comma-separated groups in OIDC_ROLE_*

OIDC_ROLE_ADMIN/EDITOR/VIEWER were read as one raw string and checked
for membership in the roles claim, so a value like
"romm-admin,platform-admins" never matched and the login was rejected
with 403. Parse each variable into a set of stripped, non-empty values
and grant the role when it intersects the claim. A single-valued string
claim is matched whole rather than by substring.

Fixes #4302

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(oidc): reject a string role claim that only contains a mapped group

Addresses Greptile's review on #5203: the string-claim case only covered a
match, which the old substring check would also have passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(config): parse every comma-separated env var with one helper

Add _get_env_list and build _get_env_set on it, with an optional fallback,
so ROMM_CORS_ALLOWED_ORIGINS and DEVICE_INSTALL_EXCLUDED_PLATFORM_SLUGS stop
hand-rolling the split/strip/drop-empty parse.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 14:05:04 -04:00
Georges-Antoine AssiandClaude Opus 5.5 507f589124 fix(v2): keep a save uploaded for a core in autosave (#5205)
RetroArch Cloud Sync now serves a ROM and core's newest autosave .srm
and hides an unslotted .srm behind it, so the upload dialog moving a
save picked with a core to "No slot" left it invisible to RetroArch once
RetroArch had synced that game. The dialog keeps the autosave default,
and the slot hint says RetroArch reads autosave.

Refs #5158

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 14:02:10 -04:00
Georges-Antoine AssiandClaude Opus 5.5 de62fa3815 fix(oidc): match any of the comma-separated groups in OIDC_ROLE_* (#5203)
* fix(oidc): match any of the comma-separated groups in OIDC_ROLE_*

OIDC_ROLE_ADMIN/EDITOR/VIEWER were read as one raw string and checked
for membership in the roles claim, so a value like
"romm-admin,platform-admins" never matched and the login was rejected
with 403. Parse each variable into a set of stripped, non-empty values
and grant the role when it intersects the claim. A single-valued string
claim is matched whole rather than by substring.

Fixes #4302

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(oidc): reject a string role claim that only contains a mapped group

Addresses Greptile's review on #5203: the string-claim case only covered a
match, which the old substring check would also have passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 13:56:00 -04:00
Georges-Antoine AssiandClaude Opus 5.5 92b6421e65 fix(saves): make a deduplicated slot upload the slot head (#5204)
* fix(saves): make a deduplicated slot upload the slot head

A slotted upload whose bytes match an older version in the slot returned
that version without touching its updated_at, so the slot's newest save
stayed one the device had moved away from. A client re-downloading the
slot head then restored the wrong save. Bump the matched version before
pruning so it becomes the head and survives the cap. A retry of the
current head is left alone so other devices don't see a spurious update.

Fixes #5200

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(saves): promote a deduplicated save in the handler, past same-second heads

Addresses Greptile review on #5204: the head promotion moves out of the
endpoint into db_save_handler.promote_to_slot_head, and the promoted
timestamp is at least a second past the current head's, so a head written
in the same second can no longer win the id tiebreak.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 13:53:25 -04:00
Georges-Antoine AssiandClaude Opus 5.5 989bfb8553 fix(scan): reassociate a moved ROM by title id when its hashes changed (#5202)
Reassociation matched a moved file to its missing entry by CRC, MD5 and
SHA1, and only consulted the binary title id when the hashes were absent.
Moving a GameCube iso into its own folder next to a dumpinfo.txt makes it
a folder ROM whose hash spans both files, so the hashes no longer matched,
the title id was never tried, and the scan created a new entry that showed
up under Recently added.

Fall back to the title id when the hashes match no missing entry. Hashes
still win when they match, and ambiguous hashes still return no match
rather than letting the title id break the tie.

Fixes #5046

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 13:24:23 -04:00
Georges-Antoine AssiandClaude Opus 5.5 f89f6ef185 fix(security): check collection ownership in the ROM filters (#5201)
* fix(security): check collection ownership in the ROM filters

The collection endpoints refuse a private collection to anyone but its
owner, but the ROM listing endpoints accepted the same id as a
collection_id or smart_collection_id filter without checking it. Any
user (or a kiosk visitor) could list, randomly pick from and bulk
download another user's private collections and Favourites, and re-share
one through a public smart collection scoped to it.

The membership filters now match only collections that are public or
owned by the caller. A collection_id inside smart collection criteria is
checked against the smart collection's owner, both when the criteria are
saved and when they are evaluated.

Reported by Takaya Sato (dagadjno).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(security): clear stale smart collection caches across owners

Addresses Greptile's review on #5201. Smart collections cache their
membership on the row, so one scoped to another user's private
collection kept serving the members it cached before the ownership
check. Migration 0152 empties those caches on upgrade, and sharing or
unsharing a collection now refreshes the smart collections scoped to
it. Also covers a second user listing a public smart collection scoped
to its owner's private collection.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(collections): harden smart collection scope refreshes (code-review)

Allow re-saving a smart collection whose stored scope was since lost,
reject non-object criteria in from_stored_criteria, guard the scoped
refresh, and refresh smart collections when a collection's ROMs are
replaced or the collection is deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(collections): share the refresh wrapper and trim scope tests (simplify)

Move the scoped smart collection refresh next to the existing wrapper,
match the scope on the raw criteria before refreshing, batch the
migration update, and fold the duplicate unshare test into helpers.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs(migrations): describe the cleared smart scopes by current behavior (review-polish)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs(roms): state the collection filter comment plainly (humanizer)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-09 13:20:26 -04:00
09d8360337 perf(frontend): serve the small cover to 1x screens (#5184)
* minimal version

* test(v2): cover that the srcset serves the small cover at low DPR

Add unit tests for the cover srcset (small at 1x, large at 2x, webp, URL escaping, no srcset for an override or alt art) and a Chromium story that checks the browser picks the small cover at a device pixel ratio of 1 or less. Shorten the DPR comment in useCoverArt.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* perf(v2): limit the cover srcset to small slots

The small cover is a 0.2x or 0.4x downscale of the large one, so it can be
narrower than wide slots such as the details page or player. Gate the
srcset behind a `responsive` prop that grid cards (xs to md) and the small
Home and settings thumbnails opt into.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(v2): address Greptile review on the GameCover srcset story

Fill the story covers from colorCoverArt tokens instead of hex literals,
expect the candidate that matches the screen's density so the story passes
on high-DPR displays, and move the component srcset checks from Vitest into
Storybook play() functions, adding a story for the default without srcset.

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]>
2026-10-08 20:12:29 -04:00
Georges-Antoine AssiandClaude Opus 5.5 be6ad905b1 fix(sync): serve the newest on-disk RetroArch state in a slot (#5193)
* fix(sync): resolve RetroArch paths across emulators sharing a core folder

Several RomM emulator ids map to one RetroArch folder (mednafen_psx_hw,
beetle_psx_hw and the raw "Beetle PSX HW" all sync through
saves/Beetle PSX HW/). The manifest built paths from whichever id an
asset carried, but GET parsed the folder back to the canonical id and
matched it exactly, so an asset filed under another alias was advertised
and then 404'd. Uploads also started a second autosave history beside
the alias's.

The manifest now lists the newest asset per folder, and GET, PUT and
DELETE resolve a path across every alias. Rewrites of an existing
unslotted save or state stay under that row's own emulator.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): prefer on-disk alias saves and share newest-per-key grouping

pr-ready step 2 (code-review --fix).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(sync): one alias helper and a lineage computed once

pr-ready step 3 (simplify).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): type the alias fixture's asset kind

pr-ready step 4 (review-polish).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): isolate the asset dir per RetroArch sync test

The alias tests write different bytes to the same asset paths, and
user and ROM ids repeat across xdist worker databases, so parallel
runs raced on those files.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): serve the newest on-disk RetroArch state in a slot

Missing states are dropped before slot grouping for the manifest and reads, so a newer missing state (or alias) no longer hides an older one still on disk. A PUT still matches the missing row and revives it in place.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): fall back past a RetroArch file that vanished unflagged

Addresses Greptile's review on #5193: a state whose file was deleted before
`missing_from_fs` was set still won its slot. The manifest now rebuilds when
hashing flags an asset, and GET flags a missing file and resolves again, so an
older asset at the path is listed and served.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 19:12:12 -04:00
Georges-Antoine AssiandClaude Opus 5.5 57ebac8efe perf(sync): resolve RetroArch core aliases in one query (#5192)
* fix(sync): resolve RetroArch paths across emulators sharing a core folder

Several RomM emulator ids map to one RetroArch folder (mednafen_psx_hw,
beetle_psx_hw and the raw "Beetle PSX HW" all sync through
saves/Beetle PSX HW/). The manifest built paths from whichever id an
asset carried, but GET parsed the folder back to the canonical id and
matched it exactly, so an asset filed under another alias was advertised
and then 404'd. Uploads also started a second autosave history beside
the alias's.

The manifest now lists the newest asset per folder, and GET, PUT and
DELETE resolve a path across every alias. Rewrites of an existing
unslotted save or state stay under that row's own emulator.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): prefer on-disk alias saves and share newest-per-key grouping

pr-ready step 2 (code-review --fix).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(sync): one alias helper and a lineage computed once

pr-ready step 3 (simplify).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): type the alias fixture's asset kind

pr-ready step 4 (review-polish).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): isolate the asset dir per RetroArch sync test

The alias tests write different bytes to the same asset paths, and
user and ROM ids repeat across xdist worker databases, so parallel
runs raced on those files.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* perf(sync): resolve RetroArch core aliases in one query

The saves handler now filters on a collection of lineages and looks up a file name across several folders, so the RetroArch alias resolvers issue one query each instead of one per alias.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* perf(sync): filter a RetroArch delete to autosave and unslotted saves in SQL

Addresses Greptile's review on #5192: saves_at_path loaded every slot of
the matching lineages and dropped the named slots in Python.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 18:54:27 -04:00
Georges-Antoine AssiandClaude Opus 5.5 79d1feba6c fix(sync): resolve RetroArch paths across emulators sharing a core folder (#5191)
* fix(sync): resolve RetroArch paths across emulators sharing a core folder

Several RomM emulator ids map to one RetroArch folder (mednafen_psx_hw,
beetle_psx_hw and the raw "Beetle PSX HW" all sync through
saves/Beetle PSX HW/). The manifest built paths from whichever id an
asset carried, but GET parsed the folder back to the canonical id and
matched it exactly, so an asset filed under another alias was advertised
and then 404'd. Uploads also started a second autosave history beside
the alias's.

The manifest now lists the newest asset per folder, and GET, PUT and
DELETE resolve a path across every alias. Rewrites of an existing
unslotted save or state stay under that row's own emulator.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): prefer on-disk alias saves and share newest-per-key grouping

pr-ready step 2 (code-review --fix).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(sync): one alias helper and a lineage computed once

pr-ready step 3 (simplify).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): type the alias fixture's asset kind

pr-ready step 4 (review-polish).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): isolate the asset dir per RetroArch sync test

The alias tests write different bytes to the same asset paths, and
user and ROM ids repeat across xdist worker databases, so parallel
runs raced on those files.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): delete every aliased state in a slot and prefer one on disk

Addresses Greptile review on #5191: a slot's state is picked live-first
across aliases, so a newer missing alias no longer hides an older one on
disk, and DELETE removes every state in the slot so an older alias can't
resurface at the same path.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 17:00:52 -04:00
Georges-Antoine AssiandClaude Opus 5.5 e232a52656 fix(docker): boot when the container runs as a non-default UID (#5189)
* fix(docker): boot when the container runs as a non-default UID

Unraid runs the container as 99:100, which can't write to the image's
/romm (owned by 1000, mode 755), only to the mounted subfolders. The
PSP pending dir at /romm/cache/retroarch_sync was created at import
time, so every gunicorn worker failed to boot.

- Buffer pending PSP save files under the user's assets folder
  (assets/users/<user>/retroarch/psp_pending/), beside the other
  RetroArch Cloud Sync files and outside the client-reachable blob
  categories.
- Move the zip and rom-converto download caches to /tmp/romm/cache,
  and point nginx's internal /cache/ alias there.
- Put gunicorn's control socket in /tmp/gunicorn instead of
  $HOME/.gunicorn, since HOME is / for that UID.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): honor ROMM_TMP_PATH for caches, explain an unwritable sync folder

- The download caches live under $ROMM_TMP_PATH/cache when it's set,
  so installs with a small Docker disk (Unraid's docker.img) can point
  them at a share. The entrypoint exports the same path for nginx's
  /cache/ alias.
- With ENABLE_SYNC_FOLDER_WATCHER on and /romm/sync not writable, /init
  warns and skips the watcher instead of dying on mkdir under errexit.
- The compose example lists the /romm/sync mount the sync features need.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(sync): cover reading back and clearing buffered PSP files

Addresses Greptile's review on #5189.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): apply code-review findings to PSP pending buffer

Remove the emptied psp_pending folder after a merge, define the sync
folder path once in init, update the architecture doc's PSP and blob
locations, and drop a stale .gitignore entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(sync): apply simplify findings to PSP pending cleanup

Clear the buffered folder with remove_directory, let the startup check
own creating the sync folder, and reword the test fixture docstring.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): cap buffered PSP files and drop them on delete

Buffered files for an unresolved save folder now count against the
bundle limits, so a folder can't fill the assets share and then fail
every merge once it resolves. A DELETE also removes the buffered copy,
so a deleted file isn't merged back when the folder resolves.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): check the sync folder for push-pull sync too

ENABLE_SYNC_PUSH_PULL keeps SSH keys and device saves under /romm/sync
as well, so an unwritable folder now disables it with the watcher and
warns at boot instead of failing when the task runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(docker): strip whitespace from env vars like the backend does

The backend strips every variable it reads, while the init script and
nginx templates used raw values, so a padded ROMM_TMP_PATH sent nginx's
/cache/ alias somewhere the backend never writes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* chore(config): drop the CACHE_BASE_PATH comment

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs(docker): say which backend config strips env whitespace

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* revert(docker): drop the entrypoint env whitespace trimming

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(sync): apply pr-ready code-review findings

Gate push-pull on its SSH keys path rather than a writable sync folder,
accept the same truthy values as the backend, and clear only the merged
PSP pending files so another worker's buffered file survives.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor: apply pr-ready simplify findings

Share one truthy pattern across init's flag checks (the watcher gates
now accept the same values as the backend), disable gunicorn's unused
control socket instead of placing it, derive the converts redirect from
its own cache path, and factor the unknown PSP folder in tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs(docker): apply pr-ready humanizer pass to the sync mount note

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(fs): create handler base folders on first write, skip leftover temp files

FSHandler no longer mkdirs its base path when constructed, so a module-level
handler rooted at an unmounted or unwritable folder can't take down boot;
every write path already creates its parent folders. Blob listings skip
.romm_tmp_ files a crashed write left behind, so they are never merged into
a PSP bundle or advertised in the sync manifest. The prefix now lives in one
TEMP_FILE_PREFIX constant.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 16:48:50 -04:00
Georges-Antoine AssiandClaude Opus 5.5 1060afc1af fix(devices): count a device that polls claim as online (#5190)
A client that drains installs by polling claim (Argosy) never holds the
/devices socket, so the install dialog showed it offline while awake.
Each claim now sets a presence key that lapses after the same 90 seconds
as a socket's.

Also add TZ to env.template, since save version names and log
timestamps follow the container's time zone.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 13:31:20 -04:00
Georges-Antoine AssiandClaude Opus 5.5 e722e96d91 docs(readme): add Vita RomM to community projects (#5188)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-08 09:36:59 -04:00
Georges-Antoine AssiandClaude Opus 5.5 ec10e5b930 fix(platforms): give platforms missing from IGDB a family (#5187)
* 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]>
2026-10-08 09:36:33 -04:00
4e8fec6845 perf(frontend): serve only the mdi icons the app uses (#5118)
* 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]>
5.4.0-alpha.2
2026-10-08 07:25:40 -04:00
38dfb208f0 fix(streaming): file RetroArch streaming exits as raw saves (#5174)
* 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]>
2026-10-07 20:44:14 -04:00
a1466d9233 perf(tasks): batch RetroAchievements status writes, make add_rom_user race-safe (#5179)
* 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]>
2026-10-07 18:31:34 -04:00
Georges-Antoine AssiandClaude Opus 5.5 3b8eabdb77 feat(sync): sync RetroArch Cloud Sync saves through save slots (#5170)
* 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]>
2026-10-07 18:30:57 -04:00
Georges-Antoine AssiandClaude Opus 5.5 a874e4d012 ci: keep CI jobs off Ubuntu apt mirrors (#5182)
* 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]>
2026-10-07 16:56:47 -04:00
Georges-Antoine AssiandClaude Opus 5.5 75e8a9efae fix(sync): keep RetroArch Cloud Sync from stalling the event loop (#5171)
* 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]>
2026-10-07 14:37:36 -04:00
Georges-Antoine AssiandClaude Opus 5.5 34e994d61e ci: add network timeouts and retries to setup-backend apt calls (#5181)
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]>
2026-10-07 14:15:44 -04:00
Georges-Antoine AssiandClaude Opus 5.5 d3e07a3d60 docs(readme): refresh community projects list (#5176)
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]>
2026-10-07 13:22:42 -04:00
Zach Clendenen 77f064561a fix(streaming): keep a slow broker from hiding save imports (#5172)
* 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.
2026-10-07 13:01:51 -04:00
361e18cc56 perf(tasks): batch RetroAchievements progress sync reads (#5144)
* 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]>
2026-10-07 12:43:39 -04:00
Georges-Antoine AssiandClaude Opus 5.5 e053234d74 feat(v2): show a top-bar indicator while a "Download as" conversion runs (#5157)
* 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]>
2026-10-07 12:43:28 -04:00
48e9b40102 perf(scan): replace superseded-folder prefix scans with ancestor set (#5146)
* 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]>
2026-10-07 12:36:43 -04:00
3f57d11d0b perf(scan): batch restored-ROM hydration reads (#5145)
* 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]>
2026-10-07 12:18:13 -04:00
Georges-Antoine AssiandClaude Opus 5.5 dbf5ae94c3 ci: check that PR titles follow Conventional Commits (#5143)
* 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]>
2026-10-07 11:52:49 -04:00
dependabot[bot] 9d216401b3 chore(deps): bump postcss-selector-parser in /frontend (#5163) 2026-10-06 15:43:48 -04:00
Sam Dornan 7377b3543a fix(frontend): guard network-quiesced dispatch after DOM teardown (#5167) 2026-10-06 15:35:30 -04:00
dependabot[bot] 9dcba51122 chore(deps): bump mako from 1.3.12 to 1.4.2 (#5166) 2026-10-06 14:31:43 -04:00
dependabot[bot] a022415d90 chore(deps): bump multidict from 6.7.1 to 6.9.1 (#5165) 2026-10-06 14:31:19 -04:00
dependabot[bot] c9348e19d0 chore(deps): bump source-map-js from 1.2.1 to 1.2.2 in /frontend (#5164) 2026-10-06 14:27:36 -04:00
Sam Dornan 208bfa7889 test(frontend): run Storybook stories and a11y checks in Chromium via addon-vitest (#5153) 2026-10-06 12:59:12 -04:00
Sam Dornan dcfb4ec86a chore(v2): switch Storybook docgen to vue-component-meta and enable autodocs (#5152) 2026-10-06 11:17:47 -04:00
Georges-Antoine AssiandClaude Opus 5.5 3f13d1e752 fix(sync): store RetroArch Cloud Sync blobs under the assets volume (#5155)
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]>
5.4.0-alpha.1
2026-10-05 19:31:21 -04:00
Sam Dornan 55498ec427 fix(v2): name dialogs and select listboxes; axe-check story overlays (#5151) 2026-10-05 15:39:16 -04:00
Sam Dornan 8983716c62 chore(v2): let the theme toolbar drive every lib story (#5150) 2026-10-05 15:10:44 -04:00
Sam Dornan b576d9e8b6 chore(eslint): adopt @vue/eslint-config-typescript and lint with type information (#5148) 2026-10-05 15:10:12 -04:00
Sam Dornan 5d73e1d923 refactor(v2): extract RSortHeader and make the index lists real tables (#5149) 2026-10-05 15:04:46 -04:00
renovate[bot] 9ae2cd0e12 chore(deps): update dependency md-editor-v3 to v7.1.0 (#5142) 2026-10-05 08:13:34 -04:00
Georges-Antoine AssiandClaude Opus 5.5 ad0146a88e chore(trunk): fix the scheduled trunk check-all failures (#5141)
* 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]>
2026-10-05 08:05:38 -04:00
Sam Dornan 377405e514 fix(igdb): harden Twitch auth; test the IGDB client and handler (#5133) 2026-10-05 07:53:56 -04:00