[cli/core/lfs] Add support for mixing platforms

This commit is contained in:
derrod 2021-12-01 20:57:43 +01:00
parent ee3f9a3e07
commit f280d53496
3 changed files with 119 additions and 91 deletions

View file

@ -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='<Platform>',
type=str, help='Platform override for download (also sets --no-install)')
install_parser.add_argument('--platform', dest='platform', action='store', metavar='<Platform>',
type=str, help='Platform override for download')
install_parser.add_argument('--prefix', dest='file_prefix', action='append', metavar='<prefix>',
help='Only fetch files whose path starts with <prefix> (case insensitive)')
install_parser.add_argument('--exclude', dest='file_exclude_prefix', action='append', metavar='<prefix>',
@ -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='<Platform>',
list_parser.add_argument('--platform', dest='platform', action='store', metavar='<Platform>', 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='<Platform>',
type=str, help='Platform override for download (disables install)')
list_files_parser.add_argument('--platform', dest='platform', action='store', metavar='<Platform>',
type=str, help='Platform override for download', default='Windows')
list_files_parser.add_argument('--manifest', dest='override_manifest', action='store', metavar='<uri>',
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='<Platform>',
type=str, help='Platform override for download', default='Windows')
args, extra = parser.parse_known_args()

View file

@ -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)

View file

@ -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)