fix: migrate to HoYoPlay

For now, only update game and voicepack are working

This commit also fixes a couple of bugs too. Tbf I enjoyed HoYoPlay until I don't have enough space to update my HSR so yeah 💀
This commit is contained in:
tretrauit 2024-09-10 16:47:20 +07:00
parent 08c51d2fd8
commit 8ff2a388d7
12 changed files with 213 additions and 500 deletions

View File

@ -1,8 +1,10 @@
import traceback
from cleo.commands.command import Command
from cleo.helpers import option, argument
from copy import deepcopy
from pathlib import PurePath
from platform import system
from vollerei.hsr.launcher.enums import GameChannel
from vollerei.common.enums import GameChannel
from vollerei.cli import utils
from vollerei.exceptions.game import GameError
from vollerei.hsr import Game, Patcher
@ -434,11 +436,12 @@ class UpdateCommand(Command):
update_diff = State.game.get_update(pre_download=pre_download)
game_info = State.game.get_remote_game(pre_download=pre_download)
except Exception as e:
print(traceback.format_exc())
progress.finish(
f"<error>Update checking failed with following error: {e} ({e.__context__})</error>"
)
return
if update_diff is None:
if update_diff is None or isinstance(game_info.major, str | None):
progress.finish("<comment>Game is already updated.</comment>")
return
progress.finish("<comment>Update available.</comment>")
@ -446,16 +449,17 @@ class UpdateCommand(Command):
f"The current version is: <comment>{State.game.get_version_str()}</comment>"
)
self.line(
f"The latest version is: <comment>{game_info.latest.version}</comment>"
f"The latest version is: <comment>{game_info.major.version}</comment>"
)
if not self.confirm("Do you want to update the game?"):
self.line("<error>Update aborted.</error>")
return
self.line("Downloading update package...")
out_path = State.game.cache.joinpath(update_diff.name)
update_game_url = update_diff.game_pkgs[0].url
out_path = State.game.cache.joinpath(PurePath(update_game_url).name)
try:
download_result = utils.download(
update_diff.path, out_path, file_len=update_diff.size
update_game_url, out_path, file_len=update_diff.game_pkgs[0].size
)
except Exception as e:
self.line_error(f"<error>Couldn't download update: {e}</error>")
@ -478,14 +482,14 @@ class UpdateCommand(Command):
# Get installed voicepacks
installed_voicepacks = State.game.get_installed_voicepacks()
# Voicepack update
for remote_voicepack in update_diff.voice_packs:
for remote_voicepack in update_diff.audio_pkgs:
if remote_voicepack.language not in installed_voicepacks:
continue
# Voicepack is installed, update it
archive_file = State.game.cache.joinpath(remote_voicepack.name)
archive_file = State.game.cache.joinpath(PurePath(remote_voicepack.url).name)
try:
download_result = utils.download(
remote_voicepack.path, archive_file, file_len=update_diff.size
remote_voicepack.url, archive_file, file_len=remote_voicepack.size
)
except Exception as e:
self.line_error(f"<error>Couldn't download update: {e}</error>")

View File

@ -1,4 +1,35 @@
from vollerei.common.api.resource import Resource
import requests
from vollerei.common.api import resource
from vollerei.common.enums import GameChannel
from vollerei.constants import LAUNCHER_API
__all__ = ["Resource"]
__all__ = ["GamePackage"]
def get_game_packages(
channel: GameChannel = GameChannel.Overseas,
) -> list[resource.GameInfo]:
"""
Get game packages information from the launcher API.
Default channel is overseas.
Args:
channel: Game channel to get the resource information from.
Returns:
Resource: Game resource information.
"""
resource_path: dict = None
match channel:
case GameChannel.Overseas:
resource_path = LAUNCHER_API.OS
case GameChannel.China:
resource_path = LAUNCHER_API.CN
return resource.from_dict(
requests.get(
resource_path["url"] + LAUNCHER_API.RESOURCE_PATH,
params=resource_path["params"],
).json()["data"]
)

View File

@ -1,409 +1,134 @@
"""
Class wrapper for API endpoint /resource
"""
from vollerei.common.enums import VoicePackLanguage
class Segment:
"""
A segment of the game archive.
Attributes:
path (str): Segment download path.
md5 (str): Segment md5 checksum.
package_size (int | None): Segment package size.
"""
path: str
md5: str
# str -> int and checked if int is 0 then None
package_size: int | None
def __init__(self, path: str, md5: str, package_size: int | None) -> None:
self.path = path
self.md5 = md5
self.package_size = package_size
@staticmethod
def from_dict(data: dict) -> "Segment":
return Segment(
data["path"],
data["md5"],
(
int(data["package_size"])
if data["package_size"] and data["package_size"] != "0"
else None
),
)
class VoicePack:
"""
Voice pack information
`name` maybe converted from `path` if the server returns empty string.
Attributes:
language (VoicePackLanguage): Language of the voice pack.
name (str): Voice pack archive name.
path (str): Voice pack download path.
size (int): Voice pack size.
md5 (str): Voice pack md5 checksum.
package_size (int): Voice pack package size.
"""
language: VoicePackLanguage
name: str
path: str
# str -> int
size: int
md5: str
# str -> int
package_size: int
def __init__(
self,
language: VoicePackLanguage,
name: str,
path: str,
size: int,
md5: str,
package_size: int,
) -> None:
self.language = language
self.name = name
self.path = path
self.size = size
self.md5 = md5
self.package_size = package_size
@staticmethod
def from_dict(data: dict) -> "VoicePack":
return VoicePack(
VoicePackLanguage.from_remote_str(data["language"]),
data["name"],
data["path"],
int(data["size"]),
data["md5"],
int(data["package_size"]),
)
class Diff:
"""
Game resource diff from a version to latest information
Attributes:
TODO
"""
name: str
version: str
path: str
# str -> int
size: int
md5: str
is_recommended_update: bool
voice_packs: list[VoicePack]
# str -> int
package_size: int
def __init__(
self,
name: str,
version: str,
path: str,
size: int,
md5: str,
is_recommended_update: bool,
voice_packs: list[VoicePack],
package_size: int,
) -> None:
self.name = name
self.version = version
self.path = path
self.size = size
self.md5 = md5
self.is_recommended_update = is_recommended_update
self.voice_packs = voice_packs
self.package_size = package_size
@staticmethod
def from_dict(data: dict) -> "Diff":
return Diff(
data["name"],
data["version"],
data["path"],
int(data["size"]),
data["md5"],
data["is_recommended_update"],
[VoicePack.from_dict(i) for i in data["voice_packs"]],
int(data["package_size"]),
)
class Latest:
"""
Latest game resource information
`name` maybe converted from `path` if the server returns empty string,
and if `path` is empty too then it'll convert the name from the first
segment of `segments` list.
`path` maybe None if the server returns empty string, in that case
you'll have to download the game using `segments` list and merge them.
`voice_packs` will be empty for Star Rail, they force you to download
in-game instead.
`decompressed_path` is useful for repairing game files by only having
to re-download the corrupted files.
`segments` is a list of game archive segments, you'll have to download
them and merge them together to get the full game archive. Not available
on Star Rail.
Attributes:
name (str): Game archive name.
version (str): Game version in the archive.
path (str | None): Game archive download path.
size (int): Game archive size in bytes.
md5 (str): Game archive MD5 checksum.
entry (str): Game entry file (e.g. GenshinImpact.exe).
voice_packs (list[VoicePack]): Game voice packs.
decompressed_path (str | None): Game archive decompressed path.
segments (list[Segment]): Game archive segments.
package_size (int): Game archive package size in bytes.
"""
name: str
version: str
path: str | None
# str -> int
size: int
md5: str
entry: str
voice_packs: list[VoicePack]
# str but checked for empty string
decompressed_path: str | None
segments: list[Segment]
# str -> int
package_size: int
def __init__(
self,
name: str,
version: str,
path: str,
size: int,
md5: str,
entry: str,
voice_packs: list[VoicePack],
decompressed_path: str | None,
segments: list[Segment],
package_size: int,
) -> None:
self.name = name
self.version = version
self.path = path
self.size = size
self.md5 = md5
self.entry = entry
self.voice_packs = voice_packs
self.decompressed_path = decompressed_path
self.segments = segments
self.package_size = package_size
@staticmethod
def from_dict(data: dict) -> "Latest":
if data["name"] == "":
if data["path"] == "":
data["name"] = data["segments"][0]["path"].split("/")[-1]
else:
data["name"] = data["path"].split("/")[-1]
return Latest(
data["name"],
data["version"],
data["path"] if data["path"] != "" else None,
int(data["size"]),
data["md5"],
data["entry"],
[VoicePack.from_dict(i) for i in data["voice_packs"]],
data["decompressed_path"] if data["decompressed_path"] != "" else None,
[Segment.from_dict(i) for i in data["segments"]],
int(data["package_size"]),
)
class Game:
latest: Latest
diffs: list[Diff]
def __init__(self, latest: Latest, diffs: list[Diff]) -> None:
self.latest = latest
self.diffs = diffs
def __init__(self, id: str, biz: str):
self.id = id
self.biz = biz
@staticmethod
def from_dict(data: dict) -> "Game":
return Game(
Latest.from_dict(data["latest"]), [Diff.from_dict(i) for i in data["diffs"]]
)
return Game(id=data["id"], biz=data["biz"])
class Plugin:
name: str
# str but checked for empty string
version: str | None
path: str
# str -> int
size: int
md5: str
# str but checked for empty string
entry: str | None
# str -> int
package_size: int
def __init__(
self,
name: str,
version: str | None,
path: str,
size: int,
md5: str,
entry: str | None,
package_size: int,
) -> None:
self.name = name
self.version = version
self.path = path
class GamePackage:
def __init__(self, url: str, md5: str, size: int, decompressed_size: int):
self.url = url
self.md5 = md5
self.size = size
self.md5 = md5
self.entry = entry
self.package_size = package_size
self.decompressed_size = decompressed_size
@staticmethod
def from_dict(data: dict) -> "Plugin":
return Plugin(
data["name"],
data["version"] if data["version"] != "" else None,
data["path"],
int(data["size"]),
data["md5"],
data["entry"] if data["entry"] != "" else None,
int(data["package_size"]),
def from_dict(data: dict) -> "GamePackage":
return GamePackage(
url=data["url"],
md5=data["md5"],
size=int(data["size"]),
decompressed_size=int(data["decompressed_size"]),
)
class LauncherPlugin:
plugins: list[Plugin]
# str -> int
version: int
def __init__(self, plugins: list[Plugin], version: int) -> None:
self.plugins = plugins
self.version = version
@staticmethod
def from_dict(data: dict) -> "LauncherPlugin":
return LauncherPlugin(
[Plugin.from_dict(i) for i in data["plugins"]], int(data["version"])
)
class DeprecatedPackage:
name: str
md5: str
def __init__(self, name: str, md5: str) -> None:
self.name = name
self.md5 = md5
@staticmethod
def from_dict(data: dict) -> "DeprecatedPackage":
return DeprecatedPackage(data["name"], data["md5"])
class DeprecatedFile:
path: str
# str but checked for empty string
md5: str | None
def __init__(self, path: str, md5: str | None) -> None:
self.path = path
self.md5 = md5
@staticmethod
def from_dict(data: dict) -> "DeprecatedFile":
return DeprecatedFile(data["path"], data["md5"] if data["md5"] != "" else None)
class Resource:
"""
Data class for /resource endpoint
I'm still unclear about `force_update` and `sdk` attributes, so I'll
leave them as None for now.
Attributes:
game (Game): Game resource information.
plugin (LauncherPlugin): Launcher plugin information.
web_url (str): Game official launcher web URL.
force_update (None): Not used by official launcher I guess?
pre_download_game (Game | None): Pre-download game resource information.
deprecated_packages (list[DeprecatedPackage]): Deprecated game packages.
sdk (None): Maybe for Bilibili version of Genshin?
deprecated_files (list[DeprecatedFile]): Deprecated game files.
"""
# I'm generous enough to convert the string into int
# for you guys, wtf Mihoyo?
game: Game
# ?? Mihoyo for plugin["plugins"] which is a list of Plugin objects
plugin: LauncherPlugin
web_url: str
# ?? Mihoyo
force_update: None
# Will be a Game object if a pre-download is available.
pre_download_game: Game | None
deprecated_packages: list[DeprecatedPackage]
# Maybe a SDK for Bilibili version in Genshin?
sdk: None
deprecated_files: list[DeprecatedFile]
class AudioPackage:
def __init__(
self,
game: Game,
plugin: Plugin,
web_url: str,
force_update: None,
pre_download_game: Game | None,
deprecated_packages: list[DeprecatedPackage],
sdk: None,
deprecated_files: list[DeprecatedFile],
) -> None:
self.game = game
self.plugin = plugin
self.web_url = web_url
self.force_update = force_update
self.pre_download_game = pre_download_game
self.deprecated_packages = deprecated_packages
self.sdk = sdk
self.deprecated_files = deprecated_files
language: VoicePackLanguage,
url: str,
md5: str,
size: int,
decompressed_size: int,
):
self.language = language
self.url = url
self.md5 = md5
self.size = size
self.decompressed_size = decompressed_size
@staticmethod
def from_dict(json: dict) -> "Resource":
return Resource(
Game.from_dict(json["game"]),
LauncherPlugin.from_dict(json["plugin"]),
json["web_url"],
json["force_update"],
(
Game.from_dict(json["pre_download_game"])
if json["pre_download_game"]
else None
),
[DeprecatedPackage.from_dict(x) for x in json["deprecated_packages"]],
json["sdk"],
[DeprecatedFile.from_dict(x) for x in json["deprecated_files"]],
def from_dict(data: dict) -> "AudioPackage":
return AudioPackage(
language=VoicePackLanguage.from_remote_str(data["language"]),
url=data["url"],
md5=data["md5"],
size=int(data["size"]),
decompressed_size=int(data["decompressed_size"]),
)
class Major:
def __init__(
self,
version: str,
game_pkgs: list[GamePackage],
audio_pkgs: list[AudioPackage],
res_list_url: str,
):
self.version = version
self.game_pkgs = game_pkgs
self.audio_pkgs = audio_pkgs
self.res_list_url = res_list_url
@staticmethod
def from_dict(data: dict) -> "Major":
return Major(
version=data["version"],
game_pkgs=[GamePackage(**x) for x in data["game_pkgs"]],
audio_pkgs=[AudioPackage(**x) for x in data["audio_pkgs"]],
res_list_url=data["res_list_url"],
)
# Currently patch has the same fields as major
Patch = Major
class Main:
def __init__(self, major: Major, patches: list[Patch]):
self.major = major
self.patches = patches
@staticmethod
def from_dict(data: dict) -> "Main":
return Main(
major=Major.from_dict(data["major"]),
patches=[Patch.from_dict(x) for x in data["patches"]],
)
class PreDownload:
def __init__(self, major: Major | str | None, patches: list[Patch]):
self.major = major
self.patches = patches
@staticmethod
def from_dict(data: dict) -> "PreDownload":
return PreDownload(
major=(
data["major"]
if isinstance(data["major"], str | None)
else Major.from_dict(data["major"])
),
patches=[Patch.from_dict(x) for x in data["patches"]],
)
# Why miHoYo uses the same name "game_packages" for this big field and smol field
class GameInfo:
def __init__(self, game: Game, main: Main, pre_download: PreDownload):
self.game = game
self.main = main
self.pre_download = pre_download
@staticmethod
def from_dict(data: dict) -> "GameInfo":
return GameInfo(
game=Game.from_dict(data["game"]),
main=Main.from_dict(data["main"]),
pre_download=PreDownload.from_dict(data["pre_download"]),
)
def from_dict(data: dict) -> list[GameInfo]:
game_pkgs = []
for pkg in data["game_packages"]:
game_pkgs.append(GameInfo.from_dict(pkg))
return game_pkgs

View File

@ -1,6 +1,11 @@
from enum import Enum
class GameChannel(Enum):
Overseas = 0
China = 1
class VoicePackLanguage(Enum):
Japanese = "ja-jp"
Chinese = "zh-cn"

View File

@ -1,3 +1,21 @@
class LAUNCHER_API:
"""Launcher API constants."""
RESOURCE_PATH: str = "hyp/hyp-connect/api/getGamePackages"
OS: dict = {
"url": "https://sg-hyp-api.hoyoverse.com/",
"params": {
"launcher_id": "VYTpXlbWo8",
},
}
CN: dict = {
"url": "https://hyp-api.mihoyo.com/",
"params": {
"launcher_id": "jGHBHlcOq1",
},
}
TELEMETRY_HOSTS = [
# Global
"log-upload-os.hoyoverse.com",

View File

@ -1,24 +0,0 @@
class LAUNCHER_API:
"""Launcher API constants."""
RESOURCE_PATH: str = "mdk/launcher/api/resource"
OS: dict = {
"url": "https://hkrpg-launcher-static.hoyoverse.com/hkrpg_global/",
"params": {
"channel_id": 1,
"key": "vplOVX8Vn7cwG8yb",
"launcher_id": 35,
},
}
ASIA: dict = {}
CN: dict = {
"url": "https://api-launcher.mihoyo.com/hkrpg_cn/mdk/launcher/api/resource",
"params": {
"channel_id": 1,
"key": "6KcVuOkbcqjJomjZ",
"launcher_id": 33,
},
}
LATEST_VERSION = (7, 2, 0)

View File

@ -1,9 +0,0 @@
from enum import Enum
class GameChannel(Enum):
Global = 0
Asia = 1
Taiwan = 2
Korea = 3
China = 4

View File

@ -1,26 +1,4 @@
class LAUNCHER_API:
"""Launcher API constants."""
RESOURCE_PATH: str = "mdk/launcher/api/resource"
OS: dict = {
"url": "https://hkrpg-launcher-static.hoyoverse.com/hkrpg_global/",
"params": {
"channel_id": 1,
"key": "vplOVX8Vn7cwG8yb",
"launcher_id": 35,
},
}
CN: dict = {
"url": "https://api-launcher.mihoyo.com/hkrpg_cn/mdk/launcher/api/resource",
"params": {
"channel_id": 1,
"key": "6KcVuOkbcqjJomjZ",
"launcher_id": 33,
},
}
LATEST_VERSION = (1, 6, 0)
LATEST_VERSION = (2, 5, 0)
MD5SUMS = {
"1.0.5": {
"cn": {

View File

@ -1,13 +1,10 @@
import requests
from vollerei.common.api import Resource
from vollerei.hsr.constants import LAUNCHER_API
from vollerei.hsr.launcher.enums import GameChannel
from vollerei.common.api import get_game_packages, resource
from vollerei.common.enums import GameChannel
def get_resource(channel: GameChannel = GameChannel.Overseas) -> Resource:
def get_game_package(channel: GameChannel = GameChannel.Overseas) -> resource.GameInfo:
"""
Get game resource information from the launcher API.
Get game package information from the launcher API.
Default channel is overseas.
@ -17,15 +14,7 @@ def get_resource(channel: GameChannel = GameChannel.Overseas) -> Resource:
Returns:
Resource: Game resource information.
"""
resource_path: dict = None
match channel:
case GameChannel.Overseas:
resource_path = LAUNCHER_API.OS
case GameChannel.China:
resource_path = LAUNCHER_API.CN
return Resource.from_dict(
requests.get(
resource_path["url"] + LAUNCHER_API.RESOURCE_PATH,
params=resource_path["params"],
).json()["data"]
)
game_packages = get_game_packages(channel=channel)
for package in game_packages:
if "hkrpg" in package.game.biz:
return package

View File

@ -1,6 +0,0 @@
from enum import Enum
class GameChannel(Enum):
Overseas = 0
China = 1

View File

@ -3,12 +3,12 @@ from configparser import ConfigParser
from hashlib import md5
from io import IOBase
from os import PathLike
from pathlib import Path
from pathlib import Path, PurePath
from shutil import move, copyfile
from vollerei.abc.launcher.game import GameABC
from vollerei.common import ConfigFile, functions
from vollerei.common.api import resource
from vollerei.common.enums import VoicePackLanguage
from vollerei.common.enums import VoicePackLanguage, GameChannel
from vollerei.exceptions.game import (
GameAlreadyUpdatedError,
GameNotInstalledError,
@ -16,7 +16,6 @@ from vollerei.exceptions.game import (
ScatteredFilesNotAvailableError,
)
from vollerei.hsr.constants import MD5SUMS
from vollerei.hsr.launcher.enums import GameChannel
from vollerei.hsr.launcher import api
from vollerei import paths
from vollerei.utils import download
@ -189,7 +188,7 @@ class Game(GameABC):
cfg_file = self._path.joinpath("config.ini")
if cfg_file.exists():
cfg = ConfigFile(cfg_file)
cfg.set("General", "game_version", self.get_version_str())
cfg.set("general", "game_version", self.get_version_str())
cfg.save()
else:
cfg = ConfigParser()
@ -307,7 +306,7 @@ class Game(GameABC):
pass
return voicepacks
def get_remote_game(self, pre_download: bool = False) -> resource.Game:
def get_remote_game(self, pre_download: bool = False) -> resource.Main | resource.PreDownload:
"""
Gets the current game information from remote.
@ -316,17 +315,17 @@ class Game(GameABC):
Defaults to False.
Returns:
A `Game` object that contains the game information.
A `Main` or `PreDownload` object that contains the game information.
"""
channel = self._channel_override or self.get_channel()
if pre_download:
game = api.get_resource(channel=channel).pre_download_game
game = api.get_game_package(channel=channel).pre_download
if not game:
raise PreDownloadNotAvailable("Pre-download version is not available.")
return game
return api.get_resource(channel=channel).game
return api.get_game_package(channel=channel).main
def get_update(self, pre_download: bool = False) -> resource.Diff | None:
def get_update(self, pre_download: bool = False) -> resource.Patch | None:
"""
Gets the current game update.
@ -335,7 +334,7 @@ class Game(GameABC):
Defaults to False.
Returns:
A `Diff` object that contains the update information or
A `Patch` object that contains the update information or
`None` if the game is not installed or already up-to-date.
"""
if not self.is_installed():
@ -345,9 +344,9 @@ class Game(GameABC):
if self._version_override
else self.get_version_str()
)
for diff in self.get_remote_game(pre_download=pre_download).diffs:
if diff.version == version:
return diff
for patch in self.get_remote_game(pre_download=pre_download).patches:
if patch.version == version:
return patch
return None
def _repair_file(self, file: PathLike, game: resource.Game) -> None:
@ -486,10 +485,10 @@ class Game(GameABC):
functions.apply_update_archive(self, archive_file, auto_repair=auto_repair)
def install_update(
self, update_info: resource.Diff = None, auto_repair: bool = True
self, update_info: resource.Patch = None, auto_repair: bool = True
):
"""
Installs an update from a `Diff` object.
Installs an update from a `Patch` object.
You may want to download the update manually and pass it to
`apply_update_archive()` instead for better control, and after that
@ -506,19 +505,20 @@ class Game(GameABC):
update_info = self.get_update()
if not update_info or update_info.version == self.get_version_str():
raise GameAlreadyUpdatedError("Game is already updated.")
update_url = update_info.game_pkgs[0].url
# Base game update
archive_file = self.cache.joinpath(update_info.name)
download(update_info.path, archive_file)
archive_file = self.cache.joinpath(PurePath(update_url).name)
download(update_url, archive_file)
self.apply_update_archive(archive_file=archive_file, auto_repair=auto_repair)
# Get installed voicepacks
installed_voicepacks = self.get_installed_voicepacks()
# Voicepack update
for remote_voicepack in update_info.voice_packs:
for remote_voicepack in update_info.audio_pkgs:
if remote_voicepack.language not in installed_voicepacks:
continue
# Voicepack is installed, update it
archive_file = self.cache.joinpath(remote_voicepack.name)
download(remote_voicepack.path, archive_file)
archive_file = self.cache.joinpath(PurePath(remote_voicepack.url).name)
download(remote_voicepack.url, archive_file)
self.apply_update_archive(
archive_file=archive_file, auto_repair=auto_repair
)

View File

@ -34,6 +34,8 @@ class HDiffPatch:
return "windows32"
case "x86_64":
return "windows64"
case "AMD64":
return "windows64"
case "arm":
return "windows_arm32"
case "arm64":