Skip to content

Commit ef1340e

Browse files
authored
feat(adapters): SaveApiV47 device sync methods (#182) (#187)
* feat(adapters): add supports_device_sync() capability check to ApiRouter (#182) * feat(adapters): add register_device() to RommApiV47 (#182) * feat(adapters): extend list_saves with device_id and slot params on V47 (#182) * feat(adapters): extend upload_save with device_id, slot, overwrite on V47 (#182) * feat(adapters): add download_save_content() to RommApiV47 (#182) * feat(adapters): add confirm_download() to RommApiV47 (#182) POST /api/saves/{id}/downloaded with device_id for manual sync confirmation when download_save_content() is called with optimistic=false. Also adds the method signature to RommApiProtocol. * feat(adapters): add get_save_summary() to RommApiV47 (#182) * test(adapters): add router delegation and v46 fallback tests for device sync (#182) * chore: update FakeSaveApi stubs and fix import-linter config (#182) * fix: update flaky_list closure signature to match extended list_saves protocol (#182)
1 parent a2499d9 commit ef1340e

8 files changed

Lines changed: 528 additions & 11 deletions

File tree

.importlinter

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ source_modules =
1515
forbidden_modules =
1616
adapters.romm.http
1717
adapters.romm.api_base
18+
adapters.romm.api_v46
1819
adapters.romm.api_v47
1920
adapters.romm.api_router
2021
adapters.steam_config

py_modules/adapters/romm/api_router.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,9 @@ def set_version(self, version: str) -> None:
4747
else:
4848
self._active = self._v46
4949

50+
def supports_device_sync(self) -> bool:
51+
"""Check if the active RomM version supports device sync features."""
52+
return isinstance(self._active, RommApiV47)
53+
5054
def __getattr__(self, name: str):
5155
return getattr(self._active, name)

py_modules/adapters/romm/api_v47.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,96 @@ def list_roms_by_collection(self, collection_id: int, limit: int = 50, offset: i
3737
def list_roms_by_virtual_collection(self, virtual_id: str, limit: int = 50, offset: int = 0) -> dict:
3838
encoded_id = urllib.parse.quote(str(virtual_id), safe="")
3939
return self._client.request(f"/api/roms?virtual_collection_id={encoded_id}&limit={limit}&offset={offset}")
40+
41+
def list_saves(
42+
self,
43+
rom_id: int,
44+
*,
45+
device_id: str | None = None,
46+
slot: str | None = None,
47+
) -> list[dict]:
48+
"""List saves with optional device sync info and slot filtering."""
49+
query = f"/api/saves?rom_id={rom_id}"
50+
if device_id is not None:
51+
query += f"&device_id={device_id}"
52+
if slot is not None:
53+
query += f"&slot={slot}"
54+
result = self._client.request(query)
55+
return result if isinstance(result, list) else []
56+
57+
def upload_save(
58+
self,
59+
rom_id: int,
60+
file_path: str,
61+
emulator: str,
62+
save_id: int | None = None,
63+
*,
64+
device_id: str | None = None,
65+
slot: str | None = None,
66+
overwrite: bool = False,
67+
) -> dict:
68+
"""Upload a save with optional device tracking and slot assignment.
69+
70+
Raises RommConflictError on 409 (another device uploaded since last sync).
71+
"""
72+
params = f"rom_id={rom_id}&emulator={urllib.parse.quote(emulator)}"
73+
if device_id is not None:
74+
params += f"&device_id={device_id}"
75+
if slot is not None:
76+
params += f"&slot={slot}"
77+
if overwrite:
78+
params += "&overwrite=true"
79+
if save_id is not None:
80+
return self._client.upload_multipart(f"/api/saves/{save_id}?{params}", file_path, method="PUT")
81+
return self._client.upload_multipart(f"/api/saves?{params}", file_path, method="POST")
82+
83+
def download_save_content(
84+
self,
85+
save_id: int,
86+
dest_path: str,
87+
*,
88+
device_id: str | None = None,
89+
optimistic: bool = True,
90+
) -> None:
91+
"""Download save content with optional device sync tracking.
92+
93+
When device_id is provided, the server records the download.
94+
optimistic=True (default) auto-marks device as synced.
95+
optimistic=False requires a manual confirm_download() call after.
96+
"""
97+
path = f"/api/saves/{save_id}/content"
98+
if device_id is not None:
99+
opt = "true" if optimistic else "false"
100+
path += f"?device_id={device_id}&optimistic={opt}"
101+
self._client.download(path, dest_path)
102+
103+
def confirm_download(self, save_id: int, device_id: str) -> dict:
104+
"""Confirm a save download for manual sync (when optimistic=false)."""
105+
return self._client.post_json(
106+
f"/api/saves/{save_id}/downloaded",
107+
{"device_id": device_id},
108+
)
109+
110+
def get_save_summary(self, rom_id: int, device_id: str | None = None) -> dict:
111+
"""Fetch grouped save summary for a ROM with slot breakdown.
112+
113+
Uses the dedicated /api/saves/summary endpoint which returns
114+
a structured response grouped by slot, unlike the flat list
115+
from list_saves.
116+
"""
117+
query = f"/api/saves/summary?rom_id={rom_id}"
118+
if device_id is not None:
119+
query += f"&device_id={device_id}"
120+
return self._client.request(query)
121+
122+
def register_device(self, name: str, platform: str, client: str, version: str) -> dict:
123+
"""Register this client as a device via POST /api/devices."""
124+
return self._client.post_json(
125+
"/api/devices",
126+
{
127+
"name": name,
128+
"platform": platform,
129+
"client": client,
130+
"version": version,
131+
},
132+
)

py_modules/services/protocols.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,17 @@ def download_firmware(self, firmware_id: int, filename: str, dest: str) -> None:
139139
"""
140140
...
141141

142-
def list_saves(self, rom_id: int) -> list[dict]:
143-
"""List all saves for a ROM.
144-
145-
Returns a list of save dicts from /api/saves?rom_id={rom_id}.
142+
def list_saves(
143+
self,
144+
rom_id: int,
145+
*,
146+
device_id: str | None = None,
147+
slot: str | None = None,
148+
) -> list[dict]:
149+
"""List saves for a ROM.
150+
151+
On v4.7+, pass device_id to populate device_syncs in response,
152+
and slot to filter by save slot.
146153
"""
147154
...
148155

@@ -152,11 +159,49 @@ def upload_save(
152159
file_path: str,
153160
emulator: str,
154161
save_id: int | None = None,
162+
*,
163+
device_id: str | None = None,
164+
slot: str | None = None,
165+
overwrite: bool = False,
155166
) -> dict:
156167
"""Upload or update a save file.
157168
158-
Creates via POST /api/saves or updates via PUT /api/saves/{save_id}.
159-
Upserts by filename. Returns the save dict.
169+
On v4.7+, pass device_id for sync tracking, slot for slot assignment,
170+
and overwrite=True to force-upload over conflicts.
171+
Raises RommConflictError on 409 when overwrite=False and conflict detected.
172+
"""
173+
...
174+
175+
def download_save_content(
176+
self,
177+
save_id: int,
178+
dest_path: str,
179+
*,
180+
device_id: str | None = None,
181+
optimistic: bool = True,
182+
) -> None:
183+
"""Download save content with optional device sync tracking.
184+
185+
Only available on RomM >= 4.7.0.
186+
When device_id is set, optimistic=True auto-marks device as synced;
187+
optimistic=False requires a manual confirm_download() call.
188+
"""
189+
...
190+
191+
def confirm_download(self, save_id: int, device_id: str) -> dict:
192+
"""Manually confirm a save download for device sync tracking.
193+
194+
Only needed when download_save_content() was called with optimistic=False.
195+
Only available on RomM >= 4.7.0.
196+
"""
197+
...
198+
199+
def get_save_summary(self, rom_id: int, device_id: str | None = None) -> dict:
200+
"""Fetch grouped save summary for a ROM with slot breakdown.
201+
202+
Only available on RomM >= 4.7.0.
203+
Uses /api/saves/summary — returns structured response grouped by slot.
204+
Pass device_id to include device sync status per save.
160205
"""
161206
...
162207

@@ -215,6 +260,22 @@ def list_roms_by_virtual_collection(self, virtual_id: str, limit: int = 50, offs
215260
"""
216261
...
217262

263+
def supports_device_sync(self) -> bool:
264+
"""Check if the connected RomM server supports device sync (v4.7+).
265+
266+
Returns True if device registration, slot-based saves, and
267+
server-side conflict detection are available.
268+
"""
269+
...
270+
271+
def register_device(self, name: str, platform: str, client: str, version: str) -> dict:
272+
"""Register this client as a sync device on the RomM server.
273+
274+
Only available on RomM >= 4.7.0 (check supports_device_sync() first).
275+
Returns device dict with id, name, created_at.
276+
"""
277+
...
278+
218279

219280
# ---------------------------------------------------------------------------
220281
# Infrastructure callback Protocols

tests/fakes/fake_save_api.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,40 @@ def list_roms_by_collection(self, collection_id: int, limit: int = 50, offset: i
102102
def list_roms_by_virtual_collection(self, virtual_id: str, limit: int = 50, offset: int = 0) -> dict:
103103
raise NotImplementedError
104104

105+
def supports_device_sync(self) -> bool:
106+
return False
107+
108+
def register_device(self, name: str, platform: str, client: str, version: str) -> dict:
109+
raise NotImplementedError
110+
111+
def download_save_content(
112+
self,
113+
save_id: int,
114+
dest_path: str,
115+
*,
116+
device_id: str | None = None,
117+
optimistic: bool = True,
118+
) -> None:
119+
raise NotImplementedError
120+
121+
def confirm_download(self, save_id: int, device_id: str) -> dict:
122+
raise NotImplementedError
123+
124+
def get_save_summary(self, rom_id: int, device_id: str | None = None) -> dict:
125+
raise NotImplementedError
126+
105127
# ------------------------------------------------------------------
106128
# Implemented save/note methods
107129
# ------------------------------------------------------------------
108130

109-
def list_saves(self, rom_id: int) -> list[dict]:
110-
self.call_log.append(("list_saves", (rom_id,), {}))
131+
def list_saves(
132+
self,
133+
rom_id: int,
134+
*,
135+
device_id: str | None = None,
136+
slot: str | None = None,
137+
) -> list[dict]:
138+
self.call_log.append(("list_saves", (rom_id,), {"device_id": device_id, "slot": slot}))
111139
self._check_fail()
112140
return [s for s in self.saves.values() if s.get("rom_id") == rom_id]
113141

@@ -117,8 +145,23 @@ def upload_save(
117145
file_path: str,
118146
emulator: str,
119147
save_id: int | None = None,
148+
*,
149+
device_id: str | None = None,
150+
slot: str | None = None,
151+
overwrite: bool = False,
120152
) -> dict:
121-
self.call_log.append(("upload_save", (rom_id, file_path, emulator), {"save_id": save_id}))
153+
self.call_log.append(
154+
(
155+
"upload_save",
156+
(rom_id, file_path, emulator),
157+
{
158+
"save_id": save_id,
159+
"device_id": device_id,
160+
"slot": slot,
161+
"overwrite": overwrite,
162+
},
163+
)
164+
)
122165
self._check_fail()
123166

124167
import os

tests/test_romm_api_router.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,46 @@ def test_update_note(self, router, mock_client):
186186
assert result == {"id": 1}
187187
mock_client.put_json.assert_called_with("/api/roms/42/notes/1", {"body": "updated"})
188188

189+
def test_register_device_v47(self, router, mock_client):
190+
router.set_version("4.7.0")
191+
mock_client.post_json.return_value = {"id": "abc"}
192+
result = router.register_device("deck", "linux", "decky", "1.0")
193+
assert result == {"id": "abc"}
194+
195+
def test_list_saves_with_device_id_v47(self, router, mock_client):
196+
router.set_version("4.7.0")
197+
mock_client.request.return_value = [{"id": 1}]
198+
result = router.list_saves(42, device_id="abc")
199+
assert result == [{"id": 1}]
200+
assert "device_id=abc" in mock_client.request.call_args[0][0]
201+
202+
def test_upload_save_with_device_params_v47(self, router, mock_client):
203+
router.set_version("4.7.0")
204+
mock_client.upload_multipart.return_value = {"id": 1}
205+
router.upload_save(42, "/tmp/s.srm", "retroarch-mgba", device_id="abc", slot="default")
206+
path = mock_client.upload_multipart.call_args[0][0]
207+
assert "device_id=abc" in path
208+
assert "slot=default" in path
209+
210+
def test_download_save_content_v47(self, router, mock_client):
211+
router.set_version("4.7.0")
212+
router.download_save_content(99, "/tmp/s.srm", device_id="abc")
213+
path = mock_client.download.call_args[0][0]
214+
assert "/api/saves/99/content" in path
215+
assert "device_id=abc" in path
216+
217+
def test_confirm_download_v47(self, router, mock_client):
218+
router.set_version("4.7.0")
219+
mock_client.post_json.return_value = {"status": "ok"}
220+
result = router.confirm_download(99, "abc")
221+
assert result == {"status": "ok"}
222+
223+
def test_get_save_summary_v47(self, router, mock_client):
224+
router.set_version("4.7.0")
225+
mock_client.request.return_value = {"slots": []}
226+
result = router.get_save_summary(42, "abc")
227+
assert result == {"slots": []}
228+
189229

190230
# -- __getattr__ safety net --
191231

@@ -205,6 +245,48 @@ def test_unknown_attr_is_romm_api_error(self, router):
205245
_ = router.nonexistent
206246
assert exc_info.value.min_version == "unknown"
207247

248+
def test_register_device_raises_on_v46(self, router):
249+
with pytest.raises(RommUnsupportedError):
250+
router.register_device("deck", "linux", "decky", "1.0")
251+
252+
def test_download_save_content_raises_on_v46(self, router):
253+
with pytest.raises(RommUnsupportedError):
254+
router.download_save_content(99, "/tmp/s.srm")
255+
256+
def test_confirm_download_raises_on_v46(self, router):
257+
with pytest.raises(RommUnsupportedError):
258+
router.confirm_download(99, "abc")
259+
260+
def test_get_save_summary_raises_on_v46(self, router):
261+
with pytest.raises(RommUnsupportedError):
262+
router.get_save_summary(42)
263+
264+
265+
# -- supports_device_sync --
266+
267+
268+
class TestSupportsDeviceSync:
269+
def test_false_by_default(self, router):
270+
assert router.supports_device_sync() is False
271+
272+
def test_false_on_v46(self, router):
273+
router.set_version("4.6.1")
274+
assert router.supports_device_sync() is False
275+
276+
def test_true_on_v47(self, router):
277+
router.set_version("4.7.0")
278+
assert router.supports_device_sync() is True
279+
280+
def test_true_on_development(self, router):
281+
router.set_version("development")
282+
assert router.supports_device_sync() is True
283+
284+
def test_switches_back_to_false(self, router):
285+
router.set_version("4.7.0")
286+
assert router.supports_device_sync() is True
287+
router.set_version("4.6.1")
288+
assert router.supports_device_sync() is False
289+
208290

209291
# -- Delegation works on both versions --
210292

0 commit comments

Comments
 (0)