From 355b1107e6f9f41b4155fb9b58fa2ad329d05949 Mon Sep 17 00:00:00 2001 From: derrod Date: Thu, 21 Oct 2021 13:26:36 +0200 Subject: [PATCH] [cli/core] Add "clean-saves" command to remove obsolete/broken cloud save data --- legendary/cli.py | 16 ++++++++++-- legendary/core.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/legendary/cli.py b/legendary/cli.py index 69dd06c..ba16e21 100644 --- a/legendary/cli.py +++ b/legendary/cli.py @@ -403,6 +403,13 @@ class LegendaryCLI: logger.info(f'Downloading saves to "{self.core.get_default_install_dir()}"') self.core.download_saves(self._resolve_aliases(args.app_name)) + def clean_saves(self, args): + if not self.core.login(): + logger.error('Login failed! Cannot continue with download process.') + exit(1) + logger.info(f'Cleaning saves...') + self.core.clean_saves(self._resolve_aliases(args.app_name)) + def sync_saves(self, args): if not self.core.login(): logger.error('Login failed! Cannot continue with download process.') @@ -1727,6 +1734,7 @@ def main(): list_files_parser = subparsers.add_parser('list-files', help='List files in manifest') list_saves_parser = subparsers.add_parser('list-saves', help='List available cloud saves') download_saves_parser = subparsers.add_parser('download-saves', help='Download all cloud saves') + clean_saves_parser = subparsers.add_parser('clean-saves', help='Clean cloud saves') sync_saves_parser = subparsers.add_parser('sync-saves', help='Sync cloud saves') verify_parser = subparsers.add_parser('verify-game', help='Verify a game\'s local files') import_parser = subparsers.add_parser('import-game', help='Import an already installed game') @@ -1745,6 +1753,8 @@ def main(): help='Name of the app (optional)') download_saves_parser.add_argument('app_name', nargs='?', metavar='', default='', help='Name of the app (optional)') + clean_saves_parser.add_argument('app_name', nargs='?', metavar='', default='', + help='Name of the app (optional)') sync_saves_parser.add_argument('app_name', nargs='?', metavar='', default='', help='Name of the app (optional)') verify_parser.add_argument('app_name', help='Name of the app', metavar='') @@ -1979,8 +1989,8 @@ def main(): if args.subparser_name not in ('auth', 'list-games', 'list-installed', 'list-files', 'launch', 'download', 'uninstall', 'install', 'update', 'repair', 'list-saves', 'download-saves', 'sync-saves', - 'verify-game', 'import-game', 'egl-sync', 'status', - 'info', 'alias', 'cleanup'): + 'clean-saves', 'verify-game', 'import-game', 'egl-sync', + 'status', 'info', 'alias', 'cleanup'): print(parser.format_help()) # Print the main help *and* the help for all of the subcommands. Thanks stackoverflow! @@ -2031,6 +2041,8 @@ def main(): cli.download_saves(args) elif args.subparser_name == 'sync-saves': cli.sync_saves(args) + elif args.subparser_name == 'clean-saves': + cli.clean_saves(args) elif args.subparser_name == 'verify-game': cli.verify_game(args) elif args.subparser_name == 'import-game': diff --git a/legendary/core.py b/legendary/core.py index 2f30f9c..60f9075 100644 --- a/legendary/core.py +++ b/legendary/core.py @@ -849,6 +849,71 @@ class LegendaryCore: self.log.info('Successfully completed savegame download.') + def clean_saves(self, app_name=''): + savegames = self.egs.get_user_cloud_saves(app_name=app_name) + files = savegames['files'] + deletion_list = [] + used_chunks = set() + do_not_delete = set() + + # check if all chunks for manifests are there + for fname, f in files.items(): + if '.manifest' not in fname: + continue + + app_name = fname.split('/', 3)[2] + + self.log.info(f'Checking {app_name} "{fname.split("/", 2)[2]}"...') + # download manifest + r = self.egs.unauth_session.get(f['readLink']) + + if r.status_code == 404: + self.log.error('Manifest is missing! Marking for deletion.') + deletion_list.append(fname) + continue + elif r.status_code != 200: + self.log.warning(f'Download failed, status code: {r.status_code}. Skipping...') + do_not_delete.add(app_name) + continue + + if not r.content: + self.log.error('Manifest is empty! Marking for deletion.') + deletion_list.append(fname) + continue + + m = self.load_manifest(r.content) + # check if all required chunks are present + chunk_fnames = set() + for chunk in m.chunk_data_list.elements: + cpath_p = fname.split('/', 3)[:3] + cpath_p.append(chunk.path) + cpath = '/'.join(cpath_p) + if cpath not in files: + self.log.error(f'Chunk missing, marking manifest for deletion.') + deletion_list.append(fname) + break + else: + chunk_fnames.add(cpath) + else: + used_chunks |= chunk_fnames + + # check for orphaned chunks (not used in any manifests) + for fname, f in files.items(): + if fname in used_chunks or '.manifest' in fname: + continue + # skip chunks where orphan status could not be reliably determined + if fname.split('/', 3)[2] in do_not_delete: + continue + self.log.debug(f'Marking orphaned chunk {fname} for deletion.') + deletion_list.append(fname) + + self.log.info('Deleting unused/broken files...') + for fname in deletion_list: + self.log.debug(f'Deleting {fname}') + self.egs.delete_game_cloud_save_file(fname) + + self.log.info('Successfully completed savegame cleanup.') + def is_offline_game(self, app_name: str) -> bool: return self.lgd.config.getboolean(app_name, 'offline', fallback=False)