Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add conflict resolution based on last sync time #666

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion legendary/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ def sync_saves(self, args):
igame.save_path = save_path
self.core.lgd.set_installed_game(igame.app_name, igame)

res, (dt_l, dt_r) = self.core.check_savegame_state(igame.save_path, latest_save.get(igame.app_name))
res, (dt_l, dt_r) = self.core.check_savegame_state(igame.save_path, igame.save_timestamp, latest_save.get(igame.app_name))

if res == SaveGameStatus.NO_SAVE:
logger.info('No cloud or local savegame found.')
Expand All @@ -526,6 +526,30 @@ def sync_saves(self, args):
logger.info(f'Save game for "{igame.title}" is up to date, skipping...')
continue

if res == SaveGameStatus.CONFLICT and not (args.force_upload or args.force_download):
logger.info(f'Cloud save for "{igame.title}" is in conflict:')
logger.info(f'- Cloud save date: {dt_r.strftime("%Y-%m-%d %H:%M:%S")}')
logger.info(f'- Local save date: {dt_l.strftime("%Y-%m-%d %H:%M:%S")}')

if args.yes:
logger.warning('Run the command again with appropriate force parameter to effectively pick the save')
continue
else:
result = get_int_choice('Which saves should be kept? Type the number corresponding to preferred action (remote - 1/local - 2/cancel - 3)',
default=3, min_choice=1, max_choice=3)
if result == 1:
self.core.download_saves(igame.app_name, save_dir=igame.save_path, clean_dir=True,
manifest_name=latest_save[igame.app_name].manifest_name)
igame.save_timestamp = time.time()
self.core.lgd.set_installed_game(igame.app_name, igame)
elif result == 2:
self.core.upload_save(igame.app_name, igame.save_path, dt_l, args.disable_filters)
igame.save_timestamp = time.time()
self.core.lgd.set_installed_game(igame.app_name, igame)
else:
logger.info(f'Skipping action for: "{igame.title}"...')
continue

if (res == SaveGameStatus.REMOTE_NEWER and not args.force_upload) or args.force_download:
if res == SaveGameStatus.REMOTE_NEWER: # only print this info if not forced
logger.info(f'Cloud save for "{igame.title}" is newer:')
Expand All @@ -547,6 +571,8 @@ def sync_saves(self, args):
logger.info('Downloading remote savegame...')
self.core.download_saves(igame.app_name, save_dir=igame.save_path, clean_dir=True,
manifest_name=latest_save[igame.app_name].manifest_name)
igame.save_timestamp = time.time()
self.core.lgd.set_installed_game(igame.app_name, igame)
elif res == SaveGameStatus.LOCAL_NEWER or args.force_upload:
if res == SaveGameStatus.LOCAL_NEWER:
logger.info(f'Local save for "{igame.title}" is newer')
Expand All @@ -566,6 +592,8 @@ def sync_saves(self, args):
continue
logger.info('Uploading local savegame...')
self.core.upload_save(igame.app_name, igame.save_path, dt_l, args.disable_filters)
igame.save_timestamp = time.time()
self.core.lgd.set_installed_game(igame.app_name, igame)

def launch_game(self, args, extra):
app_name = self._resolve_aliases(args.app_name)
Expand Down
15 changes: 11 additions & 4 deletions legendary/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -961,7 +961,7 @@ def get_save_path(self, app_name, platform='Windows'):

return absolute_path

def check_savegame_state(self, path: str, save: SaveGameFile) -> (SaveGameStatus, (datetime, datetime)):
def check_savegame_state(self, path: str, sync_timestamp: Optional[float], save: SaveGameFile) -> tuple[SaveGameStatus, tuple[datetime, datetime]]:
latest = 0
for _dir, _, _files in os.walk(path):
for _file in _files:
Expand All @@ -971,16 +971,23 @@ def check_savegame_state(self, path: str, save: SaveGameFile) -> (SaveGameStatus
if not latest and not save:
return SaveGameStatus.NO_SAVE, (None, None)

# timezones are fun!
dt_local = datetime.fromtimestamp(latest).replace(tzinfo=self.local_timezone).astimezone(timezone.utc)
dt_local = datetime.fromtimestamp(latest, tz=timezone.utc)
if not save:
return SaveGameStatus.LOCAL_NEWER, (dt_local, None)

dt_remote = datetime.strptime(save.manifest_name, '%Y.%m.%d-%H.%M.%S.manifest').replace(tzinfo=timezone.utc)
if not latest:
return SaveGameStatus.REMOTE_NEWER, (None, dt_remote)

self.log.debug(f'Local save date: {str(dt_local)}, Remote save date: {str(dt_remote)}')
dt_sync_time = datetime.fromtimestamp(sync_timestamp or 0, tz=timezone.utc)
self.log.debug(f'Local save date: {str(dt_local)}, Remote save date: {str(dt_remote)}, Last sync: {str(dt_sync_time)}')

# Pickup possible conflict
if sync_timestamp:
remote_updated = (dt_remote - dt_sync_time).total_seconds() > 60
local_updated = (dt_local - dt_sync_time).total_seconds() > 60
if remote_updated and local_updated:
return SaveGameStatus.CONFLICT, (dt_local, dt_remote)

# Ideally we check the files themselves based on manifest,
# this is mostly a guess but should be accurate enough.
Expand Down
3 changes: 3 additions & 0 deletions legendary/models/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ class InstalledGame:
uninstaller: Optional[Dict] = None
requires_ot: bool = False
save_path: Optional[str] = None
save_timestamp: Optional[float] = None

@classmethod
def from_json(cls, json):
Expand All @@ -212,6 +213,7 @@ def from_json(cls, json):
tmp.requires_ot = json.get('requires_ot', False)
tmp.is_dlc = json.get('is_dlc', False)
tmp.save_path = json.get('save_path', None)
tmp.save_timestamp = json.get('save_timestamp', None)
tmp.manifest_path = json.get('manifest_path', '')
tmp.needs_verification = json.get('needs_verification', False) is True
tmp.platform = json.get('platform', 'Windows')
Expand All @@ -237,6 +239,7 @@ class SaveGameStatus(Enum):
REMOTE_NEWER = 1
SAME_AGE = 2
NO_SAVE = 3
CONFLICT = 4


class VerifyResult(Enum):
Expand Down
Loading