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