diff --git a/vollerei/abc/launcher/game.py b/vollerei/abc/launcher/game.py index ebb5d45..d83941d 100644 --- a/vollerei/abc/launcher/game.py +++ b/vollerei/abc/launcher/game.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from os import PathLike from pathlib import Path from typing import Any +from vollerei.common.api import resource class GameABC(ABC): @@ -70,6 +71,22 @@ class GameABC(ABC): """ pass + def repair_file( + self, file: PathLike, pre_download: bool = False, game_info=None + ) -> None: + """ + Repairs a game file. + + This will automatically handle backup and restore the file if the repair + fails. + + Args: + file (PathLike): The file to repair. + pre_download (bool): Whether to get the pre-download version. + Defaults to False. + """ + pass + def get_version(self) -> tuple[int, int, int]: """ Get the game version @@ -89,3 +106,16 @@ class GameABC(ABC): Get the game channel """ pass + + def get_remote_game(self, pre_download: bool = False) -> resource.Game: + """ + Gets the current game information from remote. + + Args: + pre_download (bool): Whether to get the pre-download version. + Defaults to False. + + Returns: + A `Game` object that contains the game information. + """ + pass diff --git a/vollerei/cli/hsr.py b/vollerei/cli/hsr.py index 802bdc7..c651ee2 100644 --- a/vollerei/cli/hsr.py +++ b/vollerei/cli/hsr.py @@ -515,6 +515,36 @@ class UpdateCommand(Command): ) +class RepairCommand(Command): + name = "hsr repair" + description = "Tries to repair the local game" + options = default_options + + def handle(self): + callback(command=self) + self.line( + "This command will try to repair the game by downloading missing/broken files." + ) + self.line( + "There will be no progress available, so please be patient and just wait." + ) + if not self.confirm( + "Do you want to repair the game (this will take a long time!)?" + ): + self.line("Repairation aborted.") + return + progress = utils.ProgressIndicator(self) + progress.start("Repairing game files (no progress available)... ") + try: + State.game.repair_game() + except Exception as e: + progress.finish( + f"Repairation failed with following error: {e} ({e.__context__})" + ) + return + progress.finish("Repairation completed.") + + class UpdateDownloadCommand(Command): name = "hsr update download" description = "Download the update for the local game if available" @@ -645,6 +675,7 @@ commands = [ PatchInstallCommand, PatchTelemetryCommand, PatchTypeCommand, + RepairCommand, UpdatePatchCommand, UpdateCommand, UpdateDownloadCommand, diff --git a/vollerei/common/functions.py b/vollerei/common/functions.py index f2fd8a3..6adecdd 100644 --- a/vollerei/common/functions.py +++ b/vollerei/common/functions.py @@ -1,9 +1,11 @@ import concurrent.futures from io import IOBase import json +import hashlib from pathlib import Path import zipfile from vollerei.abc.launcher.game import GameABC +from vollerei.exceptions.game import RepairError from vollerei.utils import HDiffPatch, HPatchZPatchError @@ -133,3 +135,64 @@ def apply_update_archive( # Close the archive archive.close() + + +def repair_game( + game: GameABC, + pre_download: bool = False, +) -> None: + """ + Tries to repair the game by reading "pkg_version" file and downloading the + mismatched files from the server. + + Because this function is shared for all games, you should use the game's + `repair_game()` method instead, which additionally applies required + methods for that game. + """ + # Most code here are copied from worthless-launcher. + # worthless-launcher uses asyncio for multithreading while this one uses + # ThreadPoolExecutor, probably better for this use case. + game_info = game.get_remote_game(pre_download=pre_download) + pkg_version_file = game.path.joinpath("pkg_version") + pkg_version = [] + if not pkg_version_file.is_file(): + try: + game.repair_file("pkg_version", game_info=game_info) + except Exception as e: + raise RepairError( + "pkg_version file not found, most likely you need to download the full game again." + ) from e + else: + with pkg_version_file.open("r") as f: + for line in f.readlines(): + line = line.strip() + if not line: + continue + pkg_version.append(json.loads(line)) + repair_executor = concurrent.futures.ThreadPoolExecutor() + for file in pkg_version: + + def repair(target_file, game_info): + try: + game.repair_file(target_file, game_info=game_info) + except Exception as e: + print(f"Failed to repair {target_file['remoteName']}: {e}") + + def verify_and_repair(target_file, game_info): + file_path = game.path.joinpath(target_file["remoteName"]) + if not file_path.is_file(): + print(f"File {target_file['remoteName']} not found, repairing...") + repair(file_path, game_info) + return + with file_path.open("rb", buffering=0) as f: + file_hash = hashlib.file_digest(f, "md5").hexdigest() + if file_hash != target_file["md5"]: + print( + f"Hash mismatch for {target_file['remoteName']} ({file_hash}; expected {target_file['md5']})" + ) + repair(file_path, game_info) + + # Single-threaded for now + # verify_and_repair(file, game_info) + repair_executor.submit(verify_and_repair, file, game_info) + repair_executor.shutdown(wait=True) diff --git a/vollerei/exceptions/game.py b/vollerei/exceptions/game.py index 01d7935..1be812e 100644 --- a/vollerei/exceptions/game.py +++ b/vollerei/exceptions/game.py @@ -25,7 +25,13 @@ class GameAlreadyInstalledError(GameError): pass -class ScatteredFilesNotAvailableError(GameError): +class RepairError(GameError): + """Error occurred while repairing the game.""" + + pass + + +class ScatteredFilesNotAvailableError(RepairError): """Scattered files are not available.""" pass diff --git a/vollerei/hsr/launcher/game.py b/vollerei/hsr/launcher/game.py index 9a414fa..48595e0 100644 --- a/vollerei/hsr/launcher/game.py +++ b/vollerei/hsr/launcher/game.py @@ -3,6 +3,7 @@ from hashlib import md5 from io import IOBase from os import PathLike from pathlib import Path +from shutil import move, copyfile from vollerei.abc.launcher.game import GameABC from vollerei.common import ConfigFile, functions from vollerei.common.api import resource @@ -348,7 +349,12 @@ class Game(GameABC): return diff return None - def repair_file(self, file: PathLike, pre_download: bool = False) -> None: + def repair_file( + self, + file: PathLike, + pre_download: bool = False, + game_info: resource.Game = None, + ) -> None: """ Repairs a game file. @@ -365,22 +371,51 @@ class Game(GameABC): 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 not game_info: + game = self.get_remote_game(pre_download=pre_download) + else: + game = game_info if game.latest.decompressed_path is None: raise ScatteredFilesNotAvailableError("Scattered files are not available.") - url = game.latest.decompressed_path + "/" + file.relative_to(self._path) + # .replace("\\", "/") is needed because Windows uses backslashes :) + relative_file = file.relative_to(self._path) + url = ( + game.latest.decompressed_path + "/" + str(relative_file).replace("\\", "/") + ) # Backup the file - file.rename(file.with_suffix(file.suffix + ".bak")) + if file.exists(): + file.rename(file.with_suffix(file.suffix + ".bak")) + dest_file = file.with_suffix("") + else: + dest_file = file try: # Download the file - download(url, file.with_suffix("")) - except Exception: + temp_file = self.cache.joinpath(relative_file) + dest_file.parent.mkdir(parents=True, exist_ok=True) + print(f"Downloading repair file {url} to {temp_file}") + download(url, temp_file, overwrite=True, stream=True) + # Move the file + copyfile(temp_file, dest_file) + print("OK") + except Exception as e: # Restore the backup - file.rename(file.with_suffix("")) - raise + print("Failed", e) + if file.exists(): + file.rename(file.with_suffix("")) + raise e else: # Delete the backup - file.unlink(missing_ok=True) + if file.exists(): + file.unlink(missing_ok=True) + + def repair_game(self) -> None: + """ + Tries to repair the game by reading "pkg_version" file and downloading the + mismatched files from the server. + """ + if not self.is_installed(): + raise GameNotInstalledError("Game is not installed.") + functions.repair_game(self) def apply_update_archive( self, archive_file: PathLike | IOBase, auto_repair: bool = True diff --git a/vollerei/utils/__init__.py b/vollerei/utils/__init__.py index cf2e3f2..2bb6357 100644 --- a/vollerei/utils/__init__.py +++ b/vollerei/utils/__init__.py @@ -1,5 +1,6 @@ import requests import platform +import shutil from zipfile import ZipFile from io import BytesIO from pathlib import Path @@ -53,7 +54,11 @@ __all__ = [ def download( - url: str, out: Path, file_len: int = None, overwrite: bool = False + url: str, + out: Path, + file_len: int = None, + overwrite: bool = False, + stream: bool = True, ) -> None: """ Download to a path. @@ -65,22 +70,22 @@ def download( if overwrite: out.unlink(missing_ok=True) headers = {} + mode = "a+b" if out.exists(): cur_len = (out.stat()).st_size headers |= {"Range": f"bytes={cur_len}-{file_len if file_len else ''}"} else: + mode = "w+b" + out.parent.mkdir(parents=True, exist_ok=True) 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: + response = requests.get(url=url, headers=headers, stream=stream) + if response.status_code == 416: + print(f"File already downloaded: {out}") return - # Sizes in bytes. - block_size = 32768 - - with out.open("ab") as file: - for data in response.iter_content(block_size): - file.write(data) + response.raise_for_status() + with open(out, mode) as file: + shutil.copyfileobj(response.raw, file) return True