Compare commits
2 Commits
830e480f6f
...
master
Author | SHA1 | Date | |
---|---|---|---|
bcaa1a268e
|
|||
155e8b9664
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,3 +1,7 @@
|
||||
.vscode/
|
||||
.debug/
|
||||
debug*.py
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
@ -16,10 +16,12 @@ You should have received a copy of the GNU General Public License along with
|
||||
this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import dataclasses
|
||||
import re
|
||||
from typing import ClassVar, Optional
|
||||
from typing import ClassVar
|
||||
|
||||
__all__ = [
|
||||
"ArcaeaApkAsset",
|
||||
@ -35,27 +37,28 @@ class ArcaeaApkAsset(metaclass=abc.ABCMeta):
|
||||
zip_filename: str
|
||||
|
||||
@classmethod
|
||||
def _get_match(cls, string: str) -> Optional[re.Match]:
|
||||
def _get_match(cls, string: str) -> re.Match | None:
|
||||
return cls.PATTERN.match(string)
|
||||
|
||||
@classmethod
|
||||
@abc.abstractmethod
|
||||
def from_zip_filename(
|
||||
cls, asset_zip_filename: str
|
||||
) -> Optional["ArcaeaApkAsset"]: ...
|
||||
cls,
|
||||
asset_zip_filename: str,
|
||||
) -> ArcaeaApkAsset | None: ...
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ArcaeaApkAssetJacket(ArcaeaApkAsset):
|
||||
PATTERN = re.compile(
|
||||
r"^assets/songs/(?P<raw_song_id>.*)/(1080_)?(?P<raw_rating_class>\d|base)(_(?P<lang>.*))?\.(jpg|png)"
|
||||
r"^assets/songs/(?P<raw_song_id>.*)/(1080_)?(?P<raw_rating_class>\d|base)(_(?P<lang>.*))?\.(jpg|png)",
|
||||
)
|
||||
song_id: str
|
||||
rating_class: Optional[str]
|
||||
lang: Optional[str]
|
||||
rating_class: str | None
|
||||
lang: str | None
|
||||
|
||||
@classmethod
|
||||
def from_zip_filename(cls, asset_zip_filename: str):
|
||||
def from_zip_filename(cls, asset_zip_filename: str) -> ArcaeaApkAssetJacket | None:
|
||||
match = cls._get_match(asset_zip_filename)
|
||||
if match is None:
|
||||
return None
|
||||
@ -78,7 +81,10 @@ class ArcaeaApkAssetPartnerIcon(ArcaeaApkAsset):
|
||||
char_id: str
|
||||
|
||||
@classmethod
|
||||
def from_zip_filename(cls, asset_zip_filename: str):
|
||||
def from_zip_filename(
|
||||
cls,
|
||||
asset_zip_filename: str,
|
||||
) -> ArcaeaApkAssetPartnerIcon | None:
|
||||
match = cls._get_match(asset_zip_filename)
|
||||
if match is None:
|
||||
return None
|
||||
@ -95,7 +101,7 @@ class ArcaeaApkAssetList(ArcaeaApkAsset):
|
||||
filename: str
|
||||
|
||||
@classmethod
|
||||
def from_zip_filename(cls, asset_zip_filename: str):
|
||||
def from_zip_filename(cls, asset_zip_filename: str) -> ArcaeaApkAssetList | None:
|
||||
match = cls._get_match(asset_zip_filename)
|
||||
if match is None:
|
||||
return None
|
||||
|
@ -1,12 +1,4 @@
|
||||
from .defs import (
|
||||
ARCAEA_LANGUAGE_KEYS,
|
||||
Difficulty,
|
||||
DifficultyLocalization,
|
||||
Pack,
|
||||
PackLocalization,
|
||||
Song,
|
||||
SongLocalization,
|
||||
)
|
||||
from .defs import ARCAEA_LANGUAGE_KEYS
|
||||
from .packlist import ArcaeaPacklistParser
|
||||
from .songlist import ArcaeaSonglistParser
|
||||
|
||||
@ -14,10 +6,4 @@ __all__ = [
|
||||
"ARCAEA_LANGUAGE_KEYS",
|
||||
"ArcaeaPacklistParser",
|
||||
"ArcaeaSonglistParser",
|
||||
"Difficulty",
|
||||
"DifficultyLocalization",
|
||||
"Pack",
|
||||
"PackLocalization",
|
||||
"Song",
|
||||
"SongLocalization",
|
||||
]
|
||||
|
@ -16,11 +16,15 @@ You should have received a copy of the GNU General Public License along with
|
||||
this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
|
||||
def is_string_blank(string: str):
|
||||
@typechecked
|
||||
def is_string_blank(string: str) -> bool:
|
||||
"""
|
||||
Check if a string equals to a blank string after stripped.
|
||||
|
||||
@ -37,14 +41,11 @@ def is_string_blank(string: str):
|
||||
False
|
||||
"""
|
||||
|
||||
if not isinstance(string, str):
|
||||
msg = "A `str` object is expected"
|
||||
raise TypeError(msg)
|
||||
|
||||
return string.strip() == ""
|
||||
|
||||
|
||||
def blank_str_to_none(string: str):
|
||||
@typechecked
|
||||
def blank_str_to_none(string: str | None) -> str | None:
|
||||
"""
|
||||
Convert a blank string (see :func:`~is_string_blank`) to None, otherwise return the
|
||||
original string.
|
||||
@ -68,14 +69,11 @@ def blank_str_to_none(string: str):
|
||||
if string is None:
|
||||
return None
|
||||
|
||||
if not isinstance(string, str):
|
||||
msg = "A `str` object is expected"
|
||||
raise TypeError(msg)
|
||||
|
||||
return None if is_string_blank(string) else string
|
||||
|
||||
|
||||
def replace_single_newline(text: Optional[str]):
|
||||
@typechecked
|
||||
def replace_single_newline(text: str | None) -> str | None:
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
|
@ -16,104 +16,4 @@ You should have received a copy of the GNU General Public License along with
|
||||
this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
ARCAEA_LANGUAGE_KEYS = ["ja", "ko", "zh-Hans", "zh-Hant"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pack:
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
section: Optional[str] = None
|
||||
plus_character: Optional[int] = None
|
||||
append_parent_id: Optional[str] = None
|
||||
is_world_extend: bool = False
|
||||
|
||||
@property
|
||||
def is_append(self):
|
||||
return self.append_parent_id is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PackLocalization:
|
||||
id: str
|
||||
lang: str
|
||||
name: Optional[str]
|
||||
description: Optional[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Song:
|
||||
pack_id: str
|
||||
id: str
|
||||
added_at: datetime
|
||||
|
||||
idx: Optional[int] = None
|
||||
title: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
is_deleted: bool = False
|
||||
|
||||
version: Optional[str] = None
|
||||
|
||||
bpm: Optional[str] = None
|
||||
bpm_base: Optional[Decimal] = None
|
||||
is_remote: bool = False
|
||||
is_unlockable_in_world: bool = False
|
||||
is_beyond_unlock_state_local: bool = False
|
||||
purchase: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
side: Optional[int] = None
|
||||
bg: Optional[str] = None
|
||||
bg_inverse: Optional[str] = None
|
||||
bg_day: Optional[str] = None
|
||||
bg_night: Optional[str] = None
|
||||
|
||||
source: Optional[str] = None
|
||||
source_copyright: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongLocalization:
|
||||
id: str
|
||||
lang: str
|
||||
title: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
has_jacket: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Difficulty:
|
||||
song_id: str
|
||||
rating_class: int
|
||||
|
||||
rating: int
|
||||
is_rating_plus: bool = False
|
||||
|
||||
chart_designer: Optional[str] = None
|
||||
jacket_designer: Optional[str] = None
|
||||
|
||||
has_overriding_audio: bool = False
|
||||
has_overriding_jacket: bool = False
|
||||
jacket_night: Optional[str] = None
|
||||
|
||||
added_at: Optional[datetime] = None
|
||||
bg: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
is_legacy11: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class DifficultyLocalization:
|
||||
song_id: str
|
||||
rating_class: int
|
||||
lang: str
|
||||
title: Optional[str]
|
||||
artist: Optional[str]
|
||||
|
@ -16,11 +16,15 @@ You should have received a copy of the GNU General Public License along with
|
||||
this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arcaea_offline_schemas.dis.v5.dataclass import AodisPack, AodisPackLocalization
|
||||
from typeguard import TypeCheckError, check_type
|
||||
|
||||
from ._shared import blank_str_to_none
|
||||
from .defs import ARCAEA_LANGUAGE_KEYS, Pack, PackLocalization
|
||||
from .defs import ARCAEA_LANGUAGE_KEYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -31,70 +35,28 @@ class PacklistFormatError(Exception):
|
||||
|
||||
class ArcaeaPacklistParser:
|
||||
@staticmethod
|
||||
def _assert_root_content(root: Any):
|
||||
if not isinstance(root, dict):
|
||||
msg = f"packlist root should be `dict`, got {type(root)}"
|
||||
raise PacklistFormatError(msg)
|
||||
|
||||
packs = root.get("packs")
|
||||
if packs is None:
|
||||
msg = "`packs` field does not exist"
|
||||
raise PacklistFormatError(msg)
|
||||
|
||||
if not isinstance(packs, list):
|
||||
msg = f"`packs` field should be `list`, got {type(packs)}"
|
||||
raise PacklistFormatError(msg)
|
||||
def check_root_content(root: dict) -> None:
|
||||
try:
|
||||
check_type(root, dict)
|
||||
check_type(root["packs"], list[dict])
|
||||
except (KeyError, TypeCheckError) as e:
|
||||
raise PacklistFormatError from e
|
||||
|
||||
@staticmethod
|
||||
def _assert_pack_object(obj: Any):
|
||||
if not isinstance(obj, dict):
|
||||
msg = f"A pack object should be `dict`, got {type(obj)}"
|
||||
raise PacklistFormatError(msg)
|
||||
|
||||
id_ = obj.get("id")
|
||||
if not isinstance(id_, str):
|
||||
msg = f"`id` attribute of a pack should be `str`, got value {id_!r}"
|
||||
raise PacklistFormatError(msg)
|
||||
|
||||
@classmethod
|
||||
def items(
|
||||
cls, packlist_content: dict, *, skip_asserts: bool = False
|
||||
) -> list[Pack | PackLocalization]:
|
||||
def check_pack_object(obj: dict) -> None:
|
||||
try:
|
||||
cls._assert_root_content(packlist_content)
|
||||
except PacklistFormatError as e:
|
||||
if skip_asserts:
|
||||
logger.exception("Error skipped during packlist parsing:")
|
||||
return []
|
||||
check_type(obj, dict)
|
||||
check_type(obj["id"], str)
|
||||
except (KeyError, TypeCheckError) as e:
|
||||
raise PacklistFormatError from e
|
||||
|
||||
raise e
|
||||
|
||||
packs = packlist_content["packs"]
|
||||
results: list[Pack | PackLocalization] = [
|
||||
Pack(
|
||||
id="single",
|
||||
name="Memory Archive",
|
||||
description=None,
|
||||
section=None,
|
||||
plus_character=None,
|
||||
append_parent_id=None,
|
||||
)
|
||||
]
|
||||
|
||||
for pack in packs:
|
||||
try:
|
||||
cls._assert_pack_object(pack)
|
||||
except PacklistFormatError as e:
|
||||
if skip_asserts:
|
||||
logger.exception("Error skipped during packlist parsing:")
|
||||
continue
|
||||
|
||||
raise e
|
||||
@staticmethod
|
||||
def parse_pack(pack: dict) -> AodisPack:
|
||||
id_ = pack["id"]
|
||||
|
||||
name_localized = pack.get("name_localized", {})
|
||||
description_localized = pack.get("description_localized", {})
|
||||
|
||||
id_ = pack["id"]
|
||||
name = blank_str_to_none(name_localized.get("en"))
|
||||
description = blank_str_to_none(description_localized.get("en"))
|
||||
section = blank_str_to_none(pack.get("section"))
|
||||
@ -103,49 +65,115 @@ class ArcaeaPacklistParser:
|
||||
pack["plus_character"] if pack.get("plus_character", -1) > -1 else None
|
||||
)
|
||||
|
||||
results.append(
|
||||
Pack(
|
||||
return AodisPack(
|
||||
id=id_,
|
||||
name=name,
|
||||
name=name or "",
|
||||
isWorldExtend=pack.get("is_extend_pack") is True,
|
||||
description=description,
|
||||
section=section,
|
||||
plus_character=plus_character,
|
||||
append_parent_id=append_parent_id,
|
||||
)
|
||||
unlocksPartner=plus_character,
|
||||
appendParentId=append_parent_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_pack_localizations(pack: dict) -> list[AodisPackLocalization]:
|
||||
results = []
|
||||
|
||||
name_localized = pack.get("name_localized", {})
|
||||
description_localized = pack.get("description_localized", {})
|
||||
|
||||
id_ = pack["id"]
|
||||
|
||||
for lang_key in ARCAEA_LANGUAGE_KEYS:
|
||||
name_l10n = blank_str_to_none(name_localized.get(lang_key))
|
||||
description_l10n = blank_str_to_none(
|
||||
description_localized.get(lang_key)
|
||||
description_localized.get(lang_key),
|
||||
)
|
||||
|
||||
if name_l10n or description_l10n:
|
||||
results.append(
|
||||
PackLocalization(
|
||||
AodisPackLocalization(
|
||||
id=id_,
|
||||
lang=lang_key,
|
||||
name=name_l10n,
|
||||
description=description_l10n,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def packs(cls, packlist_content: dict, *, skip_asserts: bool = False) -> list[Pack]:
|
||||
def items(
|
||||
cls,
|
||||
packlist_content: dict,
|
||||
*,
|
||||
skip_format_checks: bool = False,
|
||||
) -> list[AodisPack | AodisPackLocalization]:
|
||||
try:
|
||||
cls.check_root_content(packlist_content)
|
||||
except PacklistFormatError:
|
||||
if skip_format_checks:
|
||||
logger.exception("Error skipped during packlist parsing:")
|
||||
return []
|
||||
|
||||
raise
|
||||
|
||||
packs = packlist_content["packs"]
|
||||
results: list[AodisPack | AodisPackLocalization] = [
|
||||
AodisPack(
|
||||
id="single",
|
||||
name="Memory Archive",
|
||||
isWorldExtend=False,
|
||||
description=None,
|
||||
section=None,
|
||||
unlocksPartner=None,
|
||||
appendParentId=None,
|
||||
),
|
||||
]
|
||||
|
||||
for pack in packs:
|
||||
try:
|
||||
cls.check_pack_object(pack)
|
||||
except PacklistFormatError:
|
||||
if skip_format_checks:
|
||||
logger.exception("Error skipped during packlist parsing:")
|
||||
continue
|
||||
|
||||
raise
|
||||
|
||||
results.append(cls.parse_pack(pack))
|
||||
results.extend(cls.parse_pack_localizations(pack))
|
||||
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def packs(
|
||||
cls,
|
||||
packlist_content: dict,
|
||||
*,
|
||||
skip_format_checks: bool = False,
|
||||
) -> list[AodisPack]:
|
||||
return [
|
||||
item
|
||||
for item in cls.items(packlist_content, skip_asserts=skip_asserts)
|
||||
if isinstance(item, Pack)
|
||||
for item in cls.items(
|
||||
packlist_content,
|
||||
skip_format_checks=skip_format_checks,
|
||||
)
|
||||
if isinstance(item, AodisPack)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def pack_localizations(
|
||||
cls, packlist_content: dict, *, skip_asserts: bool = False
|
||||
) -> list[PackLocalization]:
|
||||
cls,
|
||||
packlist_content: dict,
|
||||
*,
|
||||
skip_format_checks: bool = False,
|
||||
) -> list[AodisPackLocalization]:
|
||||
return [
|
||||
item
|
||||
for item in cls.items(packlist_content, skip_asserts=skip_asserts)
|
||||
if isinstance(item, PackLocalization)
|
||||
for item in cls.items(
|
||||
packlist_content,
|
||||
skip_format_checks=skip_format_checks,
|
||||
)
|
||||
if isinstance(item, AodisPackLocalization)
|
||||
]
|
||||
|
@ -16,19 +16,27 @@ You should have received a copy of the GNU General Public License along with
|
||||
this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from arcaea_offline_schemas.dis.v5.dataclass import (
|
||||
AodisDifficulty,
|
||||
AodisDifficultyLocalization,
|
||||
AodisSong,
|
||||
AodisSongLocalization,
|
||||
)
|
||||
from arcaea_offline_schemas.dis.v5.dataclass.shared import (
|
||||
AddedAtObject,
|
||||
Backgrounds,
|
||||
BpmObject,
|
||||
)
|
||||
from arcaea_offline_schemas.dis.v5.dataclass.song import Source
|
||||
from typeguard import TypeCheckError, check_type
|
||||
|
||||
from ._shared import blank_str_to_none
|
||||
from .defs import (
|
||||
ARCAEA_LANGUAGE_KEYS,
|
||||
Difficulty,
|
||||
DifficultyLocalization,
|
||||
Song,
|
||||
SongLocalization,
|
||||
)
|
||||
from .defs import ARCAEA_LANGUAGE_KEYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -39,102 +47,90 @@ class SonglistFormatError(Exception):
|
||||
|
||||
class ArcaeaSonglistParser:
|
||||
@staticmethod
|
||||
def _assert_root_content(root: Any):
|
||||
if not isinstance(root, dict):
|
||||
msg = f"songlist root should be `dict`, got {type(root)}"
|
||||
raise SonglistFormatError(msg)
|
||||
|
||||
songs = root.get("songs")
|
||||
if songs is None:
|
||||
msg = "`songs` field does not exist"
|
||||
raise SonglistFormatError(msg)
|
||||
|
||||
if not isinstance(songs, list):
|
||||
msg = f"`songs` field should be `list`, got {type(songs)}"
|
||||
raise SonglistFormatError(msg)
|
||||
def check_root_content_format(root: dict) -> None:
|
||||
try:
|
||||
check_type(root, dict)
|
||||
check_type(root["songs"], list)
|
||||
except (KeyError, TypeCheckError) as e:
|
||||
raise SonglistFormatError from e
|
||||
|
||||
@staticmethod
|
||||
def _assert_song_object(obj: Any):
|
||||
if not isinstance(obj, dict):
|
||||
msg = f"A song object should be `dict`, got {type(obj)}"
|
||||
raise SonglistFormatError(msg)
|
||||
|
||||
id_ = obj.get("id")
|
||||
if not isinstance(id_, str):
|
||||
msg = f"`id` attribute of a song should be `str`, got value {id_!r}"
|
||||
raise SonglistFormatError(msg)
|
||||
|
||||
is_deleted = obj.get("deleted", False)
|
||||
if not isinstance(is_deleted, bool):
|
||||
msg = f"`deleted` attribute of a song should be `bool`, got value {id_!r}"
|
||||
raise SonglistFormatError(msg)
|
||||
|
||||
# TODO: check localized objects and difficulties (strict list[dict]) and booleans
|
||||
|
||||
@classmethod
|
||||
def items(
|
||||
cls, songlist_content: dict, *, skip_asserts: bool = False
|
||||
) -> list[Song | SongLocalization | Difficulty | DifficultyLocalization]:
|
||||
def check_song_object_format(song: dict) -> None:
|
||||
try:
|
||||
cls._assert_root_content(songlist_content)
|
||||
except SonglistFormatError as e:
|
||||
if skip_asserts:
|
||||
logger.exception("Error skipped during songlist parsing:")
|
||||
return []
|
||||
check_type(song, dict)
|
||||
check_type(song["id"], str)
|
||||
|
||||
raise e
|
||||
if deleted := song.get("deleted"):
|
||||
check_type(deleted, bool)
|
||||
|
||||
songs = songlist_content["songs"]
|
||||
results = []
|
||||
bg_daynight = song.get("bg_daynight", {})
|
||||
bg = blank_str_to_none(song.get("bg"))
|
||||
bg_inverse = blank_str_to_none(song.get("bg_inverse"))
|
||||
bg_day = blank_str_to_none(bg_daynight.get("day"))
|
||||
bg_night = blank_str_to_none(bg_daynight.get("night"))
|
||||
if (bg_inverse or bg_day or bg_night) and not bg:
|
||||
msg = "One of bg_inverse, bg_day and bg_night is set without bg"
|
||||
raise SonglistFormatError(msg)
|
||||
|
||||
for song in songs:
|
||||
try:
|
||||
cls._assert_song_object(song)
|
||||
except SonglistFormatError as e:
|
||||
if skip_asserts:
|
||||
logger.exception("Error skipped during songlist parsing:")
|
||||
continue
|
||||
except (KeyError, TypeCheckError) as e:
|
||||
raise SonglistFormatError from e
|
||||
|
||||
raise e
|
||||
|
||||
if song.get("deleted"):
|
||||
continue # TODO: none, return...
|
||||
# TODO(283375): localized objects & difficulties (strict list[dict]) & booleans # noqa: E501, FIX002, TD003
|
||||
|
||||
@staticmethod
|
||||
def parse_song(song: dict) -> list[AodisSong | AodisSongLocalization]:
|
||||
id_ = song["id"]
|
||||
title_localized = song.get("title_localized", {})
|
||||
bg_daynight = song.get("bg_daynight", {})
|
||||
source_localized = song.get("source_localized", {})
|
||||
jacket_localized = song.get("jacket_localized", {})
|
||||
|
||||
id_ = song["id"]
|
||||
bg = blank_str_to_none(song.get("bg"))
|
||||
bg_inverse = blank_str_to_none(song.get("bg_inverse"))
|
||||
bg_day = blank_str_to_none(bg_daynight.get("day"))
|
||||
bg_night = blank_str_to_none(bg_daynight.get("night"))
|
||||
|
||||
results.append(
|
||||
Song(
|
||||
results = []
|
||||
|
||||
aodis_song = AodisSong(
|
||||
pack_id=song["set"],
|
||||
id=id_,
|
||||
added_at=datetime.fromtimestamp(song["date"], tz=timezone.utc),
|
||||
idx=song.get("idx", None),
|
||||
addedAt=AddedAtObject(
|
||||
date=datetime.fromtimestamp(song["date"], tz=timezone.utc).isoformat(),
|
||||
version=blank_str_to_none(song.get("version")),
|
||||
),
|
||||
idx=song.get("idx"),
|
||||
title=blank_str_to_none(title_localized.get("en")),
|
||||
artist=blank_str_to_none(song.get("artist")),
|
||||
is_deleted=song.get("deleted", False),
|
||||
version=blank_str_to_none(song.get("version")),
|
||||
bpm=blank_str_to_none(song.get("bpm")),
|
||||
bpm_base=Decimal(song["bpm_base"])
|
||||
if song.get("bpm_base")
|
||||
isDeleted=song.get("deleted", False),
|
||||
bpm=BpmObject(
|
||||
value=song["bpm_base"],
|
||||
display=blank_str_to_none(song.get("bpm")),
|
||||
)
|
||||
if song.get("bpm_base") is not None
|
||||
else None,
|
||||
is_remote=song.get("remote_dl", False),
|
||||
is_unlockable_in_world=song.get("world_unlock", False),
|
||||
is_beyond_unlock_state_local=song.get("byd_local_unlock", False),
|
||||
isRemote=song.get("remote_dl", False),
|
||||
isUnlockableInWorld=song.get("world_unlock", False),
|
||||
isBeyondUnlockStateLocal=song.get("byd_local_unlock", False),
|
||||
purchase=blank_str_to_none(song.get("purchase")),
|
||||
category=blank_str_to_none(song.get("category")),
|
||||
side=song.get("side"),
|
||||
bg=blank_str_to_none(song.get("bg")),
|
||||
bg_inverse=blank_str_to_none(song.get("bg_inverse")),
|
||||
bg_day=blank_str_to_none(bg_daynight.get("day")),
|
||||
bg_night=blank_str_to_none(bg_daynight.get("night")),
|
||||
source=blank_str_to_none(source_localized.get("en")),
|
||||
source_copyright=blank_str_to_none(song.get("source_copyright")),
|
||||
background=Backgrounds(
|
||||
base=bg or bg_inverse or bg_day or bg_night,
|
||||
inverse=blank_str_to_none(song.get("bg_inverse")),
|
||||
day=blank_str_to_none(bg_daynight.get("day")),
|
||||
night=blank_str_to_none(bg_daynight.get("night")),
|
||||
)
|
||||
if bg is not None
|
||||
else None,
|
||||
source=Source(
|
||||
display=source_localized["en"],
|
||||
copyright=blank_str_to_none(song.get("source_copyright")),
|
||||
)
|
||||
if source_localized.get("en") is not None
|
||||
else None,
|
||||
)
|
||||
results.append(aodis_song)
|
||||
|
||||
for lang_key in ARCAEA_LANGUAGE_KEYS:
|
||||
title_l10n = blank_str_to_none(title_localized.get(lang_key))
|
||||
@ -143,66 +139,123 @@ class ArcaeaSonglistParser:
|
||||
|
||||
if title_l10n or source_l10n or has_localized_jacket:
|
||||
results.append(
|
||||
SongLocalization(
|
||||
AodisSongLocalization(
|
||||
id=id_,
|
||||
lang=lang_key,
|
||||
title=title_l10n,
|
||||
source=source_l10n,
|
||||
has_jacket=has_localized_jacket,
|
||||
)
|
||||
hasJacket=has_localized_jacket,
|
||||
),
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def parse_difficulties(
|
||||
song: dict,
|
||||
) -> list[AodisDifficulty | AodisDifficultyLocalization]:
|
||||
id_ = song["id"]
|
||||
difficulties = song["difficulties"]
|
||||
|
||||
results = []
|
||||
|
||||
for difficulty in difficulties:
|
||||
difficulty_title_localized = difficulty.get("title_localized", {})
|
||||
difficulty_artist_localized = difficulty.get("artist_localized", {})
|
||||
rating_class = difficulty["ratingClass"]
|
||||
|
||||
results.append(
|
||||
Difficulty(
|
||||
song_id=id_,
|
||||
rating_class=rating_class,
|
||||
AodisDifficulty(
|
||||
songId=id_,
|
||||
ratingClass=rating_class,
|
||||
rating=difficulty["rating"],
|
||||
is_rating_plus=difficulty.get("ratingPlus", False),
|
||||
chart_designer=blank_str_to_none(
|
||||
difficulty.get("chartDesigner")
|
||||
isRatingPlus=difficulty.get("ratingPlus", False),
|
||||
chartDesigner=blank_str_to_none(
|
||||
difficulty.get("chartDesigner"),
|
||||
),
|
||||
jacket_designer=blank_str_to_none(
|
||||
difficulty.get("jacketDesigner")
|
||||
jacketDesigner=blank_str_to_none(
|
||||
difficulty.get("jacketDesigner"),
|
||||
),
|
||||
has_overriding_audio=difficulty.get("audioOverride", False),
|
||||
has_overriding_jacket=difficulty.get("jacketOverride", False),
|
||||
jacket_night=blank_str_to_none(difficulty.get("jacket_night")),
|
||||
added_at=datetime.fromtimestamp(
|
||||
difficulty["date"], tz=timezone.utc
|
||||
hasOverridingAudio=difficulty.get("audioOverride", False),
|
||||
hasOverridingJacket=difficulty.get("jacketOverride", False),
|
||||
jacketNight=blank_str_to_none(difficulty.get("jacket_night")),
|
||||
addedAt=AddedAtObject(
|
||||
date=datetime.fromtimestamp(
|
||||
difficulty["date"],
|
||||
tz=timezone.utc,
|
||||
).isoformat(),
|
||||
version=blank_str_to_none(difficulty.get("version")),
|
||||
)
|
||||
if difficulty.get("date")
|
||||
else None,
|
||||
bg=blank_str_to_none(difficulty.get("bg")),
|
||||
version=blank_str_to_none(difficulty.get("version")),
|
||||
background=Backgrounds(base=difficulty["bg"])
|
||||
if difficulty.get("bg") is not None
|
||||
else None,
|
||||
title=blank_str_to_none(difficulty_title_localized.get("en")),
|
||||
artist=blank_str_to_none(difficulty_artist_localized.get("en")),
|
||||
is_legacy11=difficulty.get("legacy11", False),
|
||||
)
|
||||
isLegacy11=difficulty.get("legacy11", False),
|
||||
),
|
||||
)
|
||||
|
||||
for lang_key in ARCAEA_LANGUAGE_KEYS:
|
||||
difficuly_title_l10n = blank_str_to_none(
|
||||
difficulty_title_localized.get(lang_key)
|
||||
difficulty_title_localized.get(lang_key),
|
||||
)
|
||||
difficuly_artist_l10n = blank_str_to_none(
|
||||
difficulty_artist_localized.get(lang_key)
|
||||
difficulty_artist_localized.get(lang_key),
|
||||
)
|
||||
|
||||
if difficuly_title_l10n:
|
||||
results.append(
|
||||
DifficultyLocalization(
|
||||
song_id=id_,
|
||||
rating_class=rating_class,
|
||||
AodisDifficultyLocalization(
|
||||
songId=id_,
|
||||
ratingClass=rating_class,
|
||||
lang=lang_key,
|
||||
title=difficuly_title_l10n,
|
||||
artist=difficuly_artist_l10n,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def items(
|
||||
cls,
|
||||
songlist_content: dict,
|
||||
*,
|
||||
skip_format_checks: bool = False,
|
||||
) -> list[
|
||||
AodisSong
|
||||
| AodisSongLocalization
|
||||
| AodisDifficulty
|
||||
| AodisDifficultyLocalization
|
||||
]:
|
||||
try:
|
||||
cls.check_root_content_format(songlist_content)
|
||||
except SonglistFormatError:
|
||||
if skip_format_checks:
|
||||
logger.exception("Error skipped during songlist parsing:")
|
||||
return []
|
||||
|
||||
raise
|
||||
|
||||
songs = songlist_content["songs"]
|
||||
results = []
|
||||
|
||||
for song in songs:
|
||||
try:
|
||||
cls.check_song_object_format(song)
|
||||
except SonglistFormatError:
|
||||
if skip_format_checks:
|
||||
logger.exception("Error skipped during songlist parsing:")
|
||||
continue
|
||||
|
||||
raise
|
||||
|
||||
if song.get("deleted"):
|
||||
continue
|
||||
|
||||
results.extend(cls.parse_song(song))
|
||||
results.extend(cls.parse_difficulties(song))
|
||||
|
||||
return results
|
||||
|
@ -16,8 +16,11 @@ You should have received a copy of the GNU General Public License along with
|
||||
this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
|
||||
import zipfile
|
||||
from typing import Any, ClassVar, Optional
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from .assets import (
|
||||
ArcaeaApkAsset,
|
||||
@ -26,6 +29,9 @@ from .assets import (
|
||||
ArcaeaApkAssetPartnerIcon,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import zipfile
|
||||
|
||||
__all__ = ["ArcaeaApkAssetsProcessor"]
|
||||
|
||||
|
||||
@ -36,14 +42,8 @@ class ArcaeaApkAssetsProcessor:
|
||||
ArcaeaApkAssetList,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _check_zipfile(value: Any):
|
||||
if not isinstance(value, zipfile.ZipFile):
|
||||
msg = f"{value!r} is not a ZipFile!"
|
||||
raise TypeError(msg)
|
||||
|
||||
@classmethod
|
||||
def get_asset(cls, asset_zip_filename: str) -> Optional[ArcaeaApkAsset]:
|
||||
def get_asset(cls, asset_zip_filename: str) -> ArcaeaApkAsset | None:
|
||||
asset = None
|
||||
for asset_type in cls.ASSET_TYPES:
|
||||
if asset_ := asset_type.from_zip_filename(asset_zip_filename):
|
||||
@ -57,9 +57,8 @@ class ArcaeaApkAssetsProcessor:
|
||||
return cls.get_asset(asset_zip_filename) is not None
|
||||
|
||||
@classmethod
|
||||
@typechecked
|
||||
def get_assets(cls, zf: zipfile.ZipFile) -> dict[zipfile.ZipInfo, ArcaeaApkAsset]:
|
||||
cls._check_zipfile(zf)
|
||||
|
||||
matches = {}
|
||||
for info in zf.infolist():
|
||||
filename = info.filename
|
||||
|
7
noxfile.py
Normal file
7
noxfile.py
Normal file
@ -0,0 +1,7 @@
|
||||
import nox
|
||||
|
||||
|
||||
@nox.session
|
||||
def tests(session: nox.Session) -> None:
|
||||
session.install("pytest")
|
||||
session.run("pytest", "--doctest-modules", "arcaea_apk_assets")
|
@ -8,27 +8,28 @@ build-backend = "setuptools.build_meta"
|
||||
# dynamic = ["version"]
|
||||
name = "arcaea-apk-assets"
|
||||
description = "Your package description goes here."
|
||||
version = "0.1.0a"
|
||||
version = "0.1.0a0.dev2"
|
||||
requires-python = ">=3.9"
|
||||
authors = [
|
||||
{name = "283375", email = "log_283375@163.com"},
|
||||
]
|
||||
authors = [{ name = "283375", email = "log_283375@163.com" }]
|
||||
readme = "README.md"
|
||||
license = "GPL-3.0-or-later"
|
||||
dependencies = ["typeguard~=4.4"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/283375/arcaea-apk-assets"
|
||||
Issues = "https://github.com/283375/arcaea-apk-assets/issues"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle
|
||||
"F", # Pyflakes
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"SIM", # flake8-simplify
|
||||
"I", # isort
|
||||
"RET", # flake8-return
|
||||
"EM", # flake8-errmsg
|
||||
"CPY", # flake8-copyright
|
||||
"RUF", # Ruff-specific rules
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line-too-long
|
||||
select = ["ALL"]
|
||||
ignore = ["D"]
|
||||
|
||||
[tool.ruff.lint.extend-per-file-ignores]
|
||||
"tests/**/*.py" = [
|
||||
# Disable certain rules for tests
|
||||
# https://github.com/astral-sh/ruff/issues/4368#issuecomment-2245567481
|
||||
"S101", # asserts are allowed in tests
|
||||
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
|
||||
"ARG", # Unused function args: fixtures nevertheless are functionally relevant
|
||||
"FBT", # Don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize()
|
||||
"PLR2004", # Magic value used in comparison
|
||||
]
|
||||
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/contents/__init__.py
Normal file
0
tests/contents/__init__.py
Normal file
52
tests/contents/packlist.py
Normal file
52
tests/contents/packlist.py
Normal file
@ -0,0 +1,52 @@
|
||||
# ruff: noqa: RUF001
|
||||
|
||||
NORMAL = {
|
||||
"packs": [
|
||||
{
|
||||
"id": "test",
|
||||
"section": "mainstory",
|
||||
"is_extend_pack": False,
|
||||
"is_active_extend_pack": False,
|
||||
"custom_banner": True,
|
||||
"small_pack_image": False,
|
||||
"plus_character": -1,
|
||||
"name_localized": {"en": "Test Collection 1"},
|
||||
"description_localized": {
|
||||
"en": "DIVINE GRACE, AWAITS YOU IN A LOST WORLD, OF SILENT SONG...",
|
||||
"ja": "「光」も「対立」も全都有、「钙麻」失われた世界で、あなたを待ち受ける⋯",
|
||||
"ko": "고통이 고통이라는 이유로 그 자체를 사랑하고 소유하려는 자는 없다",
|
||||
"zh-Hant": "神的恩典,在一個失去的世界等你,介詞安靜的歌曲……",
|
||||
"zh-Hans": "神的恩典,在一个失去的世界等你,介词安静的歌曲……",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "test_other_properties",
|
||||
"section": "archive",
|
||||
"is_extend_pack": True,
|
||||
"is_active_extend_pack": False,
|
||||
"custom_banner": True,
|
||||
"small_pack_image": True,
|
||||
"plus_character": 123,
|
||||
"name_localized": {"en": "Test Collection 1 - Archive"},
|
||||
"description_localized": {
|
||||
"en": "A FADING LIGHT, AWAITS YOU IN A PURE MEMORY WORLD, OF MUSICAL CONFLICT...",
|
||||
"ja": "「消えゆく光」が、「ピュア・メモリー」に満ちた世界が、あなたを待ち受ける⋯",
|
||||
"ko": "혹은 아무런 즐거움도 생기지 않는 고통을 회피하는 사람을 누가 탓할 수 있겠는가?",
|
||||
"zh-Hant": "一個消色的光,在一個“純 淨 回 憶”的世界等你,介詞音樂的衝突……",
|
||||
"zh-Hans": "一个消色的光,在一个“纯 净 回 忆”的世界等你,介词音乐的冲突……",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
MALFORMED_WTF = {
|
||||
"wow": [],
|
||||
"random": {},
|
||||
"parts!": "huh?",
|
||||
}
|
||||
MALFORMED_ROOT = {
|
||||
"packs": {
|
||||
"pack1": "something",
|
||||
"pack2": "otherthing",
|
||||
},
|
||||
}
|
37
tests/test_packlist.py
Normal file
37
tests/test_packlist.py
Normal file
@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
|
||||
from arcaea_apk_assets.parsers.packlist import ArcaeaPacklistParser, PacklistFormatError
|
||||
from tests.contents import packlist as contents
|
||||
|
||||
|
||||
class TestPacklistParser:
|
||||
def test_normal(self):
|
||||
content = contents.NORMAL
|
||||
|
||||
ArcaeaPacklistParser.packs(content)
|
||||
|
||||
def test_malformed(self):
|
||||
with pytest.raises(PacklistFormatError):
|
||||
ArcaeaPacklistParser.items(contents.MALFORMED_WTF)
|
||||
|
||||
with pytest.raises(PacklistFormatError):
|
||||
ArcaeaPacklistParser.items(contents.MALFORMED_ROOT)
|
||||
|
||||
assert (
|
||||
len(
|
||||
ArcaeaPacklistParser.items(
|
||||
contents.MALFORMED_WTF,
|
||||
skip_format_checks=True,
|
||||
),
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert (
|
||||
len(
|
||||
ArcaeaPacklistParser.items(
|
||||
contents.MALFORMED_ROOT,
|
||||
skip_format_checks=True,
|
||||
),
|
||||
)
|
||||
== 0
|
||||
)
|
Reference in New Issue
Block a user