Skip to content
Merged
7 changes: 4 additions & 3 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,17 @@ forbidden_modules =
adapters
domain

# Domain must not import services, adapters, or lib
# Domain must not import services or adapters. lib is allowed: lib hosts
# layer-agnostic utilities (e.g. ISO timestamp parsing) shared by both
# domain and services.
[importlinter:contract:domain-independence]
name = Domain must not import services, adapters, or lib
name = Domain must not import services or adapters
type = forbidden
source_modules =
domain
forbidden_modules =
services
adapters
lib

# Models must not import services, adapters, domain, or lib
[importlinter:contract:models-independence]
Expand Down
4 changes: 0 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -675,10 +675,6 @@ async def sync_rom_saves(self, rom_id):
async def get_save_slots(self, rom_id):
return await self._save_sync_service.get_save_slots(rom_id)

@migration_blocked
async def set_game_slot(self, rom_id, slot):
return self._save_sync_service.set_game_slot(rom_id, slot)

async def get_slot_saves(self, rom_id, slot):
return await self._save_sync_service.get_slot_saves(rom_id, slot)

Expand Down
16 changes: 8 additions & 8 deletions py_modules/adapters/romm/romm_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ def list_saves(
) -> list[dict]:
query = f"/api/saves?rom_id={rom_id}"
if device_id is not None:
query += f"&device_id={device_id}"
query += f"&device_id={urllib.parse.quote(device_id, safe='')}"
if slot is not None:
query += f"&slot={slot}"
query += f"&slot={urllib.parse.quote(slot, safe='')}"
result = self._client.request(query)
return result if isinstance(result, list) else []

Expand All @@ -130,11 +130,11 @@ def upload_save(
slot: str | None = None,
overwrite: bool = False,
) -> dict:
params = f"rom_id={rom_id}&emulator={urllib.parse.quote(emulator)}"
params = f"rom_id={rom_id}&emulator={urllib.parse.quote(emulator, safe='')}"
if device_id is not None:
params += f"&device_id={device_id}"
params += f"&device_id={urllib.parse.quote(device_id, safe='')}"
if slot is not None:
params += f"&slot={slot}"
params += f"&slot={urllib.parse.quote(slot, safe='')}"
if overwrite:
params += "&overwrite=true"
if save_id is not None:
Expand All @@ -155,7 +155,7 @@ def download_save_content(
path = f"/api/saves/{save_id}/content"
if device_id is not None:
opt = "true" if optimistic else "false"
path += f"?device_id={device_id}&optimistic={opt}"
path += f"?device_id={urllib.parse.quote(device_id, safe='')}&optimistic={opt}"
self._client.download(path, dest_path)

def confirm_download(self, save_id: int, device_id: str) -> dict:
Expand All @@ -170,7 +170,7 @@ def get_save_metadata(self, save_id: int) -> dict:
def get_save_summary(self, rom_id: int, device_id: str | None = None) -> dict:
query = f"/api/saves/summary?rom_id={rom_id}"
if device_id is not None:
query += f"&device_id={device_id}"
query += f"&device_id={urllib.parse.quote(device_id, safe='')}"
return self._client.request(query)

def delete_server_saves(self, save_ids: list[int]) -> dict:
Expand All @@ -195,7 +195,7 @@ def list_devices(self) -> list[dict]:

def update_device(self, device_id: str, **fields) -> dict:
payload = {k: v for k, v in fields.items() if v is not None}
return self._client.put_json(f"/api/devices/{device_id}", payload)
return self._client.put_json(f"/api/devices/{urllib.parse.quote(device_id, safe='')}", payload)

# ── Notes / Playtime ──────────────────────────────────────────────

Expand Down
163 changes: 0 additions & 163 deletions py_modules/domain/save_conflicts.py

This file was deleted.

27 changes: 14 additions & 13 deletions py_modules/domain/save_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,24 @@

from datetime import UTC, datetime

from lib.iso_time import parse_iso


def _format_time_ago(iso_timestamp: str) -> str | None:
"""Format an ISO timestamp as a human-readable time-ago label, or None on error."""
try:
check_dt = datetime.fromisoformat(iso_timestamp)
if check_dt.tzinfo is None:
check_dt = check_dt.replace(tzinfo=UTC)
diff_min = int((datetime.now(UTC) - check_dt).total_seconds() // 60)
if diff_min < 1:
return "Just now"
if diff_min < 60:
return f"{diff_min}m ago"
if diff_min < 1440:
return f"{diff_min // 60}h ago"
return f"{diff_min // 1440}d ago"
except (ValueError, TypeError):
check_dt = parse_iso(iso_timestamp)
if check_dt is None:
return None
if check_dt.tzinfo is None:
check_dt = check_dt.replace(tzinfo=UTC)
diff_min = int((datetime.now(UTC) - check_dt).total_seconds() // 60)
if diff_min < 1:
return "Just now"
if diff_min < 60:
return f"{diff_min}m ago"
if diff_min < 1440:
return f"{diff_min // 60}h ago"
return f"{diff_min // 1440}d ago"


def compute_save_sync_display(
Expand Down
29 changes: 9 additions & 20 deletions py_modules/domain/sync_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime

from lib.iso_time import parse_iso_to_epoch

# ---------------------------------------------------------------------------
# SyncAction variants
Expand Down Expand Up @@ -94,22 +95,6 @@ class Conflict:
# ---------------------------------------------------------------------------


def _parse_iso_to_epoch(value: str) -> float | None:
"""Parse an ISO-8601 timestamp to epoch seconds.

Handles a trailing "Z" defensively (older datetime.fromisoformat versions
reject it). Returns None on any parse failure — the caller decides how
to interpret that.
"""
if not value:
return None
try:
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
return datetime.fromisoformat(normalized).timestamp()
except (ValueError, TypeError):
return None


def _local_mtime_ge_server_updated_at(local_file: dict, server: dict) -> bool:
"""Return True iff local mtime is at-or-after the server save's updated_at.

Expand All @@ -120,7 +105,7 @@ def _local_mtime_ge_server_updated_at(local_file: dict, server: dict) -> bool:
local_mtime = local_file.get("mtime")
if not isinstance(local_mtime, int | float):
return False
server_epoch = _parse_iso_to_epoch(server.get("updated_at", ""))
server_epoch = parse_iso_to_epoch(server.get("updated_at", ""))
if server_epoch is None:
return False
return local_mtime >= server_epoch
Expand Down Expand Up @@ -195,8 +180,12 @@ def compute_sync_action(
return Upload(target_save_id=None)
return Skip(reason="nothing_to_sync")

# 2. Pick newest server save by updated_at.
server = max(server_saves_in_slot, key=lambda s: s.get("updated_at", ""))
# 2. Pick newest server save by updated_at (epoch-keyed; unparseable
# timestamps sort to the bottom so they can't beat a parseable one).
server = max(
server_saves_in_slot,
key=lambda s: parse_iso_to_epoch(s.get("updated_at")) or 0.0,
)

# 3. Find our device's entry on the chosen save and branch on it.
device_syncs = server.get("device_syncs") or []
Expand Down
30 changes: 30 additions & 0 deletions py_modules/lib/iso_time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""ISO-8601 timestamp parsing helpers.

Layer-agnostic utilities — domain and services may both import from here.
"""

from __future__ import annotations

from datetime import datetime


def parse_iso(value: str | None) -> datetime | None:
"""Parse an ISO-8601 timestamp to an aware datetime, or None on failure.

Handles a trailing "Z" defensively (older datetime.fromisoformat versions
reject it). Returns None for empty/None input or any parse failure — the
caller decides how to interpret that.
"""
if not value:
return None
try:
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
return datetime.fromisoformat(normalized)
except (ValueError, TypeError):
return None


def parse_iso_to_epoch(value: str | None) -> float | None:
"""Parse an ISO-8601 timestamp to epoch seconds (UTC), or None on failure."""
dt = parse_iso(value)
return dt.timestamp() if dt is not None else None
5 changes: 4 additions & 1 deletion py_modules/services/playtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from lib.iso_time import parse_iso
from services.protocols import RetryStrategy, RommApiProtocol, StatePersister

if TYPE_CHECKING:
Expand Down Expand Up @@ -206,7 +207,9 @@ async def record_session_end(self, rom_id: int) -> dict:
return {"success": False, "message": "No active session"}

try:
start = datetime.fromisoformat(entry["last_session_start"])
start = parse_iso(entry["last_session_start"])
if start is None:
return {"success": False, "message": "Failed to calculate session duration"}
now = datetime.now(UTC)
duration = (now - start).total_seconds()

Expand Down
Loading
Loading