""" 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 . """ from __future__ import annotations import logging from datetime import datetime, timezone 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 logger = logging.getLogger(__name__) class SonglistFormatError(Exception): pass class ArcaeaSonglistParser: @staticmethod 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 check_song_object_format(song: dict) -> None: try: check_type(song, dict) check_type(song["id"], str) if deleted := song.get("deleted"): check_type(deleted, bool) 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) 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 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