diff --git a/README.md b/README.md index 82f4a62..b0ad5f9 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ since I want to support other anime games and the launcher code is very messy, t - [x] Download the game update (including pre-downloads if available) - [x] Get the game version - [x] Get installed voicepacks -- [ ] Installation +- [x] Installation - [x] Patch the game for unsupported platforms (with telemetry checking) - [x] Repair the game (Smarter than the official launcher!) - [x] Update the game diff --git a/vollerei/cli/hsr.py b/vollerei/cli/hsr.py index 34070f5..d1205b4 100644 --- a/vollerei/cli/hsr.py +++ b/vollerei/cli/hsr.py @@ -80,6 +80,23 @@ def callback( command.add_style("warn", fg="yellow") +def set_version_config(self: Command): + self.line("Setting version config... ") + try: + State.game.set_version_config() + except Exception as e: + self.line_error(f"Couldn't set version config: {e}") + self.line_error( + "This won't affect the overall experience, but if you're using the official launcher" + ) + self.line_error( + "you may have to edit the file 'config.ini' manually to reflect the latest version." + ) + self.line( + f"The game has been updated to version: {State.game.get_version_str()}" + ) + + class VoicepackListInstalled(Command): name = "hsr voicepack list-installed" description = "Get the installed voicepacks" @@ -198,8 +215,7 @@ class VoicepackUpdateAll(Command): progress.finish( f"Update applied for language {remote_voicepack.language.name}." ) - self.line("Setting version config... ") - State.game.set_version_config() + set_version_config(self=self) self.line( f"The game has been updated to version: {State.game.get_version_str()}" ) @@ -407,6 +423,72 @@ class GetVersionCommand(Command): self.line_error(f"Couldn't get game version: {e}") +class InstallCommand(Command): + name = "hsr install" + description = ( + "Installs the latest version of the game to the specified path (default: current directory). " + + "Note that this will not install the default voicepack (English), you need to install it manually." + ) + options = default_options + [ + option("pre-download", description="Pre-download the game if available"), + ] + + def handle(self): + callback(command=self) + pre_download = self.option("pre-download") + progress = utils.ProgressIndicator(self) + progress.start("Fetching install package information... ") + try: + game_info = State.game.get_remote_game(pre_download=pre_download) + except Exception as e: + progress.finish( + f"Fetching failed with following error: {e} \n{traceback.format_exc()}" + ) + return + progress.finish( + "Installation information fetched successfully." + ) + if not self.confirm("Do you want to install the game?"): + self.line("Installation aborted.") + return + self.line("Downloading install package...") + first_pkg_out_path = None + for game_pkg in game_info.major.game_pkgs: + out_path = State.game.cache.joinpath(PurePath(game_pkg.url).name) + if not first_pkg_out_path: + first_pkg_out_path = out_path + try: + download_result = utils.download( + game_pkg.url, out_path, file_len=game_pkg.size + ) + except Exception as e: + self.line_error( + f"Couldn't download install package: {e}" + ) + return + if not download_result: + self.line_error("Download failed.") + return + self.line("Download completed.") + progress = utils.ProgressIndicator(self) + progress.start("Installing package...") + try: + State.game.install_archive(first_pkg_out_path) + except Exception as e: + progress.finish( + f"Couldn't install package: {e} \n{traceback.format_exc()}" + ) + return + progress.finish("Package applied for the base game.") + self.line("Setting version config... ") + State.game.version_override = game_info.major.version + set_version_config() + State.game.version_override = None + self.line( + f"The game has been installed to version: {State.game.get_version_str()}" + ) + + class UpdateCommand(Command): name = "hsr update" description = "Updates the local game if available" @@ -517,8 +599,9 @@ class UpdateCommand(Command): f"Update applied for language {remote_voicepack.language.name}." ) self.line("Setting version config... ") + State.game.version_override = game_info.major.version + set_version_config() State.game.version_override = None - State.game.set_version_config() self.line( f"The game has been updated to version: {State.game.get_version_str()}" ) @@ -643,6 +726,28 @@ class UpdateDownloadCommand(Command): self.line("Download completed.") +class ApplyInstallArchive(Command): + name = "hsr install apply-archive" + description = "Applies the install archive" + arguments = [argument("path", description="Path to the install archive")] + options = default_options + + def handle(self): + callback(command=self) + install_archive = self.argument("path") + progress = utils.ProgressIndicator(self) + progress.start("Applying install package...") + try: + State.game.install_archive(install_archive) + except Exception as e: + progress.finish( + f"Couldn't apply package: {e} \n{traceback.format_exc()}" + ) + return + progress.finish("Package applied.") + set_version_config(self=self) + + class ApplyUpdateArchive(Command): name = "hsr update apply-archive" description = "Applies the update archive to the local game" @@ -655,10 +760,8 @@ class ApplyUpdateArchive(Command): def handle(self): callback(command=self) - auto_repair = self.option("auto-repair") update_archive = self.argument("path") - if auto_repair: - self.line("Auto-repair is enabled.") + auto_repair = self.option("auto-repair") progress = utils.ProgressIndicator(self) progress.start("Applying update package...") try: @@ -669,25 +772,14 @@ class ApplyUpdateArchive(Command): ) return progress.finish("Update applied.") - self.line("Setting version config... ") - try: - State.game.set_version_config() - except Exception as e: - self.line_error(f"Couldn't set version config: {e}") - self.line_error( - "This won't affect the overall experience, but if you're using the official launcher" - ) - self.line_error( - "you may have to edit the file 'config.ini' manually to reflect the latest version." - ) - self.line( - f"The game has been updated to version: {State.game.get_version_str()}" - ) + set_version_config() commands = [ + ApplyInstallArchive, ApplyUpdateArchive, GetVersionCommand, + InstallCommand, PatchCommand, PatchInstallCommand, PatchTelemetryCommand, diff --git a/vollerei/common/functions.py b/vollerei/common/functions.py index 86f1261..39ce34f 100644 --- a/vollerei/common/functions.py +++ b/vollerei/common/functions.py @@ -10,6 +10,7 @@ from vollerei.abc.launcher.game import GameABC from vollerei.common.api import resource from vollerei.exceptions.game import ( RepairError, + GameAlreadyInstalledError, GameNotInstalledError, ScatteredFilesNotAvailableError, ) @@ -146,6 +147,23 @@ def apply_update_archive( archive.close() +def install_archive(game: GameABC, archive_file: Path | IOBase) -> None: + """ + Applies an install archive to the game, it can be the game itself or a + voicepack one. + + Because this function is shared for all games, you should use the game's + `install_archive()` method instead, which additionally applies required + methods for that game. + """ + if game.is_installed(): + raise GameAlreadyInstalledError("Game is already installed.") + # It's literally 3 lines but okay + archive = py7zr.SevenZipFile(archive_file, "r") + archive.extractall(game.path) + archive.close() + + def _repair_file(game: GameABC, file: PathLike, game_info: resource.Main) -> None: # .replace("\\", "/") is needed because Windows uses backslashes :) relative_file = file.relative_to(game.path) diff --git a/vollerei/hsr/launcher/game.py b/vollerei/hsr/launcher/game.py index 7defe07..b776832 100644 --- a/vollerei/hsr/launcher/game.py +++ b/vollerei/hsr/launcher/game.py @@ -401,6 +401,22 @@ class Game(GameABC): """ functions.repair_game(self) + def install_archive(self, archive_file: PathLike | IOBase) -> None: + """ + Applies an install archive to the game, it can be the game itself or a + voicepack one. + + `archive_file` can be a path to the archive file or a file-like object, + like if you have very high amount of RAM and want to download the archive + to memory instead of disk, this can be useful for you. + + Args: + archive_file (PathLike | IOBase): The archive file. + """ + if not isinstance(archive_file, IOBase): + archive_file = Path(archive_file) + functions.install_archive(self, archive_file) + def apply_update_archive( self, archive_file: PathLike | IOBase, auto_repair: bool = True ) -> None: