feat(cloud-sync): serve RetroArch Cloud Sync over WebDAV - #3904
feat(cloud-sync): serve RetroArch Cloud Sync over WebDAV#3904gantoine wants to merge 15 commits into
Conversation
RetroArch's cloud-sync driver diffs a JSON manifest of {path, hash} entries
and then GETs/PUTs/DELETEs individual files, so it needs only a narrow slice
of WebDAV and never issues PROPFIND. Expose that slice at /api/cloud-sync so
save files and states sync straight into RomM's existing asset storage.
Assets are matched to a ROM by file name, and the optional core folder maps to
the emulator field that already namespaces asset storage. The router gates
itself rather than using @protected_route, so it can answer a 401 challenge
instead of a 403, and error responses carry no body because RetroArch's client
mishandles large ones.
Closes #2313
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greptile SummaryAdds a RetroArch-compatible WebDAV cloud-sync surface.
Confidence Score: 2/5The PR should not merge until cloud paths consistently identify assets and failed uploads cannot leave filesystem and database state inconsistent. Same-named ROMs can make manifest paths resolve to the wrong database asset, while upload failures after the filesystem write leave orphaned files or stale metadata and hashes. backend/handler/cloud_sync_handler.py, backend/endpoints/cloud_sync.py Important Files Changed
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
backend/handler/cloud_sync_handler.py:170
**Manifest paths lose ROM identity**
When visible ROMs on different platforms share a filesystem basename, the manifest emits the same path for their assets while GET, PUT, and DELETE independently select the lowest-ID ROM. This causes an advertised download or deletion to return 404, attaches uploads to the wrong ROM, and makes one asset shadow the other when both exist.
### Issue 2 of 2
backend/endpoints/cloud_sync.py:183-185
**Upload commits before asset metadata**
When scanning or the subsequent database mutation fails after this write, the new bytes remain on disk without a corresponding row, or an existing file is overwritten while its metadata remains stale. The request returns an error, but the manifest then omits the orphaned upload or can retain the previous cached hash, leaving filesystem and database state inconsistent.
Reviews (1): Last reviewed commit: "feat(cloud-sync): serve RetroArch Cloud ..." | Re-trigger Greptile |
|
|
||
| entries.append( | ||
| { | ||
| "path": build_cloud_sync_path(kind, asset.emulator, asset.file_name), |
There was a problem hiding this comment.
Manifest paths lose ROM identity
When visible ROMs on different platforms share a filesystem basename, the manifest emits the same path for their assets while GET, PUT, and DELETE independently select the lowest-ID ROM. This causes an advertised download or deletion to return 404, attaches uploads to the wrong ROM, and makes one asset shadow the other when both exist.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/handler/cloud_sync_handler.py
Line: 170
Comment:
**Manifest paths lose ROM identity**
When visible ROMs on different platforms share a filesystem basename, the manifest emits the same path for their assets while GET, PUT, and DELETE independently select the lowest-ID ROM. This causes an advertised download or deletion to return 404, attaches uploads to the wrong ROM, and makes one asset shadow the other when both exist.
How can I resolve this? If you propose a fix, please make it concise.| await fs_asset_handler.write_file( | ||
| file=await request.body(), path=asset_path, filename=file_name | ||
| ) |
There was a problem hiding this comment.
Upload commits before asset metadata
When scanning or the subsequent database mutation fails after this write, the new bytes remain on disk without a corresponding row, or an existing file is overwritten while its metadata remains stale. The request returns an error, but the manifest then omits the orphaned upload or can retain the previous cached hash, leaving filesystem and database state inconsistent.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/endpoints/cloud_sync.py
Line: 183-185
Comment:
**Upload commits before asset metadata**
When scanning or the subsequent database mutation fails after this write, the new bytes remain on disk without a corresponding row, or an existing file is overwritten while its metadata remains stale. The request returns an error, but the manifest then omits the orphaned upload or can retain the previous cached hash, leaving filesystem and database state inconsistent.
**Knowledge Base Used:**
- [Database Models and Migrations](https://app.greptile.com/romm/-/custom-context/knowledge-base/rommapp/romm/-/docs/database-models.md)
- [Filesystem Handler](https://app.greptile.com/romm/-/custom-context/knowledge-base/rommapp/romm/-/docs/filesystem-handler.md)
How can I resolve this? If you propose a fix, please make it concise.… blobs RetroArch's Cloud Sync also offers a config/, thumbnails/, and system/ category alongside saves/states, none of which belong to a ROM. The previous behavior rejected them with 409 so RetroArch would keep retrying locally instead of losing data. This stores them as plain per-user files under CLOUD_SYNC_BLOB_BASE_PATH (FSCloudSyncBlobHandler) instead, namespaced by user so two RetroArch installs syncing to the same RomM instance under different accounts don't clobber each other's files, and includes them in the manifest alongside saves/states.
RetroArch's cloud-sync core-name segment uses its own directory casing (e.g. "Snes9x"), while RomM's emulator field convention is the lowercase libretro core id (e.g. "snes9x") -- the same convention its own web player matches saves against exactly. Storing the raw RetroArch casing made a synced save invisible in RomM's web player, and could hand RetroArch back a folder name its local install never uses. Adds a small translation table (ported from the community romm-retroarch-sync project) plus a safe universal normalization for the write direction. Cores outside the table round-trip unchanged rather than guessing at an unverified casing.
…-ext feat(cloud-sync): support config/thumbnails/system as opaque per-user blobs
| except ValueError: | ||
| return _empty(status.HTTP_404_NOT_FOUND) | ||
|
|
||
| if not resolved_path.is_file(): |
…ad slots States have no `slot` column (unlike saves), so a state created through RomM's own web player -- named with a display label and timestamp, not a RetroArch slot number -- was surfaced in the cloud-sync manifest under its raw file name. Verified live: RetroArch's Load State menu only ever offers numbered slots 0-999, so a synced file named otherwise downloads successfully but is never reachable from that menu. Mirrors the reasoning already used for the retroarch-webdav-romm shim project's manifest builder: states are grouped by (rom, emulator, slot suffix) via the file name's trailing .state/.state<N>/.state.auto pattern, and the newest state in each bucket -- regardless of whether it came from RetroArch itself or the web player -- is advertised under the canonical name RetroArch's own upload for that slot would carry. GET/DELETE resolve that canonical name back to the same bucket via resolve_state_by_slot, so serving and deleting agree with what the manifest advertised even when the underlying row's real file name differs.
RetroArch uploads a PNG screenshot alongside every state it syncs, named
`<state file name>.png` (e.g. `test_rom.state.png`). Verified live: this
file name doesn't match RetroArch's own `.state[N]`/`.state.auto` slot
pattern once naively split on the last dot ("test_rom.state" is not any
ROM's name), so `_resolve_rom` failed and every screenshot upload 409'd.
game_name_from_file_name now strips a trailing `.png` before applying the
existing state-suffix stripping, so it resolves the owning ROM the same way
it would for the state itself. Screenshots have no ROM-name-derived slot of
their own -- they ride along with whichever state `resolve_state_by_slot`
already picked for that (rom, emulator, slot), via the new
`resolve_state_screenshot_by_slot`, and are stored as a Screenshot (RomM's
own state-thumbnail model), not a State.
build_manifest now also advertises a `<canonical state name>.png` entry
for any state that has one attached, so RetroArch's own upload of it
succeeds and diffs correctly on subsequent syncs instead of failing (and
retrying) forever.
…g path PUT wrote every state upload under the canonical slot name RetroArch requested and only patched file_size_bytes on the matched existing row -- but that row's own file_name (e.g. a web-player upload's timestamped name) was left untouched. The new bytes landed on disk under a different path than the DB row pointed at, so the row's real content and what's actually on disk silently diverged. Verified live: this surfaced as a state that could never stop being a 'Conflicting change' on every subsequent sync, no matter how many times it was re-uploaded -- each upload wrote to the canonical path again while the tracked row (and its real save data) still pointed at the old, now-stale file, so RomM's own view of "the current state" never actually caught up to what RetroArch kept sending. Now resolves the existing row first and writes to *its* real file name when one exists, only falling back to the canonical name for a genuinely new row. Same fix applied to the state-screenshot upload path, which had the identical bug (screenshot identity resolved by exact canonical-name match instead of the owning state's real name).
…h sync
PPSSPP doesn't save a single file per game like every other core -- it
mirrors a real PSP memory stick under saves/<core>/PSP/SAVEDATA/<slot>/
as several small files (PARAM.SFO, the actual save data, ICON0.PNG, ...)
that only make sense as a set, plus saves/<core>/PSP/SYSTEM/CACHE/ holds
pure engine caches with no save data at all. Neither fits the existing
one-WebDAV-path-per-asset model.
Ports the retroarch-webdav-romm shim's pspSave.ts approach: a save
folder's files are bundled into a single zip stored as one RomM Save
(slot=None), and unbundled again for GET/manifest purposes. The rom is
resolved from the folder's PARAM.SFO TITLE (normalized-matched against
roms on the psp platform) with PSP_SERIAL_MAP as an explicit override and
fallback for titles that don't normalize-match automatically. Files that
arrive before the folder resolves (RetroArch doesn't guarantee PARAM.SFO
lands first) are buffered on disk until it does, and merged in once
resolved.
Manifest lists each bundle member as its own {path, hash} entry, since
RetroArch diffs per-file, not per-bundle.
Also fixes a latent zipfile bug this surfaced: zipfile_inflate64 (pulled
in elsewhere for ROM archive reading) replaces zipfile._get_compressor
with a signature CPython 3.13's own ZipFile.writestr() can't call --
RomM already has a shim for this (utils/zip_cache.py's
_ensure_zipfile_writable), just needed calling here too.
…brary
RetroArch's own Cloud Sync client never issues PROPFIND (verified against
its source, already noted in this router's docstring), so this isn't on
RetroArch's actual sync path -- it's for a real WebDAV client (iOS Files'
"Connect to Server", Cyberduck, ...) to mount the same /api/cloud-sync
URL and browse/download the library as plain files, same as the
retroarch-webdav-romm shim's romBrowser.ts + webdavXml.ts.
Adds PROPFIND for roms/<platform>/<file> (RomM's own library, read-only --
no PUT/DELETE) and saves/states/ (the current cloud-sync manifest; unlike
the shim, only current entries are browsable here, not full history --
RomM's own web UI covers that). LOCK/UNLOCK are a fake always-succeeds
handshake some WebDAV clients require before they'll mount a server at
all, matching the shim.
GET/HEAD for a rom file redirects (307) to RomM's existing
/api/roms/{id}/content/{file_name} endpoint rather than reimplementing
Range support, multi-file zip caching and (in production) nginx
X-Accel-Redirect -- Basic Auth carries over on the redirect since that
endpoint already accepts it alongside OAuth.
Found and fixed along the way: Rom.has_multiple_files / .files depend on
columns/relationships get_roms_scalar doesn't eager-load, so accessing
them outside the query's own session raised DetachedInstanceError --
re-fetch the visibility-filtered ids via get_roms_by_ids (which does
eager-load them) instead of using the raw scalar results directly.
…ilter # Conflicts: # backend/endpoints/cloud_sync.py
…orever Verified live with a real WebDAV client (Cyberduck): every PropfindEntry href was relative to this router's own mount point (e.g. "roms/", "saves/Snes9x/"), not absolute from the server root the way WebDAV clients expect a <D:href> to be. The client couldn't match a "self" entry's href back to the path it had just requested, so it rendered that entry as an extra nested subfolder instead of recognizing it as the current directory -- and since every subfolder has the same mismatch, browsing into it produced another apparent copy of the whole tree, forever. Prefixing every href with this router's actual mount path (/api/cloud-sync) fixes the self-entry match and ends the loop. Also fixed along the way: href path segments were never percent-encoded, unlike the retroarch-webdav-romm shim's own hrefEscape (which used encodeURIComponent) that this was supposed to mirror -- a save/state/rom filename containing a space or other reserved character would have produced invalid hrefs.
sanitize_filename treated "+" as an invalid character alongside the actual Windows-reserved set (< > : " / \ | ? *), which broke cloud-sync's filename-based ROM matching for any title containing one -- RomM itself stores such ROMs (e.g. combo carts like "Super Mario All-Stars + Super Mario World") on disk with the "+" intact, so stripping it before resolving the ROM caused every save/state upload for that game to 409. Confirmed live against production (romm.vmyaman.com) via a real RetroArch client.
Keeping the test-file footprint down; the production fix (dropping "+" from sanitize_filename's invalid-char set) stands on its own and is covered live.
…ilter fix(cloud-sync): remap web-player states into RetroArch's numbered load slots
Description
Explain the changes or enhancements you are proposing with this pull request.
Makes RomM a native cloud-sync target for RetroArch, so
.srmsaves and.statesave states sync straight into RomM's existing asset storage with no sidecar service. Closes #2313.RetroArch's cloud-sync driver only speaks a narrow slice of WebDAV: it
GETs a JSON manifest of{path, hash}entries frommanifest.server, diffs it three ways against its local state, thenGETs/PUTs/DELETEs individual files. It never issuesPROPFIND, so there is no collection listing to implement. This PR serves exactly that slice at/api/cloud-sync.Setup for users: point RetroArch's Cloud Sync at
https://<host>/api/cloud-sync/(trailing slash required), enable save/state sync only, and authenticate with a RomM username and password over HTTP Basic.What's here
backend/endpoints/cloud_sync.py— the DAV surface:OPTIONS,GET,PUT,DELETE,MKCOL,MOVE.backend/handler/cloud_sync_handler.py— path grammar, ROM resolution, manifest construction.get_roms_by_fs_name_no_ext,get_state_by_path,compute_file_md5.Design notes
/api/cloud-sync, not the/webdav/proposed in the issue. nginx already proxies/apito the backend; a new top-level path would fall intolocation /(the static SPA) and need its own location block.snes9x↔Snes9x. Here the emulator string is stored verbatim and namespaces storage exactly as it already does for/api/savesuploads (saves/<platform>/<rom_id>/<emulator>/). RetroArch's own rows round-trip exactly, and there's no table to keep current as cores come and go.PUTs return 409, not a fake success. Answering OK for a file we didn't store would put it in RetroArch's local manifest; the next sync would then read its absence from ours as a remote delete and wipe the local copy. Same reasoning rejects theconfig/,thumbnails/andsystem/roots, which RomM has nowhere to store.MOVEis treated as a delete. It's what RetroArch sends in non-destructive mode to shelve a file underdeleted/; RomM has no such holding area, and keeping the row would just push the file back on the next sync.@protected_route, so it can answer a 401WWW-Authenticatechallenge rather than the 403 that decorator produces. HTTP Basic already worked inHybridAuthBackend, andCSRFMiddlewarealready skips Basic-authenticated requests, so no middleware changes were needed.updated_at. Without it, a library with a few hundred save states would re-read gigabytes on every sync.include_in_schema=False, so OpenAPI is unchanged and no frontend type regeneration is needed.Known limitations
config/,thumbnails/orsystem/sync — RomM has no generic blob storage.AI assistance disclosure
Written with Claude Code (Opus 4.8). The protocol behaviour was re-derived from RetroArch's own
tasks/task_cloudsync.candnetwork/cloud_sync/webdav.c; the design was informed by reading the README of the unlicensed community shimfmustafayaman/retroarch-webdav-romm, but none of its code was used. All design decisions above were reviewed by me, and I ran the test suite and linters locally.Checklist
Please check all that apply.
Testing note: 34 new endpoint/unit tests pass, and the saves/states/assets/sync/roms suites (279 tests) show no regressions;
trunk fmt && trunk checkis clean. The tests exercise the real middleware stack viaTestClient, but this has not yet been run against a live RetroArch client — the manifest shape, verb set and status-code expectations come from reading RetroArch's source. Worth a real-device sync before merging.