Compare commits

...

2 Commits

Author SHA1 Message Date
bcaa1a268e 2 2025-07-06 18:43:00 +08:00
155e8b9664 who cares 2025-07-05 14:27:26 +08:00
14 changed files with 482 additions and 411 deletions

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
.vscode/
.debug/
debug*.py
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View File

@ -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/>. this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import abc import abc
import dataclasses import dataclasses
import re import re
from typing import ClassVar, Optional from typing import ClassVar
__all__ = [ __all__ = [
"ArcaeaApkAsset", "ArcaeaApkAsset",
@ -35,27 +37,28 @@ class ArcaeaApkAsset(metaclass=abc.ABCMeta):
zip_filename: str zip_filename: str
@classmethod @classmethod
def _get_match(cls, string: str) -> Optional[re.Match]: def _get_match(cls, string: str) -> re.Match | None:
return cls.PATTERN.match(string) return cls.PATTERN.match(string)
@classmethod @classmethod
@abc.abstractmethod @abc.abstractmethod
def from_zip_filename( def from_zip_filename(
cls, asset_zip_filename: str cls,
) -> Optional["ArcaeaApkAsset"]: ... asset_zip_filename: str,
) -> ArcaeaApkAsset | None: ...
@dataclasses.dataclass @dataclasses.dataclass
class ArcaeaApkAssetJacket(ArcaeaApkAsset): class ArcaeaApkAssetJacket(ArcaeaApkAsset):
PATTERN = re.compile( 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 song_id: str
rating_class: Optional[str] rating_class: str | None
lang: Optional[str] lang: str | None
@classmethod @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) match = cls._get_match(asset_zip_filename)
if match is None: if match is None:
return None return None
@ -78,7 +81,10 @@ class ArcaeaApkAssetPartnerIcon(ArcaeaApkAsset):
char_id: str char_id: str
@classmethod @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) match = cls._get_match(asset_zip_filename)
if match is None: if match is None:
return None return None
@ -95,7 +101,7 @@ class ArcaeaApkAssetList(ArcaeaApkAsset):
filename: str filename: str
@classmethod @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) match = cls._get_match(asset_zip_filename)
if match is None: if match is None:
return None return None

View File

@ -1,12 +1,4 @@
from .defs import ( from .defs import ARCAEA_LANGUAGE_KEYS
ARCAEA_LANGUAGE_KEYS,
Difficulty,
DifficultyLocalization,
Pack,
PackLocalization,
Song,
SongLocalization,
)
from .packlist import ArcaeaPacklistParser from .packlist import ArcaeaPacklistParser
from .songlist import ArcaeaSonglistParser from .songlist import ArcaeaSonglistParser
@ -14,10 +6,4 @@ __all__ = [
"ARCAEA_LANGUAGE_KEYS", "ARCAEA_LANGUAGE_KEYS",
"ArcaeaPacklistParser", "ArcaeaPacklistParser",
"ArcaeaSonglistParser", "ArcaeaSonglistParser",
"Difficulty",
"DifficultyLocalization",
"Pack",
"PackLocalization",
"Song",
"SongLocalization",
] ]

View File

@ -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/>. this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import re 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. Check if a string equals to a blank string after stripped.
@ -37,14 +41,11 @@ def is_string_blank(string: str):
False False
""" """
if not isinstance(string, str):
msg = "A `str` object is expected"
raise TypeError(msg)
return string.strip() == "" 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 Convert a blank string (see :func:`~is_string_blank`) to None, otherwise return the
original string. original string.
@ -68,14 +69,11 @@ def blank_str_to_none(string: str):
if string is None: if string is None:
return 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 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: if text is None:
return None return None

View File

@ -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/>. 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"] 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]

View File

@ -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/>. this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import logging 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 ._shared import blank_str_to_none
from .defs import ARCAEA_LANGUAGE_KEYS, Pack, PackLocalization from .defs import ARCAEA_LANGUAGE_KEYS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -31,121 +35,145 @@ class PacklistFormatError(Exception):
class ArcaeaPacklistParser: class ArcaeaPacklistParser:
@staticmethod @staticmethod
def _assert_root_content(root: Any): def check_root_content(root: dict) -> None:
if not isinstance(root, dict): try:
msg = f"packlist root should be `dict`, got {type(root)}" check_type(root, dict)
raise PacklistFormatError(msg) check_type(root["packs"], list[dict])
except (KeyError, TypeCheckError) as e:
packs = root.get("packs") raise PacklistFormatError from e
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)
@staticmethod @staticmethod
def _assert_pack_object(obj: Any): def check_pack_object(obj: dict) -> None:
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]:
try: try:
cls._assert_root_content(packlist_content) check_type(obj, dict)
except PacklistFormatError as e: check_type(obj["id"], str)
if skip_asserts: except (KeyError, TypeCheckError) as e:
logger.exception("Error skipped during packlist parsing:") raise PacklistFormatError from e
return []
raise e @staticmethod
def parse_pack(pack: dict) -> AodisPack:
id_ = pack["id"]
packs = packlist_content["packs"] name_localized = pack.get("name_localized", {})
results: list[Pack | PackLocalization] = [ description_localized = pack.get("description_localized", {})
Pack(
id="single",
name="Memory Archive",
description=None,
section=None,
plus_character=None,
append_parent_id=None,
)
]
for pack in packs: name = blank_str_to_none(name_localized.get("en"))
try: description = blank_str_to_none(description_localized.get("en"))
cls._assert_pack_object(pack) section = blank_str_to_none(pack.get("section"))
except PacklistFormatError as e: append_parent_id = pack.get("pack_parent")
if skip_asserts: plus_character = (
logger.exception("Error skipped during packlist parsing:") pack["plus_character"] if pack.get("plus_character", -1) > -1 else None
continue )
raise e return AodisPack(
id=id_,
name=name or "",
isWorldExtend=pack.get("is_extend_pack") is True,
description=description,
section=section,
unlocksPartner=plus_character,
appendParentId=append_parent_id,
)
name_localized = pack.get("name_localized", {}) @staticmethod
description_localized = pack.get("description_localized", {}) def parse_pack_localizations(pack: dict) -> list[AodisPackLocalization]:
results = []
id_ = pack["id"] name_localized = pack.get("name_localized", {})
name = blank_str_to_none(name_localized.get("en")) description_localized = pack.get("description_localized", {})
description = blank_str_to_none(description_localized.get("en"))
section = blank_str_to_none(pack.get("section")) id_ = pack["id"]
append_parent_id = pack.get("pack_parent")
plus_character = ( for lang_key in ARCAEA_LANGUAGE_KEYS:
pack["plus_character"] if pack.get("plus_character", -1) > -1 else None name_l10n = blank_str_to_none(name_localized.get(lang_key))
description_l10n = blank_str_to_none(
description_localized.get(lang_key),
) )
results.append( if name_l10n or description_l10n:
Pack( results.append(
id=id_, AodisPackLocalization(
name=name, id=id_,
description=description, lang=lang_key,
section=section, name=name_l10n,
plus_character=plus_character, description=description_l10n,
append_parent_id=append_parent_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)
)
if name_l10n or description_l10n:
results.append(
PackLocalization(
id=id_,
lang=lang_key,
name=name_l10n,
description=description_l10n,
)
)
return results return results
@classmethod @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 [ return [
item item
for item in cls.items(packlist_content, skip_asserts=skip_asserts) for item in cls.items(
if isinstance(item, Pack) packlist_content,
skip_format_checks=skip_format_checks,
)
if isinstance(item, AodisPack)
] ]
@classmethod @classmethod
def pack_localizations( def pack_localizations(
cls, packlist_content: dict, *, skip_asserts: bool = False cls,
) -> list[PackLocalization]: packlist_content: dict,
*,
skip_format_checks: bool = False,
) -> list[AodisPackLocalization]:
return [ return [
item item
for item in cls.items(packlist_content, skip_asserts=skip_asserts) for item in cls.items(
if isinstance(item, PackLocalization) packlist_content,
skip_format_checks=skip_format_checks,
)
if isinstance(item, AodisPackLocalization)
] ]

View File

@ -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/>. this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import logging import logging
from datetime import datetime, timezone 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 ._shared import blank_str_to_none
from .defs import ( from .defs import ARCAEA_LANGUAGE_KEYS
ARCAEA_LANGUAGE_KEYS,
Difficulty,
DifficultyLocalization,
Song,
SongLocalization,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -39,170 +47,215 @@ class SonglistFormatError(Exception):
class ArcaeaSonglistParser: class ArcaeaSonglistParser:
@staticmethod @staticmethod
def _assert_root_content(root: Any): def check_root_content_format(root: dict) -> None:
if not isinstance(root, dict): try:
msg = f"songlist root should be `dict`, got {type(root)}" check_type(root, dict)
raise SonglistFormatError(msg) check_type(root["songs"], list)
except (KeyError, TypeCheckError) as e:
songs = root.get("songs") raise SonglistFormatError from e
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)
@staticmethod @staticmethod
def _assert_song_object(obj: Any): def check_song_object_format(song: dict) -> None:
if not isinstance(obj, dict): try:
msg = f"A song object should be `dict`, got {type(obj)}" check_type(song, dict)
raise SonglistFormatError(msg) check_type(song["id"], str)
id_ = obj.get("id") if deleted := song.get("deleted"):
if not isinstance(id_, str): check_type(deleted, bool)
msg = f"`id` attribute of a song should be `str`, got value {id_!r}"
raise SonglistFormatError(msg)
is_deleted = obj.get("deleted", False) bg_daynight = song.get("bg_daynight", {})
if not isinstance(is_deleted, bool): bg = blank_str_to_none(song.get("bg"))
msg = f"`deleted` attribute of a song should be `bool`, got value {id_!r}" bg_inverse = blank_str_to_none(song.get("bg_inverse"))
raise SonglistFormatError(msg) 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)
# TODO: check localized objects and difficulties (strict list[dict]) and booleans except (KeyError, TypeCheckError) as e:
raise SonglistFormatError from e
# 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", {})
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 = []
aodis_song = AodisSong(
pack_id=song["set"],
id=id_,
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")),
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,
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"),
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))
source_l10n = blank_str_to_none(source_localized.get(lang_key))
has_localized_jacket = jacket_localized.get(lang_key)
if title_l10n or source_l10n or has_localized_jacket:
results.append(
AodisSongLocalization(
id=id_,
lang=lang_key,
title=title_l10n,
source=source_l10n,
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(
AodisDifficulty(
songId=id_,
ratingClass=rating_class,
rating=difficulty["rating"],
isRatingPlus=difficulty.get("ratingPlus", False),
chartDesigner=blank_str_to_none(
difficulty.get("chartDesigner"),
),
jacketDesigner=blank_str_to_none(
difficulty.get("jacketDesigner"),
),
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,
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")),
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),
)
difficuly_artist_l10n = blank_str_to_none(
difficulty_artist_localized.get(lang_key),
)
if difficuly_title_l10n:
results.append(
AodisDifficultyLocalization(
songId=id_,
ratingClass=rating_class,
lang=lang_key,
title=difficuly_title_l10n,
artist=difficuly_artist_l10n,
),
)
return results
@classmethod @classmethod
def items( def items(
cls, songlist_content: dict, *, skip_asserts: bool = False cls,
) -> list[Song | SongLocalization | Difficulty | DifficultyLocalization]: songlist_content: dict,
*,
skip_format_checks: bool = False,
) -> list[
AodisSong
| AodisSongLocalization
| AodisDifficulty
| AodisDifficultyLocalization
]:
try: try:
cls._assert_root_content(songlist_content) cls.check_root_content_format(songlist_content)
except SonglistFormatError as e: except SonglistFormatError:
if skip_asserts: if skip_format_checks:
logger.exception("Error skipped during songlist parsing:") logger.exception("Error skipped during songlist parsing:")
return [] return []
raise e raise
songs = songlist_content["songs"] songs = songlist_content["songs"]
results = [] results = []
for song in songs: for song in songs:
try: try:
cls._assert_song_object(song) cls.check_song_object_format(song)
except SonglistFormatError as e: except SonglistFormatError:
if skip_asserts: if skip_format_checks:
logger.exception("Error skipped during songlist parsing:") logger.exception("Error skipped during songlist parsing:")
continue continue
raise e raise
if song.get("deleted"): if song.get("deleted"):
continue # TODO: none, return... continue
title_localized = song.get("title_localized", {}) results.extend(cls.parse_song(song))
bg_daynight = song.get("bg_daynight", {}) results.extend(cls.parse_difficulties(song))
source_localized = song.get("source_localized", {})
jacket_localized = song.get("jacket_localized", {})
id_ = song["id"]
results.append(
Song(
pack_id=song["set"],
id=id_,
added_at=datetime.fromtimestamp(song["date"], tz=timezone.utc),
idx=song.get("idx", None),
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")
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),
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")),
)
)
for lang_key in ARCAEA_LANGUAGE_KEYS:
title_l10n = blank_str_to_none(title_localized.get(lang_key))
source_l10n = blank_str_to_none(source_localized.get(lang_key))
has_localized_jacket = jacket_localized.get(lang_key)
if title_l10n or source_l10n or has_localized_jacket:
results.append(
SongLocalization(
id=id_,
lang=lang_key,
title=title_l10n,
source=source_l10n,
has_jacket=has_localized_jacket,
)
)
difficulties = song["difficulties"]
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,
rating=difficulty["rating"],
is_rating_plus=difficulty.get("ratingPlus", False),
chart_designer=blank_str_to_none(
difficulty.get("chartDesigner")
),
jacket_designer=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
)
if difficulty.get("date")
else None,
bg=blank_str_to_none(difficulty.get("bg")),
version=blank_str_to_none(difficulty.get("version")),
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),
)
)
for lang_key in ARCAEA_LANGUAGE_KEYS:
difficuly_title_l10n = blank_str_to_none(
difficulty_title_localized.get(lang_key)
)
difficuly_artist_l10n = blank_str_to_none(
difficulty_artist_localized.get(lang_key)
)
if difficuly_title_l10n:
results.append(
DifficultyLocalization(
song_id=id_,
rating_class=rating_class,
lang=lang_key,
title=difficuly_title_l10n,
artist=difficuly_artist_l10n,
)
)
return results return results

View File

@ -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/>. this program. If not, see <https://www.gnu.org/licenses/>.
""" """
import zipfile from __future__ import annotations
from typing import Any, ClassVar, Optional
from typing import TYPE_CHECKING, ClassVar
from typeguard import typechecked
from .assets import ( from .assets import (
ArcaeaApkAsset, ArcaeaApkAsset,
@ -26,6 +29,9 @@ from .assets import (
ArcaeaApkAssetPartnerIcon, ArcaeaApkAssetPartnerIcon,
) )
if TYPE_CHECKING:
import zipfile
__all__ = ["ArcaeaApkAssetsProcessor"] __all__ = ["ArcaeaApkAssetsProcessor"]
@ -36,14 +42,8 @@ class ArcaeaApkAssetsProcessor:
ArcaeaApkAssetList, 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 @classmethod
def get_asset(cls, asset_zip_filename: str) -> Optional[ArcaeaApkAsset]: def get_asset(cls, asset_zip_filename: str) -> ArcaeaApkAsset | None:
asset = None asset = None
for asset_type in cls.ASSET_TYPES: for asset_type in cls.ASSET_TYPES:
if asset_ := asset_type.from_zip_filename(asset_zip_filename): 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 return cls.get_asset(asset_zip_filename) is not None
@classmethod @classmethod
@typechecked
def get_assets(cls, zf: zipfile.ZipFile) -> dict[zipfile.ZipInfo, ArcaeaApkAsset]: def get_assets(cls, zf: zipfile.ZipFile) -> dict[zipfile.ZipInfo, ArcaeaApkAsset]:
cls._check_zipfile(zf)
matches = {} matches = {}
for info in zf.infolist(): for info in zf.infolist():
filename = info.filename filename = info.filename

7
noxfile.py Normal file
View 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")

View File

@ -8,27 +8,28 @@ build-backend = "setuptools.build_meta"
# dynamic = ["version"] # dynamic = ["version"]
name = "arcaea-apk-assets" name = "arcaea-apk-assets"
description = "Your package description goes here." description = "Your package description goes here."
version = "0.1.0a" version = "0.1.0a0.dev2"
requires-python = ">=3.9" requires-python = ">=3.9"
authors = [ authors = [{ name = "283375", email = "log_283375@163.com" }]
{name = "283375", email = "log_283375@163.com"},
]
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" 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] [tool.ruff.lint]
select = [ select = ["ALL"]
"E", # pycodestyle ignore = ["D"]
"F", # Pyflakes
"UP", # pyupgrade [tool.ruff.lint.extend-per-file-ignores]
"B", # flake8-bugbear "tests/**/*.py" = [
"SIM", # flake8-simplify # Disable certain rules for tests
"I", # isort # https://github.com/astral-sh/ruff/issues/4368#issuecomment-2245567481
"RET", # flake8-return "S101", # asserts are allowed in tests
"EM", # flake8-errmsg "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
"CPY", # flake8-copyright "ARG", # Unused function args: fixtures nevertheless are functionally relevant
"RUF", # Ruff-specific rules "FBT", # Don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize()
] "PLR2004", # Magic value used in comparison
ignore = [
"E501", # line-too-long
] ]

0
tests/__init__.py Normal file
View File

View File

View 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
View 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
)