From 6f030e79ce02385525ffb2c9289ee7241f25b3b8 Mon Sep 17 00:00:00 2001 From: tretrauit Date: Tue, 2 Jan 2024 15:27:40 +0700 Subject: [PATCH] feat(hsr): download & install updates WIP WIP WIP, probably will crash at some point. Also the code in paths isn't working, so i'm merging them to constants in the next commit --- vollerei/cli/hsr.py | 82 +++++++++++++++++++++++- vollerei/cli/utils.py | 32 ++++++++++ vollerei/common/functions.py | 11 ++-- vollerei/exceptions/game.py | 24 ++++++++ vollerei/hsr/constants.py | 2 +- vollerei/hsr/launcher/api.py | 2 +- vollerei/hsr/launcher/game.py | 113 +++++++++++++++++++++++++++------- vollerei/paths.py | 20 ++++-- vollerei/utils/__init__.py | 32 ++++++++++ vollerei/utils/linux.py | 4 +- 10 files changed, 283 insertions(+), 39 deletions(-) diff --git a/vollerei/cli/hsr.py b/vollerei/cli/hsr.py index fd9ace9..94f41a2 100644 --- a/vollerei/cli/hsr.py +++ b/vollerei/cli/hsr.py @@ -1,16 +1,21 @@ from cleo.commands.command import Command from cleo.helpers import option from platform import system +from vollerei.hsr.launcher.enums import GameChannel +from vollerei.paths import set_base_path, base_paths from vollerei.cli import utils from vollerei.exceptions.game import GameError from vollerei.hsr import Game, Patcher from vollerei.exceptions.patcher import PatcherError, PatchUpdateError from vollerei.hsr.patcher import PatchType +from tqdm import tqdm +import requests patcher = Patcher() default_options = [ + option("channel", "c", description="Game channel", flag=False, default="overseas"), option( "game-path", "g", @@ -19,6 +24,7 @@ default_options = [ default=".", ), option("patch-type", "p", description="Patch type", flag=False), + option("temporary-path", "t", description="Temporary path", flag=False), option("silent", "s", description="Silent mode"), option("noconfirm", "y", description="Do not ask for confirmation"), ] @@ -36,9 +42,17 @@ def callback( """ game_path = command.option("game-path") patch_type = command.option("patch-type") + channel = command.option("channel") silent = command.option("silent") noconfirm = command.option("noconfirm") - State.game: Game = Game(game_path) + temporary_path = command.option("temporary-path") + if isinstance(channel, str): + channel = GameChannel[channel.capitalize()] + elif isinstance(channel, int): + channel = GameChannel(channel) + State.game: Game = Game(game_path, temporary_path) + if channel: + State.game.channel_override = channel if patch_type is None: patch_type = PatchType.Jadeite elif isinstance(patch_type, str): @@ -212,7 +226,73 @@ class GetVersionCommand(Command): self.line_error(f"Couldn't get game version: {e}") +class UpdateCommand(Command): + name = "hsr update" + description = "Updates the local game if available" + options = default_options + [ + option( + "auto-repair", "R", description="Automatically repair the game if needed" + ), + option("pre-download", description="Pre-download the game if available"), + ] + + def handle(self): + callback(command=self) + auto_repair = self.option("auto-repair") + pre_download = self.option("pre-download") + if auto_repair: + self.line("Auto-repair is enabled.") + progress = utils.ProgressIndicator(self) + progress.start("Checking for updates... ") + try: + update_diff = State.game.get_update(pre_download=pre_download) + game_info = State.game.get_remote_game(pre_download=pre_download) + except Exception as e: + progress.finish( + f"Update checking failed with following error: {e} ({e.__context__})" + ) + return + if update_diff is None: + progress.finish("Game is already updated.") + return + progress.finish("Update available.") + self.line( + f"The current version is: {State.game.get_version_str()}" + ) + self.line( + f"The latest version is: {game_info.latest.version}" + ) + if not self.confirm("Do you want to update the game?"): + self.line("Update aborted.") + return + self.line("Downloading update package...") + out_path = State.game._cache.joinpath(update_diff.name) + try: + download_result = utils.download( + update_diff.path, out_path, file_len=update_diff.size + ) + except Exception as e: + self.line_error(f"Couldn't download update: {e}") + return + + if not download_result: + self.line_error("Download failed.") + return + self.line("Download completed.") + progress = utils.ProgressIndicator(self) + progress.start("Applying update package...") + try: + State.game.apply_update_archive(out_path, auto_repair=auto_repair) + except Exception as e: + progress.finish( + f"Couldn't apply update: {e} ({e.__context__})" + ) + return + progress.finish("Update applied.") + + commands = [ + UpdateCommand(), PatchTypeCommand(), UpdatePatchCommand(), PatchInstallCommand(), diff --git a/vollerei/cli/utils.py b/vollerei/cli/utils.py index fe25cb4..3f64a1e 100644 --- a/vollerei/cli/utils.py +++ b/vollerei/cli/utils.py @@ -1,6 +1,9 @@ +import requests from cleo.commands.command import Command +from pathlib import Path from threading import Thread from time import sleep +from tqdm import tqdm no_confirm = False @@ -59,6 +62,35 @@ class ProgressIndicator: self.progress.finish(message=message, reset_indicator=reset_indicator) +def download(url, out: Path, file_len: int = None, overwrite: bool = False) -> bool: + if overwrite: + out.unlink(missing_ok=True) + headers = {} + if out.exists(): + cur_len = (out.stat()).st_size + headers |= {"Range": f"bytes={cur_len}-{file_len if file_len else ''}"} + else: + out.touch() + # Streaming, so we can iterate over the response. + response = requests.get(url=url, headers=headers, stream=True) + response.raise_for_status() + if response.status_code == 416: + return + # Sizes in bytes. + total_size = int(response.headers.get("content-length", 0)) + block_size = 32768 + + with tqdm(total=total_size, unit="KB", unit_scale=True) as progress_bar: + with out.open("ab") as file: + for data in response.iter_content(block_size): + progress_bar.update(len(data)) + file.write(data) + + if total_size != 0 and progress_bar.n != total_size: + return False + return True + + def msg(*args, **kwargs): """ Print but silentable diff --git a/vollerei/common/functions.py b/vollerei/common/functions.py index fbcbed7..b2ff44e 100644 --- a/vollerei/common/functions.py +++ b/vollerei/common/functions.py @@ -1,4 +1,4 @@ -import concurrent +import concurrent.futures from io import IOBase import json from pathlib import Path @@ -65,13 +65,12 @@ def apply_update_archive( patchpath.unlink(missing_ok=True) # Extract patch file archive.extract(patch_file, game.temppath) - old_suffix = file.suffix - file = file.rename(file.with_suffix(".bak")) + file = file.rename(file.with_suffix(file.suffix + ".bak")) try: - _hdiff.patch_file(file, file.with_suffix(old_suffix), patchpath) + _hdiff.patch_file(file, file.with_suffix(""), patchpath) except HPatchZPatchError: # Let the game download the file. - file.rename(file.with_suffix(old_suffix)) + file.rename(file.with_suffix("")) return finally: patchpath.unlink() @@ -86,7 +85,7 @@ def apply_update_archive( # Repair file if not auto_repair: raise e - game._repair_file(game.path.joinpath(file)) + game.repair_file(game.path.joinpath(file)) # Multi-threaded patching patch_jobs = [] diff --git a/vollerei/exceptions/game.py b/vollerei/exceptions/game.py index 7019dfc..01d7935 100644 --- a/vollerei/exceptions/game.py +++ b/vollerei/exceptions/game.py @@ -13,6 +13,30 @@ class GameNotInstalledError(GameError): pass +class GameAlreadyUpdatedError(GameError): + """Game is already updated.""" + + pass + + +class GameAlreadyInstalledError(GameError): + """Game is already installed.""" + + pass + + +class ScatteredFilesNotAvailableError(GameError): + """Scattered files are not available.""" + + pass + + +class GameNotUpdatedError(GameError): + """Game is not updated.""" + + pass + + class PreDownloadNotAvailable(GameError): """Pre-download version is not available.""" diff --git a/vollerei/hsr/constants.py b/vollerei/hsr/constants.py index 5a515b1..6617df9 100644 --- a/vollerei/hsr/constants.py +++ b/vollerei/hsr/constants.py @@ -20,7 +20,7 @@ class LAUNCHER_API: } -LATEST_VERSION = (1, 1, 0) +LATEST_VERSION = (1, 6, 0) MD5SUMS = { "1.0.5": { "cn": { diff --git a/vollerei/hsr/launcher/api.py b/vollerei/hsr/launcher/api.py index d6482d1..69598f9 100644 --- a/vollerei/hsr/launcher/api.py +++ b/vollerei/hsr/launcher/api.py @@ -17,7 +17,7 @@ def get_resource(channel: GameChannel = GameChannel.Overseas) -> Resource: Returns: Resource: Game resource information. """ - resource_path: dict + resource_path: dict = None match channel: case GameChannel.Overseas: resource_path = LAUNCHER_API.OS diff --git a/vollerei/hsr/launcher/game.py b/vollerei/hsr/launcher/game.py index ddda12d..9fcc7e9 100644 --- a/vollerei/hsr/launcher/game.py +++ b/vollerei/hsr/launcher/game.py @@ -5,11 +5,17 @@ from pathlib import Path from vollerei.abc.launcher.game import GameABC from vollerei.common import ConfigFile, functions from vollerei.common.api import resource -from vollerei.exceptions.game import GameNotInstalledError, PreDownloadNotAvailable +from vollerei.exceptions.game import ( + GameAlreadyUpdatedError, + GameNotInstalledError, + PreDownloadNotAvailable, + ScatteredFilesNotAvailableError, +) from vollerei.hsr.constants import MD5SUMS from vollerei.hsr.launcher.enums import GameChannel from vollerei.hsr.launcher import api -from vollerei.paths import cache_path +from vollerei import paths +from vollerei.utils import download class Game(GameABC): @@ -21,16 +27,20 @@ class Game(GameABC): the property `channel_override` to the channel you want to use. """ - def __init__(self, path: PathLike = None): + def __init__(self, path: PathLike = None, cache_path: PathLike = None): self._path: Path | None = Path(path) if path else None - self._cache: Path = cache_path.joinpath("game") + if not cache_path: + cache_path = paths.cache_path + cache_path = Path(cache_path) + self._cache: Path = cache_path.joinpath("game/hsr/") + self._cache.mkdir(parents=True, exist_ok=True) self._version_override: tuple[int, int, int] | None = None self._channel_override: GameChannel | None = None @property def version_override(self) -> tuple[int, int, int] | None: """ - Override the game version. + Overrides the game version. This can be useful if you want to override the version of the game and additionally working around bugs. @@ -46,7 +56,7 @@ class Game(GameABC): @property def channel_override(self) -> GameChannel | None: """ - Override the game channel. + Overrides the game channel. Because game channel detection isn't implemented yet, you may need to use this for some functions to work. @@ -65,7 +75,7 @@ class Game(GameABC): @property def path(self) -> Path | None: """ - Path to the game folder. + Paths to the game folder. """ return self._path @@ -75,7 +85,7 @@ class Game(GameABC): def data_folder(self) -> Path: """ - Path to the game data folder. + Paths to the game data folder. """ try: return self._path.joinpath("StarRail_Data") @@ -84,7 +94,7 @@ class Game(GameABC): def is_installed(self) -> bool: """ - Check if the game is installed. + Checks if the game is installed. Returns: bool: True if the game is installed, False otherwise. @@ -103,7 +113,7 @@ class Game(GameABC): def get_version_config(self) -> tuple[int, int, int]: """ - Get the current installed game version from config.ini. + Gets the current installed game version from config.ini. Using this is not recommended, as only official launcher creates and uses this file, instead you should use `get_version()`. @@ -130,9 +140,22 @@ class Game(GameABC): return (0, 0, 0) return version + def set_version_config(self): + """ + Sets the current installed game version to config.ini. + + This method is meant to keep compatibility with the official launcher only. + """ + cfg_file = self._path.joinpath("config.ini") + if not cfg_file.exists(): + raise FileNotFoundError("config.ini not found.") + cfg = ConfigFile(cfg_file) + cfg.set("General", "game_version", self.get_version_str()) + cfg.save() + def get_version(self) -> tuple[int, int, int]: """ - Get the current installed game version. + Gets the current installed game version. Credits to An Anime Team for the code that does the magic: https://github.com/an-anime-team/anime-game-core/blob/main/src/games/star_rail/game.rs#L49 @@ -197,18 +220,18 @@ class Game(GameABC): # Fallback to config.ini return self.get_version_config() - def version_as_str(self, version: tuple[int, int, int]) -> str: + def get_version_str(self) -> str: """ - Convert a version tuple to a string. + Gets the current installed game version as a string. Returns: str: The version as a string. """ - return ".".join(str(i) for i in version) + return ".".join(str(i) for i in self.get_version()) def get_channel(self) -> GameChannel: """ - Get the current game channel. + Gets the current game channel. Only works for Star Rail version 1.0.5, other versions will return the overridden channel or None if no channel is overridden. @@ -234,9 +257,15 @@ class Game(GameABC): case "os": return GameChannel.Overseas else: - return + # if self._path.joinpath("StarRail_Data").is_dir(): + # return GameChannel.Overseas + # elif self._path.joinpath("StarRail_Data").exists(): + # return GameChannel.China + # No reliable method there, so we'll just return the overridden channel or + # fallback to overseas. + return self._channel_override or GameChannel.Overseas - def _get_game(self, pre_download: bool) -> resource.Game: + def get_remote_game(self, pre_download: bool) -> resource.Game: channel = self._channel_override or self.get_channel() if pre_download: game = api.get_resource(channel=channel).pre_download_game @@ -247,24 +276,57 @@ class Game(GameABC): def get_update(self, pre_download: bool = False) -> resource.Diff | None: """ - Get the current game update. + Gets the current game update. Returns a `Diff` object that contains the update information or None if the game is not installed or already up-to-date. """ if not self.is_installed(): return None - version = self.version_as_str(self._version_override or self.get_version()) - for diff in self._get_game(pre_download=pre_download).diffs: + version = ( + ".".join(x for x in self._version_override) + if self._version_override + else self.get_version_str() + ) + for diff in self.get_remote_game(pre_download=pre_download).diffs: if diff.version == version: return diff return None + def repair_file(self, file: PathLike, pre_download: bool = False) -> None: + """ + Repairs a game file. + + Args: + file (PathLike): The file to repair. + """ + if not self.is_installed(): + raise GameNotInstalledError("Game is not installed.") + file = Path(file) + if not file.is_relative_to(self._path): + raise ValueError("File is not in the game folder.") + game = self.get_remote_game(pre_download=pre_download) + if game.latest.decompressed_path is None: + raise ScatteredFilesNotAvailableError("Scattered files are not available.") + url = game.latest.decompressed_path + "/" + file.relative_to(self._path) + # Backup the file + file.rename(file.with_suffix(file.suffix + ".bak")) + try: + # Download the file + download(url, file.with_suffix("")) + except Exception: + # Restore the backup + file.rename(file.with_suffix("")) + raise + else: + # Delete the backup + file.unlink(missing_ok=True) + def apply_update_archive( self, archive_file: PathLike | IOBase, auto_repair: bool = True ) -> None: """ - Apply an update archive to the game, it can be the game update or a + Applies an update archive to the game, it can be the game update or a voicepack update. `archive_file` can be a path to the archive file or a file-like object, @@ -285,12 +347,13 @@ class Game(GameABC): archive_file = Path(archive_file) # Hello hell again, dealing with HDiffPatch and all the things again. functions.apply_update_archive(self, archive_file, auto_repair=auto_repair) + self.set_version_config() def install_update( self, update_info: resource.Diff = None, auto_repair: bool = True ): """ - Install an update from a `Diff` object. + Installs an update from a `Diff` object. You may want to download the update manually and pass it to `apply_update_archive()` instead for better control. @@ -302,4 +365,8 @@ class Game(GameABC): raise GameNotInstalledError("Game is not installed.") if not update_info: update_info = self.get_update() - pass + if not update_info or update_info.version == self.get_version_str(): + raise GameAlreadyUpdatedError("Game is already updated.") + archive_file = self._cache.joinpath(update_info.name) + download(update_info.path, archive_file) + self.apply_update_archive(archive_file=archive_file, auto_repair=auto_repair) diff --git a/vollerei/paths.py b/vollerei/paths.py index 9381402..d9d3085 100644 --- a/vollerei/paths.py +++ b/vollerei/paths.py @@ -19,11 +19,9 @@ class Paths: @staticmethod def set_base_path(path: PathLike): path = Path(path) - Paths.base_paths = PlatformDirs( - "vollerei", "tretrauit", roaming=True, base_path=path - ) - Paths.cache_path = Paths.base_paths.site_cache_path - Paths.data_path = Paths.base_paths.site_data_path + Paths.base_paths = path + Paths.cache_path = Paths.base_paths.joinpath("Cache") + Paths.data_path = Paths.base_paths Paths.tools_data_path = Paths.data_path.joinpath("tools") Paths.tools_cache_path = Paths.cache_path.joinpath("tools") Paths.launcher_cache_path = Paths.cache_path.joinpath("launcher") @@ -38,3 +36,15 @@ tools_data_path = Paths.tools_data_path tools_cache_path = Paths.tools_cache_path launcher_cache_path = Paths.launcher_cache_path utils_cache_path = Paths.utils_cache_path + + +def set_base_path(path: PathLike): + Paths.set_base_path(path) + global base_paths, cache_path, data_path, tools_data_path, tools_cache_path, launcher_cache_path, utils_cache_path + base_paths = Paths.base_paths + cache_path = Paths.cache_path + data_path = Paths.data_path + tools_data_path = Paths.tools_data_path + tools_cache_path = Paths.tools_cache_path + launcher_cache_path = Paths.launcher_cache_path + utils_cache_path = Paths.utils_cache_path diff --git a/vollerei/utils/__init__.py b/vollerei/utils/__init__.py index 8ac6bb2..cf2e3f2 100644 --- a/vollerei/utils/__init__.py +++ b/vollerei/utils/__init__.py @@ -52,6 +52,38 @@ __all__ = [ ] +def download( + url: str, out: Path, file_len: int = None, overwrite: bool = False +) -> None: + """ + Download to a path. + + Args: + url (str): URL to download from. + path (Path): Path to download to. + """ + if overwrite: + out.unlink(missing_ok=True) + headers = {} + if out.exists(): + cur_len = (out.stat()).st_size + headers |= {"Range": f"bytes={cur_len}-{file_len if file_len else ''}"} + else: + out.touch() + # Streaming, so we can iterate over the response. + response = requests.get(url=url, headers=headers, stream=True) + response.raise_for_status() + if response.status == 416: + return + # Sizes in bytes. + block_size = 32768 + + with out.open("ab") as file: + for data in response.iter_content(block_size): + file.write(data) + return True + + def download_and_extract(url: str, path: Path) -> None: """ Download and extract a zip file to a path. diff --git a/vollerei/utils/linux.py b/vollerei/utils/linux.py index 4f29207..fbc59c8 100644 --- a/vollerei/utils/linux.py +++ b/vollerei/utils/linux.py @@ -29,11 +29,11 @@ def write_text(text, path: str | Path): """Write text to a file using pkexec (friendly gui)""" if isinstance(path, Path): path = str(path) - exec_su(f'pkexec tee "{path}"', stdin=text) + exec_su(f'tee "{path}"', stdin=text) def append_text(text, path: str | Path): """Append text to a file using pkexec (friendly gui)""" if isinstance(path, Path): path = str(path) - exec_su(f'pkexec tee -a "{path}"', stdin=text) + exec_su(f'tee -a "{path}"', stdin=text)