feat(repair): rework the repair feature

Mostly usable now
This commit is contained in:
tretrauit 2024-06-18 02:49:52 +07:00
parent e8f63f175f
commit 08c51d2fd8
3 changed files with 146 additions and 53 deletions

View File

@ -87,6 +87,28 @@ class GameABC(ABC):
""" """
pass 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]: def get_version(self) -> tuple[int, int, int]:
""" """
Get the game version Get the game version

View File

@ -154,7 +154,7 @@ def repair_game(
# ThreadPoolExecutor, probably better for this use case. # ThreadPoolExecutor, probably better for this use case.
game_info = game.get_remote_game(pre_download=pre_download) game_info = game.get_remote_game(pre_download=pre_download)
pkg_version_file = game.path.joinpath("pkg_version") pkg_version_file = game.path.joinpath("pkg_version")
pkg_version = [] pkg_version: dict[str, dict[str, str]] = {}
if not pkg_version_file.is_file(): if not pkg_version_file.is_file():
try: try:
game.repair_file(game.path.joinpath("pkg_version"), game_info=game_info) game.repair_file(game.path.joinpath("pkg_version"), game_info=game_info)
@ -167,31 +167,61 @@ def repair_game(
line = line.strip() line = line.strip()
if not line: if not line:
continue 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() 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: try:
game.repair_file(target_file, game_info=game_info) target_file = pkg_version.pop(relative_path_str)
except Exception as e: if target_file:
print(f"Failed to repair {target_file['remoteName']}: {e}") 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): repair_executor.submit(verify, file)
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) 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)

View File

@ -1,3 +1,4 @@
import concurrent.futures
from configparser import ConfigParser from configparser import ConfigParser
from hashlib import md5 from hashlib import md5
from io import IOBase from io import IOBase
@ -349,6 +350,40 @@ class Game(GameABC):
return diff return diff
return None 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( def repair_file(
self, self,
file: PathLike, file: PathLike,
@ -377,36 +412,42 @@ class Game(GameABC):
game = game_info game = game_info
if game.latest.decompressed_path is None: if game.latest.decompressed_path is None:
raise ScatteredFilesNotAvailableError("Scattered files are not available.") raise ScatteredFilesNotAvailableError("Scattered files are not available.")
# .replace("\\", "/") is needed because Windows uses backslashes :) self._repair_file(file, game=game)
relative_file = file.relative_to(self._path)
url = ( def repair_files(
game.latest.decompressed_path + "/" + str(relative_file).replace("\\", "/") self,
) files: list[PathLike],
# Backup the file pre_download: bool = False,
if file.exists(): game_info: resource.Game = None,
file.rename(file.with_suffix(file.suffix + ".bak")) ) -> None:
dest_file = file.with_suffix("") """
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: else:
dest_file = file game = game_info
try: if game.latest.decompressed_path is None:
# Download the file raise ScatteredFilesNotAvailableError("Scattered files are not available.")
temp_file = self.cache.joinpath(relative_file) executor = concurrent.futures.ThreadPoolExecutor()
dest_file.parent.mkdir(parents=True, exist_ok=True) for file in files_path:
print(f"Downloading repair file {url} to {temp_file}") executor.submit(self._repair_file, file, game=game)
download(url, temp_file, overwrite=True, stream=True) # self._repair_file(file, game=game)
# Move the file executor.shutdown(wait=True)
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)
def repair_game(self) -> None: def repair_game(self) -> None:
""" """