diff --git a/vollerei/abc/launcher/game.py b/vollerei/abc/launcher/game.py index d83941d..6256132 100644 --- a/vollerei/abc/launcher/game.py +++ b/vollerei/abc/launcher/game.py @@ -87,6 +87,28 @@ class GameABC(ABC): """ pass + def repair_files( + self, + files: list[PathLike], + pre_download: bool = False, + game_info: resource.Game = None, + ) -> None: + """ + Repairs multiple game files. + + This will automatically handle backup and restore the file if the repair + fails. + + This method is not multi-threaded, so it may take a while to repair + multiple files. + + 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 diff --git a/vollerei/common/functions.py b/vollerei/common/functions.py index 2b43216..7e73627 100644 --- a/vollerei/common/functions.py +++ b/vollerei/common/functions.py @@ -154,7 +154,7 @@ def repair_game( # 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 = [] + pkg_version: dict[str, dict[str, str]] = {} if not pkg_version_file.is_file(): try: game.repair_file(game.path.joinpath("pkg_version"), game_info=game_info) @@ -167,31 +167,61 @@ def repair_game( line = line.strip() if not line: continue - pkg_version.append(json.loads(line)) + line_json = json.loads(line) + pkg_version[line_json["remoteName"]] = { + "md5": line_json["md5"], + "fileSize": line_json["fileSize"], + } + read_needed_files: list[Path] = [] + target_files: list[Path] = [] repair_executor = concurrent.futures.ThreadPoolExecutor() - for file in pkg_version: + for file in game.path.rglob("*"): + # Ignore webCaches folder (because it's user data) + if file.is_dir(): + continue + if "webCaches" in str(file): + continue - def repair(target_file, game_info): + def verify(file_path: Path): + nonlocal target_files + nonlocal pkg_version + relative_path = file_path.relative_to(game.path) + relative_path_str = str(relative_path).replace("\\", "/") + # print(relative_path_str) + # Wtf mihoyo, you build this game for Windows and then use Unix path separator :moyai: try: - game.repair_file(target_file, game_info=game_info) - except Exception as e: - print(f"Failed to repair {target_file['remoteName']}: {e}") + target_file = pkg_version.pop(relative_path_str) + if target_file: + with file_path.open("rb", buffering=0) as f: + file_hash = hashlib.file_digest(f, "md5").hexdigest() + if file_hash == target_file["md5"]: + return + print( + f"Hash mismatch for {target_file['remoteName']} ({file_hash}; expected {target_file['md5']})" + ) + target_files.append(file_path) + except KeyError: + # File not found in pkg_version + read_needed_files.append(file_path) - 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.submit(verify, file) repair_executor.shutdown(wait=True) + for file in read_needed_files: + try: + with file.open("rb", buffering=0) as f: + # We only need to read 4 bytes to see if the file is readable or not + f.read(4) + except Exception: + print(f"File {file=} is corrupted.") + target_files.append(file) + # value not used for now + for key, _ in pkg_version.items(): + target_file = game.path.joinpath(key) + if target_file.is_file(): + continue + print(f"{key} not found.") + target_files.append(target_file) + if not target_files: + return + print("Begin repairing files...") + game.repair_files(target_files, game_info=game_info) diff --git a/vollerei/hsr/launcher/game.py b/vollerei/hsr/launcher/game.py index 48595e0..6c2669c 100644 --- a/vollerei/hsr/launcher/game.py +++ b/vollerei/hsr/launcher/game.py @@ -1,3 +1,4 @@ +import concurrent.futures from configparser import ConfigParser from hashlib import md5 from io import IOBase @@ -349,6 +350,40 @@ class Game(GameABC): return diff return None + def _repair_file(self, file: PathLike, game: resource.Game) -> None: + # .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 + if file.exists(): + backup_file = file.with_suffix(file.suffix + ".bak") + if backup_file.exists(): + backup_file.unlink() + file.rename(backup_file) + dest_file = file.with_suffix("") + else: + dest_file = file + try: + # Download the file + 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 + print("Failed", e) + if file.exists(): + file.rename(file.with_suffix("")) + raise e + # Delete the backup + if file.exists(): + file.unlink(missing_ok=True) + def repair_file( self, file: PathLike, @@ -377,36 +412,42 @@ class Game(GameABC): game = game_info if game.latest.decompressed_path is None: raise ScatteredFilesNotAvailableError("Scattered files are not available.") - # .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 - if file.exists(): - file.rename(file.with_suffix(file.suffix + ".bak")) - dest_file = file.with_suffix("") + self._repair_file(file, game=game) + + def repair_files( + self, + files: list[PathLike], + pre_download: bool = False, + game_info: resource.Game = None, + ) -> None: + """ + Repairs multiple game files. + + 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. + """ + if not self.is_installed(): + raise GameNotInstalledError("Game is not installed.") + files_path = [Path(file) for file in files] + for file in files_path: + if not file.is_relative_to(self._path): + raise ValueError("File is not in the game folder.") + if not game_info: + game = self.get_remote_game(pre_download=pre_download) else: - dest_file = file - try: - # Download the file - 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 - print("Failed", e) - if file.exists(): - file.rename(file.with_suffix("")) - raise e - else: - # Delete the backup - if file.exists(): - file.unlink(missing_ok=True) + game = game_info + if game.latest.decompressed_path is None: + raise ScatteredFilesNotAvailableError("Scattered files are not available.") + executor = concurrent.futures.ThreadPoolExecutor() + for file in files_path: + executor.submit(self._repair_file, file, game=game) + # self._repair_file(file, game=game) + executor.shutdown(wait=True) def repair_game(self) -> None: """