Skip to content

Commit 031fb57

Browse files
authored
Merge pull request #3479 from tmgast/fix/save-device-attribution
Expose per-device save sync attribution and origin device
2 parents 7e7368f + 842bb29 commit 031fb57

7 files changed

Lines changed: 448 additions & 49 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Track the device that created a save
2+
3+
Revision ID: 0082_save_origin_device
4+
Revises: 0081_add_archive_members
5+
Create Date: 2026-06-05 00:00:00.000000
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
revision = "0082_save_origin_device"
13+
down_revision = "0081_add_archive_members"
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade() -> None:
19+
# Order matters on MariaDB: the index must exist before the FK (it backs the
20+
# constraint), so create column -> index -> FK.
21+
with op.batch_alter_table("saves", schema=None) as batch_op:
22+
batch_op.add_column(
23+
sa.Column("origin_device_id", sa.String(length=255), nullable=True),
24+
if_not_exists=True,
25+
)
26+
batch_op.create_index(
27+
"ix_saves_origin_device_id", ["origin_device_id"], if_not_exists=True
28+
)
29+
batch_op.create_foreign_key(
30+
"fk_saves_origin_device_id",
31+
"devices",
32+
["origin_device_id"],
33+
["id"],
34+
ondelete="SET NULL",
35+
)
36+
37+
38+
def downgrade() -> None:
39+
# Drop the FK before the index it backs, then the column.
40+
with op.batch_alter_table("saves", schema=None) as batch_op:
41+
batch_op.drop_constraint("fk_saves_origin_device_id", type_="foreignkey")
42+
batch_op.drop_index("ix_saves_origin_device_id", if_exists=True)
43+
batch_op.drop_column("origin_device_id", if_exists=True)

backend/endpoints/responses/assets.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class SaveSchema(BaseAsset):
3838
slot: str | None = None
3939
content_hash: str | None = None
4040
screenshot: ScreenshotSchema | None
41+
origin_device_id: str | None = None
4142
device_syncs: list[DeviceSyncSchema] = []
4243

4344
@model_validator(mode="before")

backend/endpoints/saves.py

Lines changed: 65 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import re
3+
from collections.abc import Sequence
34
from datetime import datetime, timezone
45
from typing import Annotated
56

@@ -34,34 +35,66 @@
3435

3536
def _build_save_schema(
3637
save: Save,
38+
syncs: Sequence[tuple[DeviceSaveSync, str | None]] = (),
3739
device: Device | None = None,
38-
sync: DeviceSaveSync | None = None,
3940
) -> SaveSchema:
41+
"""Attach one ``DeviceSyncSchema`` per device that has synced this save.
42+
43+
``syncs`` is the full list of sync rows (paired with device name) for this
44+
save across every device, so clients can attribute the save to its creator.
45+
``device`` is the caller's device, when supplied: its entry is emitted first
46+
for stable ordering and old-client compatibility, and a placeholder entry is
47+
synthesized when the caller has not yet synced this save.
48+
"""
4049
save_schema = SaveSchema.model_validate(save)
4150

42-
if device:
43-
if sync:
44-
is_current = to_utc(sync.last_synced_at) >= to_utc(save.updated_at)
45-
last_synced = sync.last_synced_at
46-
is_untracked = sync.is_untracked
47-
else:
48-
is_current = False
49-
last_synced = save.updated_at
50-
is_untracked = False
51+
save_updated = to_utc(save.updated_at)
52+
caller_present = False
53+
entries: list[DeviceSyncSchema] = []
54+
for sync, device_name in syncs:
55+
if device and sync.device_id == device.id:
56+
caller_present = True
57+
entries.append(
58+
DeviceSyncSchema(
59+
device_id=sync.device_id,
60+
device_name=device_name,
61+
last_synced_at=sync.last_synced_at,
62+
is_untracked=sync.is_untracked,
63+
is_current=to_utc(sync.last_synced_at) >= save_updated,
64+
)
65+
)
5166

52-
save_schema.device_syncs = [
67+
if device and not caller_present:
68+
entries.append(
5369
DeviceSyncSchema(
5470
device_id=device.id,
5571
device_name=device.name,
56-
last_synced_at=last_synced,
57-
is_untracked=is_untracked,
58-
is_current=is_current,
72+
last_synced_at=save.updated_at,
73+
is_untracked=False,
74+
is_current=False,
5975
)
60-
]
76+
)
6177

78+
if device:
79+
entries.sort(key=lambda entry: entry.device_id != device.id)
80+
81+
save_schema.device_syncs = entries
6282
return save_schema
6383

6484

85+
def _syncs_for_save(
86+
save_id: int, device: Device | None
87+
) -> list[tuple[DeviceSaveSync, str | None]]:
88+
"""Fetch every device sync for a single save when a device is in context.
89+
90+
Device attribution is only meaningful to a device-scoped caller, so callers
91+
without a device get an empty list and no query is issued.
92+
"""
93+
if not device:
94+
return []
95+
return db_device_save_sync_handler.get_syncs_for_saves([save_id]).get(save_id, [])
96+
97+
6598
DATETIME_TAG_PATTERN = re.compile(r" \[\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\]")
6699

67100

@@ -229,12 +262,9 @@ async def add_save(
229262
await fs_asset_handler.remove_file(f"{saves_path}/{actual_filename}")
230263
except FileNotFoundError:
231264
pass
232-
sync = None
233-
if device:
234-
sync = db_device_save_sync_handler.get_sync(
235-
device_id=device.id, save_id=existing_by_hash.id
236-
)
237-
return _build_save_schema(existing_by_hash, device, sync)
265+
return _build_save_schema(
266+
existing_by_hash, _syncs_for_save(existing_by_hash.id, device), device
267+
)
238268

239269
if db_save:
240270
update_data: dict = {
@@ -249,6 +279,7 @@ async def add_save(
249279
scanned_save.user_id = request.user.id
250280
scanned_save.emulator = emulator
251281
scanned_save.slot = slot
282+
scanned_save.origin_device_id = device.id if device else None
252283
db_save = db_save_handler.add_save(save=scanned_save)
253284

254285
if device:
@@ -322,12 +353,7 @@ async def add_save(
322353
rom_user.id, {"last_played": datetime.now(timezone.utc)}
323354
)
324355

325-
sync = None
326-
if device:
327-
sync = db_device_save_sync_handler.get_sync(
328-
device_id=device.id, save_id=db_save.id
329-
)
330-
return _build_save_schema(db_save, device, sync)
356+
return _build_save_schema(db_save, _syncs_for_save(db_save.id, device), device)
331357

332358

333359
@protected_route(router.get, "", [Scope.ASSETS_READ])
@@ -350,13 +376,13 @@ def get_saves(
350376
if not device:
351377
return [_build_save_schema(save) for save in saves]
352378

353-
syncs = db_device_save_sync_handler.get_syncs_for_device_and_saves(
354-
device_id=device.id, save_ids=[s.id for s in saves]
379+
syncs_by_save_id = db_device_save_sync_handler.get_syncs_for_saves(
380+
[s.id for s in saves]
355381
)
356-
sync_by_save_id = {s.save_id: s for s in syncs}
357382

358383
return [
359-
_build_save_schema(save, device, sync_by_save_id.get(save.id)) for save in saves
384+
_build_save_schema(save, syncs_by_save_id.get(save.id, []), device)
385+
for save in saves
360386
]
361387

362388

@@ -404,12 +430,7 @@ def get_save(request: Request, id: int, device_id: str | None = None) -> SaveSch
404430
detail=f"Save with ID {id} not found",
405431
)
406432

407-
sync = None
408-
if device:
409-
sync = db_device_save_sync_handler.get_sync(
410-
device_id=device.id, save_id=save.id
411-
)
412-
return _build_save_schema(save, device, sync)
433+
return _build_save_schema(save, _syncs_for_save(save.id, device), device)
413434

414435

415436
@protected_route(router.get, "/{id}/content", [Scope.ASSETS_READ])
@@ -475,14 +496,14 @@ def confirm_download(
475496
)
476497

477498
device = _resolve_device(device_id, request.user.id)
478-
sync = db_device_save_sync_handler.upsert_sync(
499+
db_device_save_sync_handler.upsert_sync(
479500
device_id=device_id,
480501
save_id=save.id,
481502
synced_at=save.updated_at,
482503
)
483504
db_device_handler.update_last_seen(device_id=device_id, user_id=request.user.id)
484505

485-
return _build_save_schema(save, device, sync)
506+
return _build_save_schema(save, _syncs_for_save(save.id, device), device)
486507

487508

488509
@protected_route(router.put, "/{id}", [Scope.ASSETS_WRITE])
@@ -581,12 +602,7 @@ async def update_save(
581602
)
582603
db_device_handler.update_last_seen(device_id=device.id, user_id=request.user.id)
583604

584-
sync = None
585-
if device:
586-
sync = db_device_save_sync_handler.get_sync(
587-
device_id=device.id, save_id=db_save.id
588-
)
589-
return _build_save_schema(db_save, device, sync)
605+
return _build_save_schema(db_save, _syncs_for_save(db_save.id, device), device)
590606

591607

592608
@protected_route(
@@ -661,11 +677,11 @@ def track_save(
661677
)
662678

663679
device = _resolve_device(device_id, request.user.id)
664-
sync = db_device_save_sync_handler.set_untracked(
680+
db_device_save_sync_handler.set_untracked(
665681
device_id=device_id, save_id=id, untracked=False
666682
)
667683

668-
return _build_save_schema(save, device, sync)
684+
return _build_save_schema(save, _syncs_for_save(save.id, device), device)
669685

670686

671687
@protected_route(router.post, "/{id}/untrack", [Scope.DEVICES_WRITE])
@@ -683,8 +699,8 @@ def untrack_save(
683699
)
684700

685701
device = _resolve_device(device_id, request.user.id)
686-
sync = db_device_save_sync_handler.set_untracked(
702+
db_device_save_sync_handler.set_untracked(
687703
device_id=device_id, save_id=id, untracked=True
688704
)
689705

690-
return _build_save_schema(save, device, sync)
706+
return _build_save_schema(save, _syncs_for_save(save.id, device), device)

backend/handler/database/device_save_sync_handler.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sqlalchemy.orm import Session
66

77
from decorators.database import begin_session
8+
from models.device import Device
89
from models.device_save_sync import DeviceSaveSync
910

1011
from .base_handler import DBBaseHandler
@@ -40,6 +41,31 @@ def get_syncs_for_device_and_saves(
4041
)
4142
).all()
4243

44+
@begin_session
45+
def get_syncs_for_saves(
46+
self,
47+
save_ids: list[int],
48+
session: Session = None, # type: ignore
49+
) -> dict[int, list[tuple[DeviceSaveSync, str | None]]]:
50+
"""Fetch every device sync row for the given saves, grouped by save id.
51+
52+
Each row is paired with its device name so callers can attribute a save
53+
to the device that created it without triggering the lazy-raise
54+
``DeviceSaveSync.device`` relationship.
55+
"""
56+
if not save_ids:
57+
return {}
58+
rows = session.execute(
59+
select(DeviceSaveSync, Device.name)
60+
.join(Device, DeviceSaveSync.device_id == Device.id)
61+
.filter(DeviceSaveSync.save_id.in_(save_ids))
62+
.order_by(DeviceSaveSync.last_synced_at.desc(), DeviceSaveSync.device_id)
63+
).all()
64+
grouped: dict[int, list[tuple[DeviceSaveSync, str | None]]] = {}
65+
for sync, device_name in rows:
66+
grouped.setdefault(sync.save_id, []).append((sync, device_name))
67+
return grouped
68+
4369
@begin_session
4470
def upsert_sync(
4571
self,

backend/models/assets.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ class Save(RomAsset):
6565
emulator: Mapped[str | None] = mapped_column(String(length=50))
6666
slot: Mapped[str | None] = mapped_column(String(length=255))
6767
content_hash: Mapped[str | None] = mapped_column(String(length=32))
68+
origin_device_id: Mapped[str | None] = mapped_column(
69+
String(length=255),
70+
ForeignKey("devices.id", ondelete="SET NULL"),
71+
default=None,
72+
)
6873

6974
rom: Mapped[Rom] = relationship(lazy="joined", back_populates="saves")
7075
user: Mapped[User] = relationship(lazy="joined", back_populates="saves")

0 commit comments

Comments
 (0)