diff --git a/legendary/cli.py b/legendary/cli.py index 442fccf..3af80de 100644 --- a/legendary/cli.py +++ b/legendary/cli.py @@ -194,7 +194,7 @@ class LegendaryCLI: logger.info('Getting game list... (this may take a while)') games, dlc_list = self.core.get_game_and_dlc_list( - platform_override=args.platform_override, skip_ue=not args.include_ue, + platform=args.platform, skip_ue=not args.include_ue, force_refresh=args.force_refresh ) # Get information for games that cannot be installed through legendary (yet), such @@ -216,24 +216,24 @@ class LegendaryCLI: writer = csv.writer(stdout, dialect='excel-tab' if args.tsv else 'excel', lineterminator='\n') writer.writerow(['App name', 'App title', 'Version', 'Is DLC']) for game in games: - writer.writerow((game.app_name, game.app_title, game.app_version, False)) - for dlc in dlc_list[game.asset_info.catalog_item_id]: - writer.writerow((dlc.app_name, dlc.app_title, dlc.app_version, True)) + writer.writerow((game.app_name, game.app_title, game.app_version(args.platform), False)) + for dlc in dlc_list[game.asset_infos[args.platform].catalog_item_id]: + writer.writerow((dlc.app_name, dlc.app_title, dlc.app_version(args.platform), True)) return if args.json: _out = [] for game in games: _j = vars(game) - _j['dlcs'] = [vars(dlc) for dlc in dlc_list[game.asset_info.catalog_item_id]] + _j['dlcs'] = [vars(dlc) for dlc in dlc_list[game.asset_infos[args.platform].catalog_item_id]] _out.append(_j) return self._print_json(_out, args.pretty_json) print('\nAvailable games:') for game in games: - print(f' * {game.app_title.strip()} (App name: {game.app_name} | Version: {game.app_version})') - if not game.app_version: + print(f' * {game.app_title.strip()} (App name: {game.app_name} | Version: {game.app_version(args.platform)})') + if not game.app_version(args.platform): _store = game.third_party_store if _store == 'Origin': print(f' - This game has to be activated, installed, and launched via Origin, use ' @@ -242,9 +242,9 @@ class LegendaryCLI: print(f' ! This game has to be installed through third-party store ({_store}, not supported)') else: print(f' ! No version information (unknown cause)') - for dlc in dlc_list[game.asset_info.catalog_item_id]: - print(f' + {dlc.app_title} (App name: {dlc.app_name} | Version: {dlc.app_version})') - if not dlc.app_version: + for dlc in dlc_list[game.asset_infos[args.platform].catalog_item_id]: + print(f' + {dlc.app_title} (App name: {dlc.app_name} | Version: {dlc.app_version(args.platform)})') + if not dlc.app_version(args.platform): print(' ! This DLC is included in the game does not have to be downloaded separately') print(f'\nTotal: {len(games)}') @@ -263,7 +263,8 @@ class LegendaryCLI: versions = dict() for game in games: try: - versions[game.app_name] = self.core.get_asset(game.app_name).build_version + print(f'{game.title} (App name: {game.app_name}, platform: {game.platform})') + versions[game.app_name] = self.core.get_asset(game.app_name, platform=game.platform).build_version except ValueError: logger.warning(f'Metadata for "{game.app_name}" is missing, the game may have been removed from ' f'your account or not be in legendary\'s database yet, try rerunning the command ' @@ -315,7 +316,7 @@ class LegendaryCLI: print(f'\nTotal: {len(games)}') def list_files(self, args): - if args.platform_override: + if args.platform: args.force_download = True if not args.override_manifest and not args.app_name: @@ -340,7 +341,7 @@ class LegendaryCLI: if not game: logger.fatal(f'Could not fetch metadata for "{args.app_name}" (check spelling/account ownership)') exit(1) - manifest_data, _ = self.core.get_cdn_manifest(game, platform_override=args.platform_override) + manifest_data, _ = self.core.get_cdn_manifest(game, platform=args.platform) manifest = self.core.load_manifest(manifest_data) files = sorted(manifest.file_manifest_list.elements, @@ -678,6 +679,7 @@ class LegendaryCLI: args.app_name = self._resolve_aliases(args.app_name) if self.core.is_installed(args.app_name): igame = self.core.get_installed_game(args.app_name) + args.platform = igame.platform if igame.needs_verification and not args.repair_mode: logger.info('Game needs to be verified before updating, switching to repair mode...') args.repair_mode = True @@ -695,6 +697,10 @@ class LegendaryCLI: logger.error('Login failed! Cannot continue with download process.') exit(1) + # default to windows unless installed game or command line has overriden it + if not args.platform: + args.platform = 'Windows' + if args.file_prefix or args.file_exclude_prefix: args.no_install = True @@ -703,9 +709,6 @@ class LegendaryCLI: logger.error(f'Update requested for "{args.app_name}", but app not installed!') exit(1) - if args.platform_override: - args.no_install = True - game = self.core.get_game(args.app_name, update_meta=True) if not game: @@ -805,7 +808,7 @@ class LegendaryCLI: override_manifest=args.override_manifest, override_old_manifest=args.override_old_manifest, override_base_url=args.override_base_url, - platform_override=args.platform_override, + platform=args.platform, file_prefix_filter=args.file_prefix, file_exclude_filter=args.file_exclude_prefix, file_install_tag=args.install_tag, @@ -901,7 +904,8 @@ class LegendaryCLI: if dlcs and not args.skip_dlcs: print('The following DLCs are available for this game:') for dlc in dlcs: - print(f' - {dlc.app_title} (App name: {dlc.app_name}, version: {dlc.app_version})') + print(f' - {dlc.app_title} (App name: {dlc.app_name}, version: ' + f'{dlc.app_version(args.platform)})') print('Manually installing DLCs works the same; just use the DLC app name instead.') install_dlcs = not args.skip_dlcs @@ -1344,8 +1348,11 @@ class LegendaryCLI: info_items = dict(game=list(), manifest=list(), install=list()) InfoItem = namedtuple('InfoItem', ['name', 'json_name', 'value', 'json_value']) - game = self.core.get_game(app_name, update_meta=not args.offline) - if game and not self.core.asset_available(game): + if self.core.is_installed(app_name): + args.platform = self.core.get_installed_game(app_name).platform + + game = self.core.get_game(app_name, update_meta=not args.offline, platform=args.platform) + if game and not self.core.asset_available(game, platform=args.platform): logger.warning(f'Asset information for "{game.app_name}" is missing, the game may have been removed from ' f'your account or you may be logged in with a different account than the one used to build ' f'legendary\'s metadata database.') @@ -1369,11 +1376,11 @@ class LegendaryCLI: elif game: entitlements = self.core.egs.get_user_entitlements() # get latest metadata and manifest - if game.asset_info.catalog_item_id: - egl_meta = self.core.egs.get_game_info(game.asset_info.namespace, - game.asset_info.catalog_item_id) + if game.asset_infos[args.platform].catalog_item_id: + egl_meta = self.core.egs.get_game_info(game.asset_infos[args.platform].namespace, + game.asset_infos[args.platform].catalog_item_id) game.metadata = egl_meta - manifest_data, _ = self.core.get_cdn_manifest(game) + manifest_data, _ = self.core.get_cdn_manifest(game, args.platform) else: # Origin games do not have asset info, so fall back to info from metadata egl_meta = self.core.egs.get_game_info(game.metadata['namespace'], @@ -1384,7 +1391,10 @@ class LegendaryCLI: game_infos = info_items['game'] game_infos.append(InfoItem('App name', 'app_name', game.app_name, game.app_name)) game_infos.append(InfoItem('Title', 'title', game.app_title, game.app_title)) - game_infos.append(InfoItem('Latest version', 'version', game.app_version, game.app_version)) + game_infos.append(InfoItem('Latest version', 'version', game.app_version(args.platform), + game.app_version(args.platform))) + all_versions = {k: v.build_version for k,v in game.asset_infos.items()} + game_infos.append(InfoItem('All versions', 'platform_versions', all_versions, all_versions)) game_infos.append(InfoItem('Cloud saves supported', 'cloud_saves_supported', game.supports_cloud_saves, game.supports_cloud_saves)) if game.supports_cloud_saves: @@ -1447,6 +1457,7 @@ class LegendaryCLI: igame = self.core.get_installed_game(app_name) if igame: installation_info = info_items['install'] + installation_info.append(InfoItem('Platform', 'platform', igame.platform, igame.platform)) installation_info.append(InfoItem('Version', 'version', igame.version, igame.version)) disk_size_human = f'{igame.install_size / 1024 / 1024 / 1024:.02f} GiB' installation_info.append(InfoItem('Install size', 'disk_size', disk_size_human, @@ -1586,6 +1597,10 @@ class LegendaryCLI: print(f'- {item.name}:') for list_item in item.value: print(' + ', list_item) + elif isinstance(item.value, dict): + print(f'- {item.name}:') + for k, v in item.value.items(): + print(' + ', k, ':', v) else: print(f'- {item.name}: {item.value}') @@ -1809,8 +1824,8 @@ def main(): help='Only update, do not do anything if specified app is not installed') install_parser.add_argument('--dlm-debug', dest='dlm_debug', action='store_true', help='Set download manager and worker processes\' loglevel to debug') - install_parser.add_argument('--platform', dest='platform_override', action='store', metavar='', - type=str, help='Platform override for download (also sets --no-install)') + install_parser.add_argument('--platform', dest='platform', action='store', metavar='', + type=str, help='Platform override for download') install_parser.add_argument('--prefix', dest='file_prefix', action='append', metavar='', help='Only fetch files whose path starts with (case insensitive)') install_parser.add_argument('--exclude', dest='file_exclude_prefix', action='append', metavar='', @@ -1891,7 +1906,7 @@ def main(): launch_parser.add_argument('--no-wine', dest='no_wine', help=argparse.SUPPRESS, action='store_true', default=True) - list_parser.add_argument('--platform', dest='platform_override', action='store', metavar='', + list_parser.add_argument('--platform', dest='platform', action='store', metavar='', default='Windows', type=str, help='Override platform that games are shown for (e.g. Win32/Mac)') list_parser.add_argument('--include-ue', dest='include_ue', action='store_true', help='Also include Unreal Engine content (Engine/Marketplace) in list') @@ -1916,8 +1931,8 @@ def main(): list_files_parser.add_argument('--force-download', dest='force_download', action='store_true', help='Always download instead of using on-disk manifest') - list_files_parser.add_argument('--platform', dest='platform_override', action='store', metavar='', - type=str, help='Platform override for download (disables install)') + list_files_parser.add_argument('--platform', dest='platform', action='store', metavar='', + type=str, help='Platform override for download', default='Windows') list_files_parser.add_argument('--manifest', dest='override_manifest', action='store', metavar='', help='Manifest URL or path to use instead of the CDN one') list_files_parser.add_argument('--csv', dest='csv', action='store_true', help='Output in CSV format') @@ -1982,6 +1997,8 @@ def main(): help='Only print info available offline') info_parser.add_argument('--json', dest='json', action='store_true', help='Output information in JSON format') + info_parser.add_argument('--platform', dest='platform', action='store', metavar='', + type=str, help='Platform override for download', default='Windows') args, extra = parser.parse_known_args() diff --git a/legendary/core.py b/legendary/core.py index f2f851c..67c1a63 100644 --- a/legendary/core.py +++ b/legendary/core.py @@ -312,90 +312,93 @@ class LegendaryCore: if _aliases_enabled and (force or not self.lgd.aliases): self.lgd.generate_aliases() - def get_assets(self, update_assets=False, platform_override=None) -> List[GameAsset]: + def get_assets(self, update_assets=False, platform='Windows') -> List[GameAsset]: # do not save and always fetch list when platform is overridden - if platform_override: - return [GameAsset.from_egs_json(a) for a in - self.egs.get_game_assets(platform=platform_override)] - if not self.lgd.assets or update_assets: # if not logged in, return empty list if not self.egs.user: return [] - self.lgd.assets = [GameAsset.from_egs_json(a) for a in self.egs.get_game_assets()] - return self.lgd.assets + if self.lgd.assets: + assets = self.lgd.assets.copy() + else: + assets = dict() - def get_asset(self, app_name, update=False) -> GameAsset: - if update: - self.get_assets(update_assets=True) + assets.update({ + platform: [ + GameAsset.from_egs_json(a) for a in + self.egs.get_game_assets(platform=platform) + ] + }) + + self.lgd.assets = assets + + return self.lgd.assets[platform] + + def get_asset(self, app_name, platform='Windows', update=False) -> GameAsset: + if update or platform not in self.lgd.assets: + self.get_assets(update_assets=True, platform=platform) try: - return next(i for i in self.lgd.assets if i.app_name == app_name) + return next(i for i in self.lgd.assets[platform] if i.app_name == app_name) except StopIteration: raise ValueError def asset_valid(self, app_name) -> bool: - return any(i.app_name == app_name for i in self.lgd.assets) + # EGL sync is only supported for Windows titles so this is fine + return any(i.app_name == app_name for i in self.lgd.assets['Windows']) - def asset_available(self, game: Game) -> bool: + def asset_available(self, game: Game, platform='Windows') -> bool: # Just say yes for Origin titles if game.third_party_store: return True try: - asset = self.get_asset(game.app_name) + asset = self.get_asset(game.app_name, platform=platform) return asset is not None except ValueError: return False - def get_game(self, app_name, update_meta=False) -> Game: + def get_game(self, app_name, update_meta=False, platform='Windows') -> Game: if update_meta: - self.get_game_list(True) + self.get_game_list(True, platform=platform) return self.lgd.get_game_meta(app_name) - def get_game_list(self, update_assets=True) -> List[Game]: - return self.get_game_and_dlc_list(update_assets=update_assets)[0] + def get_game_list(self, update_assets=True, platform='Windows') -> List[Game]: + return self.get_game_and_dlc_list(update_assets=update_assets, platform=platform)[0] - def get_game_and_dlc_list(self, update_assets=True, platform_override=None, + def get_game_and_dlc_list(self, update_assets=True, platform='Windows', force_refresh=False, skip_ue=True) -> (List[Game], Dict[str, List[Game]]): _ret = [] _dlc = defaultdict(list) meta_updated = False - for ga in self.get_assets(update_assets=update_assets, - platform_override=platform_override): + for ga in self.get_assets(update_assets=update_assets, platform=platform): if ga.namespace == 'ue' and skip_ue: continue game = self.lgd.get_game_meta(ga.app_name) if update_assets and (not game or force_refresh or - (game and game.app_version != ga.build_version and not platform_override)): - if game and game.app_version != ga.build_version and not platform_override: + (game and game.app_version(platform) != ga.build_version)): + if game and game.app_version(platform) != ga.build_version: self.log.info(f'Updating meta for {game.app_name} due to build version mismatch') eg_meta = self.egs.get_game_info(ga.namespace, ga.catalog_item_id) - game = Game(app_name=ga.app_name, app_version=ga.build_version, - app_title=eg_meta['title'], asset_info=ga, metadata=eg_meta) + game.asset_infos[platform] = ga + game = Game(app_name=ga.app_name, app_title=eg_meta['title'], metadata=eg_meta, + asset_infos=game.asset_infos) - if not platform_override: - meta_updated = True - self.lgd.set_game_meta(game.app_name, game) - - # replace asset info with the platform specific one if override is used - if platform_override: - game.app_version = ga.build_version - game.asset_info = ga + meta_updated = True + self.lgd.set_game_meta(game.app_name, game) if game.is_dlc: _dlc[game.metadata['mainGameItem']['id']].append(game) elif not any(i['path'] == 'mods' for i in game.metadata.get('categories', [])): _ret.append(game) - if not platform_override: - self.update_aliases(force=meta_updated) - if meta_updated: - self._prune_metadata() + self.update_aliases(force=meta_updated) + if meta_updated: + self._prune_metadata() return _ret, _dlc @@ -440,8 +443,7 @@ class LegendaryCore: game = self.lgd.get_game_meta(libitem['appName']) if not game or force_refresh: eg_meta = self.egs.get_game_info(libitem['namespace'], libitem['catalogItemId']) - game = Game(app_name=libitem['appName'], app_version=None, - app_title=eg_meta['title'], asset_info=None, metadata=eg_meta) + game = Game(app_name=libitem['appName'], app_title=eg_meta['title'], metadata=eg_meta) self.lgd.set_game_meta(game.app_name, game) if game.is_dlc: @@ -453,7 +455,7 @@ class LegendaryCore: self.update_aliases(force=True) return _ret, _dlc - def get_dlc_for_game(self, app_name): + def get_dlc_for_game(self, app_name, platform='Windows'): game = self.get_game(app_name) if not game: self.log.warning(f'Metadata for {app_name} is missing!') @@ -462,8 +464,8 @@ class LegendaryCore: if game.is_dlc: # dlc shouldn't have DLC return [] - _, dlcs = self.get_game_and_dlc_list(update_assets=False) - return dlcs[game.asset_info.catalog_item_id] + _, dlcs = self.get_game_and_dlc_list(update_assets=False, platform=platform) + return dlcs[game.asset_infos['Windows'].catalog_item_id] def get_installed_list(self, include_dlc=False) -> List[InstalledGame]: if self.egl_sync_enabled: @@ -539,6 +541,11 @@ class LegendaryCore: install = self.lgd.get_installed_game(app_name) game = self.lgd.get_game_meta(app_name) + # Disable wine for non-Windows executables (e.g. native macOS) + if not install.platform.startswith('Win'): + disable_wine = True + wine_pfx = wine_bin = None + if executable_override or (executable_override := self.lgd.config.get(app_name, 'override_exe', fallback=None)): game_exe = executable_override.replace('\\', '/') exe_path = os.path.join(install.install_path, game_exe) @@ -584,10 +591,11 @@ class LegendaryCore: if install.requires_ot and not offline: self.log.info('Getting ownership token.') - ovt = self.egs.get_ownership_token(game.asset_info.namespace, - game.asset_info.catalog_item_id) + ovt = self.egs.get_ownership_token(game.asset_infos['Windows'].namespace, + game.asset_infos['Windows'].catalog_item_id) ovt_path = os.path.join(self.lgd.get_tmp_path(), - f'{game.asset_info.namespace}{game.asset_info.catalog_item_id}.ovt') + f'{game.asset_infos["Windows"].namespace}' + f'{game.asset_infos["Windows"].catalog_item_id}.ovt') with open(ovt_path, 'wb') as f: f.write(ovt) params.egl_parameters.append(f'-epicovt={ovt_path}') @@ -975,10 +983,9 @@ class LegendaryCore: old_bytes = self.lgd.load_manifest(app_name, igame.version) return old_bytes, igame.base_urls - def get_cdn_urls(self, game, platform_override=''): - platform = 'Windows' if not platform_override else platform_override - m_api_r = self.egs.get_game_manifest(game.asset_info.namespace, - game.asset_info.catalog_item_id, + def get_cdn_urls(self, game, platform='Windows'): + m_api_r = self.egs.get_game_manifest(game.asset_infos[platform].namespace, + game.asset_infos[platform].catalog_item_id, game.app_name, platform) # never seen this outside the launcher itself, but if it happens: PANIC! @@ -1000,8 +1007,8 @@ class LegendaryCore: return manifest_urls, base_urls - def get_cdn_manifest(self, game, platform_override=''): - manifest_urls, base_urls = self.get_cdn_urls(game, platform_override) + def get_cdn_manifest(self, game, platform='Windows'): + manifest_urls, base_urls = self.get_cdn_urls(game, platform) self.log.debug(f'Downloading manifest from {manifest_urls[0]} ...') r = self.egs.unauth_session.get(manifest_urls[0]) r.raise_for_status() @@ -1036,7 +1043,7 @@ class LegendaryCore: force: bool = False, disable_patching: bool = False, game_folder: str = '', override_manifest: str = '', override_old_manifest: str = '', override_base_url: str = '', - platform_override: str = '', file_prefix_filter: list = None, + platform: str = '', file_prefix_filter: list = None, file_exclude_filter: list = None, file_install_tag: list = None, dl_optimizations: bool = False, dl_timeout: int = 10, repair: bool = False, repair_use_latest: bool = False, @@ -1069,7 +1076,7 @@ class LegendaryCore: if _base_urls: base_urls = _base_urls else: - new_manifest_data, base_urls = self.get_cdn_manifest(game, platform_override) + new_manifest_data, base_urls = self.get_cdn_manifest(game, platform) # overwrite base urls in metadata with current ones to avoid using old/dead CDNs game.base_urls = base_urls # save base urls to game metadata @@ -1203,6 +1210,8 @@ class LegendaryCore: offline = game.metadata.get('customAttributes', {}).get('CanRunOffline', {}).get('value', 'true') ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false') + if file_install_tag is None: + file_install_tag = [] igame = InstalledGame(app_name=game.app_name, title=game.app_title, version=new_manifest.meta.build_version, prereq_info=prereq, manifest_path=override_manifest, base_urls=base_urls, @@ -1210,7 +1219,8 @@ class LegendaryCore: launch_parameters=new_manifest.meta.launch_command, can_run_offline=offline == 'true', requires_ot=ot == 'true', is_dlc=base_game is not None, install_size=anlres.install_size, - egl_guid=egl_guid, install_tags=file_install_tag) + egl_guid=egl_guid, install_tags=file_install_tag, + platform=platform) return dlm, anlres, igame @@ -1293,7 +1303,7 @@ class LegendaryCore: return os.path.expanduser(self.lgd.config.get('Legendary', 'install_dir', fallback='~/legendary')) def install_game(self, installed_game: InstalledGame) -> dict: - if self.egl_sync_enabled and not installed_game.is_dlc: + if self.egl_sync_enabled and not installed_game.is_dlc and installed_game.platform.startswith('Win'): if not installed_game.egl_guid: installed_game.egl_guid = str(uuid4()).replace('-', '').upper() prereq = self._install_game(installed_game) @@ -1426,7 +1436,8 @@ class LegendaryCore: def egl_get_exportable(self): if not self.egl.manifests: self.egl.read_manifests() - return [g for g in self.get_installed_list() if g.app_name not in self.egl.manifests] + return [g for g in self.get_installed_list() if + g.app_name not in self.egl.manifests and g.platform.startswith('Win')] def egl_import(self, app_name): if not self.asset_valid(app_name): @@ -1506,8 +1517,8 @@ class LegendaryCore: mf.write(manifest_data) mancpn = dict(FormatVersion=0, AppName=app_name, - CatalogItemId=lgd_game.asset_info.catalog_item_id, - CatalogNamespace=lgd_game.asset_info.namespace) + CatalogItemId=lgd_game.asset_infos['Windows'].catalog_item_id, + CatalogNamespace=lgd_game.asset_infos['Windows'].namespace) with open(os.path.join(egstore_folder, f'{egl_game.installation_guid}.mancpn', ), 'w') as mcpnf: json.dump(mancpn, mcpnf, indent=4, sort_keys=True) diff --git a/legendary/lfs/lgndry.py b/legendary/lfs/lgndry.py index 7a445d7..8604547 100644 --- a/legendary/lfs/lgndry.py +++ b/legendary/lfs/lgndry.py @@ -177,8 +177,8 @@ class LGDLFS: def assets(self): if self._assets is None: try: - self._assets = [GameAsset.from_json(a) for a in - json.load(open(os.path.join(self.path, 'assets.json')))] + tmp = json.load(open(os.path.join(self.path, 'assets.json'))) + self._assets = {k: [GameAsset.from_json(j) for j in v] for k, v in tmp.items()} except Exception as e: self.log.debug(f'Failed to load assets data: {e!r}') return None @@ -191,7 +191,7 @@ class LGDLFS: raise ValueError('Assets is none!') self._assets = assets - json.dump([a.__dict__ for a in self._assets], + json.dump({platform: [a.__dict__ for a in assets] for platform, assets in self._assets.items()}, open(os.path.join(self.path, 'assets.json'), 'w'), indent=2, sort_keys=True)