feat(hsr): repair game
Apparently doesn't completely work on my setup (NTFS + Btrfs) but yours may. If it doesn't fix the game then copy the file it downloaded from temp to the game directory and it should work.
This commit is contained in:
parent
bbdb8d3596
commit
f5e7417cdf
@ -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
|
||||
|
@ -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("<error>Repairation aborted.</error>")
|
||||
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"<error>Repairation failed with following error: {e} ({e.__context__})</error>"
|
||||
)
|
||||
return
|
||||
progress.finish("<comment>Repairation completed.</comment>")
|
||||
|
||||
|
||||
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,
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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,23 +371,52 @@ class Game(GameABC):
|
||||
file = Path(file)
|
||||
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:
|
||||
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
|
||||
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
|
||||
print("Failed", e)
|
||||
if file.exists():
|
||||
file.rename(file.with_suffix(""))
|
||||
raise
|
||||
raise e
|
||||
else:
|
||||
# Delete the backup
|
||||
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
|
||||
) -> None:
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user