This commit is contained in:
2025-05-17 15:52:35 +08:00
commit 87a5b5ae5f
12 changed files with 1674 additions and 0 deletions

View File

@ -0,0 +1,15 @@
from .aseets import (
ArcaeaApkAsset,
ArcaeaApkAssetJacket,
ArcaeaApkAssetList,
ArcaeaApkAssetPartnerIcon,
)
from .processor import ArcaeaApkAssetsProcessor
__all__ = [
"ArcaeaApkAsset",
"ArcaeaApkAssetJacket",
"ArcaeaApkAssetList",
"ArcaeaApkAssetPartnerIcon",
"ArcaeaApkAssetsProcessor",
]

106
arcaea_apk_assets/aseets.py Normal file
View File

@ -0,0 +1,106 @@
"""
Copyright (C) 2025 283375
This file is part of "arcaea-apk-assets" (stated as "this program" below).
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
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 abc
import dataclasses
import re
from typing import ClassVar, Optional
__all__ = [
"ArcaeaApkAsset",
"ArcaeaApkAssetJacket",
"ArcaeaApkAssetList",
"ArcaeaApkAssetPartnerIcon",
]
@dataclasses.dataclass
class ArcaeaApkAsset(metaclass=abc.ABCMeta):
PATTERN: ClassVar[re.Pattern]
zip_filename: str
@classmethod
def _get_match(cls, string: str) -> Optional[re.Match]:
return cls.PATTERN.match(string)
@classmethod
@abc.abstractmethod
def from_zip_filename(
cls, asset_zip_filename: str
) -> Optional["ArcaeaApkAsset"]: ...
@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)"
)
song_id: str
rating_class: Optional[str]
lang: Optional[str]
@classmethod
def from_zip_filename(cls, asset_zip_filename: str):
match = cls._get_match(asset_zip_filename)
if match is None:
return None
return cls(
zip_filename=asset_zip_filename,
song_id=match.group("raw_song_id").replace("dl_", ""),
rating_class=(
None
if match.group("raw_rating_class") == "base"
else match.group("raw_rating_class")
),
lang=match.group("lang"),
)
@dataclasses.dataclass
class ArcaeaApkAssetPartnerIcon(ArcaeaApkAsset):
PATTERN = re.compile(r"^assets/char/(?P<char_id>\d+u?)_icon\.(jpg|png)")
char_id: str
@classmethod
def from_zip_filename(cls, asset_zip_filename: str):
match = cls._get_match(asset_zip_filename)
if match is None:
return None
return cls(
zip_filename=asset_zip_filename,
char_id=match.group("char_id"),
)
@dataclasses.dataclass
class ArcaeaApkAssetList(ArcaeaApkAsset):
PATTERN = re.compile(r"^assets/songs/(?P<filename>(pack|song)list|unlocks)")
filename: str
@classmethod
def from_zip_filename(cls, asset_zip_filename: str):
match = cls._get_match(asset_zip_filename)
if match is None:
return None
return cls(
zip_filename=asset_zip_filename,
filename=match.group("filename"),
)

View File

@ -0,0 +1,23 @@
from .defs import (
ARCAEA_LANGUAGE_KEYS,
Difficulty,
DifficultyLocalization,
Pack,
PackLocalization,
Song,
SongLocalization,
)
from .packlist import ArcaeaPacklistParser
from .songlist import ArcaeaSonglistParser
__all__ = [
"ARCAEA_LANGUAGE_KEYS",
"ArcaeaPacklistParser",
"ArcaeaSonglistParser",
"Difficulty",
"DifficultyLocalization",
"Pack",
"PackLocalization",
"Song",
"SongLocalization",
]

View File

@ -0,0 +1,82 @@
"""
Copyright (C) 2025 283375
This file is part of "arcaea-apk-assets" (stated as "this program" below).
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
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 re
from typing import Optional
def is_string_blank(string: str):
"""
Check if a string equals to a blank string after stripped.
>>> is_string_blank("abc")
False
>>> is_string_blank("")
True
>>> is_string_blank(" ")
True
>>> is_string_blank(" a ")
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):
"""
Convert a blank string (see :func:`~is_string_blank`) to None, otherwise return the
original string.
>>> blank_str_to_none("") is None
True
>>> blank_str_to_none("abc") == "abc"
True
>>> blank_str_to_none(" ") is None
True
>>> blank_str_to_none(" abc ") == " abc "
True
>>> blank_str_to_none(None) is None
True
"""
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]):
if text is None:
return None
return re.sub(r"\n+", lambda m: "\n" * (len(m.group()) - 1), text)

View File

@ -0,0 +1,118 @@
"""
Copyright (C) 2025 283375
This file is part of "arcaea-apk-assets" (stated as "this program" below).
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
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
is_legacy11: bool = False
@dataclass
class DifficultyLocalization:
song_id: str
rating_class: int
lang: str
title: Optional[str]
artist: Optional[str]

View File

@ -0,0 +1,151 @@
"""
Copyright (C) 2025 283375
This file is part of "arcaea-apk-assets" (stated as "this program" below).
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
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 logging
from typing import Any
from ._shared import blank_str_to_none
from .defs import ARCAEA_LANGUAGE_KEYS, Pack, PackLocalization
logger = logging.getLogger(__name__)
class PacklistFormatError(Exception):
pass
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)
@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]:
try:
cls._assert_root_content(packlist_content)
except PacklistFormatError as e:
if skip_asserts:
logger.exception("Error skipped during packlist parsing:")
return []
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
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"))
append_parent_id = pack.get("pack_parent")
plus_character = (
pack["plus_character"] if pack.get("plus_character", -1) > -1 else None
)
results.append(
Pack(
id=id_,
name=name,
description=description,
section=section,
plus_character=plus_character,
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
@classmethod
def packs(cls, packlist_content: dict, *, skip_asserts: bool = False) -> list[Pack]:
return [
item
for item in cls.items(packlist_content, skip_asserts=skip_asserts)
if isinstance(item, Pack)
]
@classmethod
def pack_localizations(
cls, packlist_content: dict, *, skip_asserts: bool = False
) -> list[PackLocalization]:
return [
item
for item in cls.items(packlist_content, skip_asserts=skip_asserts)
if isinstance(item, PackLocalization)
]

View File

@ -0,0 +1,208 @@
"""
Copyright (C) 2025 283375
This file is part of "arcaea-apk-assets" (stated as "this program" below).
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
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 logging
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any
from ._shared import blank_str_to_none
from .defs import (
ARCAEA_LANGUAGE_KEYS,
Difficulty,
DifficultyLocalization,
Song,
SongLocalization,
)
logger = logging.getLogger(__name__)
class SonglistFormatError(Exception):
pass
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)
@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]:
try:
cls._assert_root_content(songlist_content)
except SonglistFormatError as e:
if skip_asserts:
logger.exception("Error skipped during songlist parsing:")
return []
raise e
songs = songlist_content["songs"]
results = []
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
raise e
if song.get("deleted"):
continue # TODO: none, return...
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"]
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

View File

@ -0,0 +1,70 @@
"""
Copyright (C) 2025 283375
This file is part of "arcaea-apk-assets" (stated as "this program" below).
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
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 .aseets import (
ArcaeaApkAsset,
ArcaeaApkAssetJacket,
ArcaeaApkAssetList,
ArcaeaApkAssetPartnerIcon,
)
__all__ = ["ArcaeaApkAssetsProcessor"]
class ArcaeaApkAssetsProcessor:
ASSET_TYPES: ClassVar[list[type[ArcaeaApkAsset]]] = [
ArcaeaApkAssetJacket,
ArcaeaApkAssetPartnerIcon,
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]:
asset = None
for asset_type in cls.ASSET_TYPES:
if asset_ := asset_type.from_zip_filename(asset_zip_filename):
asset = asset_
break
return asset
@classmethod
def is_asset(cls, asset_zip_filename: str) -> bool:
return cls.get_asset(asset_zip_filename) is not None
@classmethod
def get_assets(cls, zf: zipfile.ZipFile) -> dict[zipfile.ZipInfo, ArcaeaApkAsset]:
cls._check_zipfile(zf)
matches = {}
for info in zf.infolist():
filename = info.filename
asset = cls.get_asset(filename)
if asset is not None:
matches[info] = asset
return matches