maimai_py.providers

 1from .arcade import ArcadeProvider
 2from .base import (
 3    IAliasProvider,
 4    IAreaProvider,
 5    ICurveProvider,
 6    IItemListProvider,
 7    IPlayerIdentifierProvider,
 8    IPlayerProvider,
 9    IProvider,
10    IRegionProvider,
11    IScoreProvider,
12    IScoreUpdateProvider,
13    ISongProvider,
14)
15from .divingfish import DivingFishProvider
16from .local import LocalProvider
17from .lxns import LXNSProvider
18from .wechat import WechatProvider
19from .yuzu import YuzuProvider
20
21__all__ = [
22    "IProvider",
23    "IScoreUpdateProvider",
24    "IAliasProvider",
25    "IAreaProvider",
26    "ICurveProvider",
27    "IItemListProvider",
28    "IPlayerProvider",
29    "IRegionProvider",
30    "IScoreProvider",
31    "ISongProvider",
32    "IPlayerIdentifierProvider",
33    "ArcadeProvider",
34    "DivingFishProvider",
35    "LocalProvider",
36    "LXNSProvider",
37    "WechatProvider",
38    "YuzuProvider",
39]
class IProvider:
11class IProvider:
12    @abstractmethod
13    def _hash(self) -> str: ...
class IScoreUpdateProvider(maimai_py.providers.IProvider):
73class IScoreUpdateProvider(IProvider):
74    """The provider that updates scores to a specific source.
75
76    Available providers: `DivingFishProvider`, `LXNSProvider`
77    """
78
79    @abstractmethod
80    async def update_scores(self, identifier: PlayerIdentifier, scores: Iterable[Score], client: "MaimaiClient") -> None:
81        """@private"""
82        raise NotImplementedError()

The provider that updates scores to a specific source.

Available providers: DivingFishProvider, LXNSProvider

class IAliasProvider(maimai_py.providers.IProvider):
28class IAliasProvider(IProvider):
29    """The provider that fetches song aliases from a specific source.
30
31    Available providers: `YuzuProvider`, `LXNSProvider`
32    """
33
34    @abstractmethod
35    async def get_aliases(self, client: "MaimaiClient") -> dict[int, list[str]]:
36        """@private"""
37        raise NotImplementedError()

The provider that fetches song aliases from a specific source.

Available providers: YuzuProvider, LXNSProvider

class IAreaProvider(maimai_py.providers.IProvider):
146class IAreaProvider(IProvider):
147    """The provider that fetches area data from a specific source.
148
149    Available providers: `LocalProvider`
150    """
151
152    @abstractmethod
153    async def get_areas(self, lang: str, client: "MaimaiClient") -> dict[str, Area]:
154        """@private"""
155        raise NotImplementedError()

The provider that fetches area data from a specific source.

Available providers: LocalProvider

class ICurveProvider(maimai_py.providers.IProvider):
85class ICurveProvider(IProvider):
86    """The provider that fetches statistics curves from a specific source.
87
88    Available providers: `DivingFishProvider`
89    """
90
91    @abstractmethod
92    async def get_curves(self, client: "MaimaiClient") -> dict[tuple[int, SongType], list[CurveObject]]:
93        """@private"""
94        raise NotImplementedError()

The provider that fetches statistics curves from a specific source.

Available providers: DivingFishProvider

class IItemListProvider(maimai_py.providers.IProvider):
109class IItemListProvider(IProvider):
110    """The provider that fetches player item list data from a specific source.
111
112    Available providers: `LXNSProvider`, `LocalProvider`
113    """
114
115    @abstractmethod
116    async def get_icons(self, client: "MaimaiClient") -> dict[int, PlayerIcon]:
117        """@private"""
118        raise NotImplementedError()
119
120    @abstractmethod
121    async def get_nameplates(self, client: "MaimaiClient") -> dict[int, PlayerNamePlate]:
122        """@private"""
123        raise NotImplementedError()
124
125    @abstractmethod
126    async def get_frames(self, client: "MaimaiClient") -> dict[int, PlayerFrame]:
127        """@private"""
128        raise NotImplementedError()
129
130    @abstractmethod
131    async def get_partners(self, client: "MaimaiClient") -> dict[int, PlayerPartner]:
132        """@private"""
133        raise NotImplementedError()
134
135    @abstractmethod
136    async def get_charas(self, client: "MaimaiClient") -> dict[int, PlayerChara]:
137        """@private"""
138        raise NotImplementedError()
139
140    @abstractmethod
141    async def get_trophies(self, client: "MaimaiClient") -> dict[int, PlayerTrophy]:
142        """@private"""
143        raise NotImplementedError()

The provider that fetches player item list data from a specific source.

Available providers: LXNSProvider, LocalProvider

class IPlayerProvider(maimai_py.providers.IProvider):
40class IPlayerProvider(IProvider):
41    """The provider that fetches players from a specific source.
42
43    Available providers: `DivingFishProvider`, `LXNSProvider`
44    """
45
46    @abstractmethod
47    async def get_player(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> Player:
48        """@private"""
49        raise NotImplementedError()

The provider that fetches players from a specific source.

Available providers: DivingFishProvider, LXNSProvider

class IRegionProvider(maimai_py.providers.IProvider):
 97class IRegionProvider(IProvider):
 98    """The provider that fetches player regions from a specific source.
 99
100    Available providers: `ArcadeProvider`
101    """
102
103    @abstractmethod
104    async def get_regions(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> list[PlayerRegion]:
105        """@private"""
106        raise NotImplementedError()

The provider that fetches player regions from a specific source.

Available providers: ArcadeProvider

class IScoreProvider(maimai_py.providers.IProvider):
52class IScoreProvider(IProvider):
53    """The provider that fetches scores from a specific source.
54
55    Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`
56    """
57
58    async def get_scores_one(self, identifier: PlayerIdentifier, song: Song, client: "MaimaiClient") -> list[Score]:
59        """@private"""
60        scores = await self.get_scores_all(identifier, client)
61        return [score for score in scores if score.id == song.id]
62
63    async def get_scores_best(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> list[Score]:
64        """@private"""
65        return await self.get_scores_all(identifier, client)
66
67    @abstractmethod
68    async def get_scores_all(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> list[Score]:
69        """@private"""
70        raise NotImplementedError()

The provider that fetches scores from a specific source.

Available providers: DivingFishProvider, LXNSProvider, WechatProvider

class ISongProvider(maimai_py.providers.IProvider):
16class ISongProvider(IProvider):
17    """The provider that fetches songs from a specific source.
18
19    Available providers: `DivingFishProvider`, `LXNSProvider`
20    """
21
22    @abstractmethod
23    async def get_songs(self, client: "MaimaiClient") -> list[Song]:
24        """@private"""
25        raise NotImplementedError()

The provider that fetches songs from a specific source.

Available providers: DivingFishProvider, LXNSProvider

class IPlayerIdentifierProvider(maimai_py.providers.IProvider):
158class IPlayerIdentifierProvider(IProvider):
159    """The provider that fetches player identifiers from a specific source.
160
161    Available providers: `ArcadeProvider`
162    """
163
164    @abstractmethod
165    async def get_identifier(self, code: Union[str, dict[str, str]], client: "MaimaiClient") -> PlayerIdentifier:
166        """@private"""
167        raise NotImplementedError()

The provider that fetches player identifiers from a specific source.

Available providers: ArcadeProvider

 19class ArcadeProvider(IPlayerProvider, IScoreProvider, IRegionProvider, IPlayerIdentifierProvider):
 20    """The provider that fetches data from the wahlap maimai arcade.
 21
 22    This part of the maimai.py is not open-source, we distribute the compiled version of this part of the code as maimai_ffi.
 23
 24    Feel free to ask us to solve if your platform or architecture is not supported.
 25
 26    maimai.ffi: https://pypi.org/project/maimai-ffi
 27    """
 28
 29    _http_proxy: Optional[str] = None
 30
 31    def __init__(self, http_proxy: Optional[str] = None):
 32        self._http_proxy = http_proxy
 33
 34    def _hash(self) -> str:
 35        return hashlib.md5(b"arcade").hexdigest()
 36
 37    @staticmethod
 38    async def _deser_score(score: dict, songs: "MaimaiSongs") -> Optional[Score]:
 39        song_type = SongType._from_id(score["musicId"])
 40        level_index = LevelIndex(score["level"]) if song_type != SongType.UTAGE else None
 41        achievement = float(score["achievement"]) / 10000
 42        if song := await songs.by_id(score["musicId"] % 10000):
 43            if level_index and (diff := song.get_difficulty(song_type, level_index)):
 44                fs_type = FSType(score["syncStatus"]) if 0 < score["syncStatus"] < 5 else None
 45                fs_type = FSType.SYNC if score["syncStatus"] == 5 else fs_type
 46                return Score(
 47                    id=song.id,
 48                    level=diff.level,
 49                    level_index=diff.level_index,
 50                    achievements=achievement,
 51                    fc=FCType(4 - score["comboStatus"]) if score["comboStatus"] != 0 else None,
 52                    fs=fs_type,
 53                    dx_score=score["deluxscoreMax"],
 54                    dx_rating=ScoreCoefficient(achievement).ra(diff.level_value),
 55                    play_count=score["playCount"],
 56                    rate=RateType._from_achievement(achievement),
 57                    type=song_type,
 58                )
 59
 60    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(TitleServerNetworkError), reraise=True)
 61    async def get_player(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> ArcadePlayer:
 62        maimai_icons = await client.items(PlayerIcon)
 63        maimai_trophies = await client.items(PlayerTrophy)
 64        maimai_nameplates = await client.items(PlayerNamePlate)
 65        if identifier.credentials and isinstance(identifier.credentials, str):
 66            resp_dict = await arcade.get_user_preview(identifier.credentials.encode(), http_proxy=self._http_proxy)
 67            return ArcadePlayer(
 68                name=resp_dict["userName"],
 69                rating=resp_dict["playerRating"],
 70                is_login=resp_dict["isLogin"],
 71                name_plate=await maimai_nameplates.by_id(resp_dict["nameplateId"]),
 72                icon=await maimai_icons.by_id(resp_dict["iconId"]),
 73                trophy=await maimai_trophies.by_id(resp_dict["trophyId"]),
 74            )
 75        raise InvalidPlayerIdentifierError("Player identifier credentials should be provided.")
 76
 77    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(TitleServerNetworkError), reraise=True)
 78    async def get_scores_all(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> list[Score]:
 79        maimai_songs = await client.songs()
 80        if identifier.credentials and isinstance(identifier.credentials, str):
 81            resp_list = await arcade.get_user_scores(identifier.credentials.encode(), http_proxy=self._http_proxy)
 82            return [s for score in resp_list if (s := await ArcadeProvider._deser_score(score, maimai_songs))]
 83        raise InvalidPlayerIdentifierError("Player identifier credentials should be provided.")
 84
 85    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(TitleServerNetworkError), reraise=True)
 86    async def get_regions(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> list[PlayerRegion]:
 87        if identifier.credentials and isinstance(identifier.credentials, str):
 88            resp_dict = await arcade.get_user_region(identifier.credentials.encode(), http_proxy=self._http_proxy)
 89            return [
 90                PlayerRegion(
 91                    region_id=region["regionId"],
 92                    region_name=region["regionName"],
 93                    play_count=region["playCount"],
 94                    created_at=datetime.strptime(region["created"], "%Y-%m-%d %H:%M:%S"),
 95                )
 96                for region in resp_dict["userRegionList"]
 97            ]
 98        raise InvalidPlayerIdentifierError("Player identifier credentials should be provided.")
 99
100    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type((TimeoutException, NetworkError)), reraise=True)
101    async def get_identifier(self, code: Union[str, dict[str, str]], client: "MaimaiClient") -> PlayerIdentifier:
102        resp_bytes: bytes = await arcade.get_uid_encrypted(str(code), http_proxy=self._http_proxy)
103        return PlayerIdentifier(credentials=resp_bytes.decode())

The provider that fetches data from the wahlap maimai arcade.

This part of the maimai.py is not open-source, we distribute the compiled version of this part of the code as maimai_ffi.

Feel free to ask us to solve if your platform or architecture is not supported.

maimai.ffi: https://pypi.org/project/maimai-ffi

ArcadeProvider(http_proxy: Optional[str] = None)
31    def __init__(self, http_proxy: Optional[str] = None):
32        self._http_proxy = http_proxy
 19class DivingFishProvider(ISongProvider, IPlayerProvider, IScoreProvider, IScoreUpdateProvider, ICurveProvider):
 20    """The provider that fetches data from the Diving Fish.
 21
 22    DivingFish: https://www.diving-fish.com/maimaidx/prober/
 23    """
 24
 25    developer_token: Optional[str]
 26    """The developer token used to access the Diving Fish API."""
 27    base_url = "https://www.diving-fish.com/api/maimaidxprober/"
 28    """The base URL for the Diving Fish API."""
 29
 30    @property
 31    def headers(self):
 32        """@private"""
 33        if not self.developer_token:
 34            raise InvalidDeveloperTokenError("Developer token is not provided.")
 35        return {"developer-token": self.developer_token}
 36
 37    def __init__(self, developer_token: Optional[str] = None):
 38        """Initializes the DivingFishProvider.
 39
 40        Args:
 41            developer_token: The developer token used to access the Diving Fish API.
 42        """
 43        self.developer_token = developer_token
 44
 45    def _hash(self) -> str:
 46        return hashlib.md5(b"divingfish").hexdigest()
 47
 48    @staticmethod
 49    def _deser_song(song: dict) -> Song:
 50        return Song(
 51            id=int(song["id"]) % 10000,
 52            title=song["basic_info"]["title"] if int(song["id"]) != 383 else "Link",
 53            artist=song["basic_info"]["artist"],
 54            genre=name_to_genre[song["basic_info"]["genre"]],
 55            bpm=song["basic_info"]["bpm"],
 56            map=None,
 57            rights=None,
 58            aliases=None,
 59            version=divingfish_to_version[song["basic_info"]["from"]].value,
 60            disabled=False,
 61            difficulties=SongDifficulties(standard=[], dx=[], utage=[]),
 62        )
 63
 64    @staticmethod
 65    def _deser_diffs(song: dict) -> Generator[SongDifficulty, None, None]:
 66        song_type = SongType._from_id(song["id"])
 67        for idx, chart in enumerate(song["charts"]):
 68            song_diff = SongDifficulty(
 69                type=song_type,
 70                level=song["level"][idx],
 71                level_value=song["ds"][idx],
 72                level_index=LevelIndex(idx),
 73                note_designer=chart["charter"],
 74                version=divingfish_to_version[song["basic_info"]["from"]].value,
 75                tap_num=chart["notes"][0],
 76                hold_num=chart["notes"][1],
 77                slide_num=chart["notes"][2],
 78                touch_num=chart["notes"][3] if song_type == SongType.DX else 0,
 79                break_num=chart["notes"][4] if song_type == SongType.DX else chart["notes"][3],
 80                curve=None,
 81            )
 82            if song_type == SongType.UTAGE:
 83                song_diff = SongDifficultyUtage(
 84                    **dataclasses.asdict(song_diff),
 85                    kanji=song["basic_info"]["title"][1:2],
 86                    description="LET'S PARTY!",
 87                    is_buddy=False,
 88                )
 89            yield song_diff
 90
 91    @staticmethod
 92    def _deser_score(score: dict) -> Score:
 93        return Score(
 94            id=score["song_id"] % 10000,
 95            level=score["level"],
 96            level_index=LevelIndex(score["level_index"]),
 97            achievements=score["achievements"],
 98            fc=FCType[score["fc"].upper()] if score["fc"] else None,
 99            fs=FSType[score["fs"].upper()] if score["fs"] else None,
100            dx_score=score["dxScore"],
101            dx_rating=score["ra"],
102            play_count=None,
103            rate=RateType[score["rate"].upper()],
104            type=SongType._from_id(score["song_id"]),
105        )
106
107    @staticmethod
108    async def _ser_score(score: Score, songs: "MaimaiSongs") -> Optional[dict]:
109        song_title = song.title if (song := await songs.by_id(score.id)) else None
110        song_title = "Link(CoF)" if score.id == 383 else song_title
111        if song_title is not None:
112            return {
113                "song_id": score.type._to_id(score.id),
114                "title": song_title,
115                "level": score.level,
116                "level_index": score.level_index.value,
117                "achievements": score.achievements,
118                "fc": score.fc.name.lower() if score.fc else None,
119                "fs": score.fs.name.lower() if score.fs else None,
120                "dxScore": score.dx_score,
121                "ra": score.dx_rating,
122                "rate": score.rate.name.lower(),
123                "type": score.type._to_abbr(),
124            }
125
126    @staticmethod
127    def _deser_curve(chart: dict) -> CurveObject:
128        return CurveObject(
129            sample_size=int(chart["cnt"]),
130            fit_level_value=chart["fit_diff"],
131            avg_achievements=chart["avg"],
132            stdev_achievements=chart["std_dev"],
133            avg_dx_score=chart["avg_dx"],
134            rate_sample_size={v: chart["dist"][13 - i] for i, v in enumerate(RateType)},
135            fc_sample_size={v: chart["dist"][4 - i] for i, v in enumerate(FCType)},
136        )
137
138    def _check_response_player(self, resp: Response) -> dict:
139        try:
140            resp_json = resp.json()
141            if resp.status_code in [400, 401]:
142                raise InvalidPlayerIdentifierError(resp_json["message"])
143            elif resp.status_code == 403:
144                raise PrivacyLimitationError(resp_json["message"])
145            elif "msg" in resp_json and resp_json["msg"] in ["请先联系水鱼申请开发者token", "开发者token有误", "开发者token被禁用"]:
146                raise InvalidDeveloperTokenError(resp_json["msg"])
147            elif "message" in resp_json and resp_json["message"] in ["导入token有误", "尚未登录", "会话过期"]:
148                raise InvalidPlayerIdentifierError(resp_json["message"])
149            elif not resp.is_success:
150                resp.raise_for_status()
151            return resp_json
152        except JSONDecodeError as exc:
153            raise InvalidJsonError(resp.text) from exc
154        except HTTPStatusError as exc:
155            raise MaimaiPyError(exc) from exc
156
157    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
158    async def get_songs(self, client: "MaimaiClient") -> list[Song]:
159        resp = await client._client.get(self.base_url + "music_data")
160        resp.raise_for_status()
161        resp_json = resp.json()
162        unique_songs: dict[int, Song] = {}
163        for song in resp_json:
164            unique_key = int(song["id"]) % 10000
165            song_type: SongType = SongType._from_id(song["id"])
166            if unique_key not in unique_songs:
167                unique_songs[unique_key] = DivingFishProvider._deser_song(song)
168            difficulties: list[SongDifficulty] = unique_songs[unique_key].difficulties.__getattribute__(song_type.value)
169            difficulties.extend(DivingFishProvider._deser_diffs(song))
170        return list(unique_songs.values())
171
172    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
173    async def get_player(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> DivingFishPlayer:
174        resp = await client._client.post(self.base_url + "query/player", json=identifier._as_diving_fish())
175        resp_json = self._check_response_player(resp)
176        return DivingFishPlayer(
177            name=resp_json["username"],
178            rating=resp_json["rating"],
179            nickname=resp_json["nickname"],
180            plate=resp_json["plate"],
181            additional_rating=resp_json["additional_rating"],
182        )
183
184    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
185    async def get_scores_all(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> list[Score]:
186        resp = await client._client.get(self.base_url + "dev/player/records", params=identifier._as_diving_fish(), headers=self.headers)
187        resp_json = self._check_response_player(resp)
188        return [s for score in resp_json["records"] if (s := DivingFishProvider._deser_score(score))]
189
190    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
191    async def get_scores_best(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> list[Score]:
192        resp = await client._client.post(self.base_url + "query/player", json={"b50": True, **identifier._as_diving_fish()})
193        resp_json = self._check_response_player(resp)
194        return [DivingFishProvider._deser_score(score) for score in resp_json["charts"]["sd"] + resp_json["charts"]["dx"]]
195
196    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
197    async def get_scores_one(self, identifier: PlayerIdentifier, song: Song, client: "MaimaiClient") -> list[Score]:
198        resp = await client._client.post(
199            self.base_url + "dev/player/record",
200            json={"music_id": list(song.get_divingfish_ids()), **identifier._as_diving_fish()},
201            headers=self.headers,
202        )
203        resp_json: dict[str, dict] = self._check_response_player(resp)
204        return [s for scores in resp_json.values() for score in scores if (s := DivingFishProvider._deser_score(score))]
205
206    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
207    async def update_scores(self, identifier: PlayerIdentifier, scores: Iterable[Score], client: "MaimaiClient") -> None:
208        headers, cookies = None, None
209        maimai_songs = await client.songs()
210        if identifier.username and identifier.credentials:
211            login_json = {"username": identifier.username, "password": identifier.credentials}
212            resp1 = await client._client.post("https://www.diving-fish.com/api/maimaidxprober/login", json=login_json)
213            self._check_response_player(resp1)
214            cookies = resp1.cookies
215        elif not identifier.username and identifier.credentials and isinstance(identifier.credentials, str):
216            headers = {"Import-Token": identifier.credentials}
217        else:
218            raise InvalidPlayerIdentifierError("Either username and password or import token is required to deliver scores")
219        scores_json = [json for score in scores if (json := await DivingFishProvider._ser_score(score, maimai_songs))]
220        resp2 = await client._client.post(self.base_url + "player/update_records", cookies=cookies, headers=headers, json=scores_json)
221        self._check_response_player(resp2)
222
223    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
224    async def get_curves(self, client: "MaimaiClient") -> dict[tuple[int, SongType], list[CurveObject]]:
225        resp = await client._client.get(self.base_url + "chart_stats")
226        resp.raise_for_status()
227        return {
228            (int(idx) % 10000, SongType._from_id(int(idx))): ([DivingFishProvider._deser_curve(chart) for chart in charts if chart != {}])
229            for idx, charts in (resp.json())["charts"].items()
230        }

The provider that fetches data from the Diving Fish.

DivingFish: https://www.diving-fish.com/maimaidx/prober/

DivingFishProvider(developer_token: Optional[str] = None)
37    def __init__(self, developer_token: Optional[str] = None):
38        """Initializes the DivingFishProvider.
39
40        Args:
41            developer_token: The developer token used to access the Diving Fish API.
42        """
43        self.developer_token = developer_token

Initializes the DivingFishProvider.

Arguments:
  • developer_token: The developer token used to access the Diving Fish API.
developer_token: Optional[str]

The developer token used to access the Diving Fish API.

base_url = 'https://www.diving-fish.com/api/maimaidxprober/'

The base URL for the Diving Fish API.

 15class LocalProvider(IItemListProvider, IAreaProvider):
 16    """The provider that fetches data from the local storage.
 17
 18    Most of the data are stored in JSON files in the same directory as this file.
 19    """
 20
 21    def _hash(self) -> str:
 22        current_folder = Path(__file__).resolve().parent
 23        json_files = sorted(current_folder.glob("*.json"))
 24
 25        if not json_files:
 26            return hashlib.md5(b"local").hexdigest()
 27
 28        combined_content = b""
 29        for file_path in json_files:
 30            with open(file_path, "rb") as f:
 31                combined_content += f.read()
 32
 33        return hashlib.md5(combined_content).hexdigest()
 34
 35    def _read_file(self, file_name: str) -> Any:
 36        current_folder = Path(__file__).resolve().parent
 37        path = current_folder / f"{file_name}.json"
 38        if not path.exists():
 39            raise FileNotFoundError(f"File {path} not found.")
 40        with open(path, "r", encoding="utf-8") as f:
 41            return json.load(f)
 42
 43    def _read_file_dict(self, file_name: str) -> dict:
 44        obj = self._read_file(file_name)
 45        if isinstance(obj, dict):
 46            return obj["data"]
 47        else:
 48            raise ValueError(f"File {file_name} is not a dictionary.")
 49
 50    def _read_file_list(self, file_name: str) -> list:
 51        obj = self._read_file(file_name)
 52        if isinstance(obj, list):
 53            return obj
 54        else:
 55            raise ValueError(f"File {file_name} is not a list.")
 56
 57    async def get_icons(self, client: "MaimaiClient") -> dict[int, PlayerIcon]:
 58        return {int(k): PlayerIcon(id=int(k), name=v, description=None, genre=None) for k, v in self._read_file_dict("icons").items()}
 59
 60    async def get_nameplates(self, client: "MaimaiClient") -> dict[int, PlayerNamePlate]:
 61        return {int(k): PlayerNamePlate(id=int(k), name=v, description=None, genre=None) for k, v in self._read_file_dict("nameplates").items()}
 62
 63    async def get_frames(self, client: "MaimaiClient") -> dict[int, PlayerFrame]:
 64        return {int(k): PlayerFrame(id=int(k), name=v, description=None, genre=None) for k, v in self._read_file_dict("frames").items()}
 65
 66    async def get_partners(self, client: "MaimaiClient") -> dict[int, PlayerPartner]:
 67        return {int(k): PlayerPartner(id=int(k), name=v) for k, v in self._read_file_dict("partners").items()}
 68
 69    async def get_charas(self, client: "MaimaiClient") -> dict[int, PlayerChara]:
 70        return {int(k): PlayerChara(id=int(k), name=v) for k, v in self._read_file_dict("charas").items()}
 71
 72    async def get_trophies(self, client: "MaimaiClient") -> dict[int, PlayerTrophy]:
 73        return {int(k): PlayerTrophy(id=int(k), name=v["title"], color=v["rareType"]) for k, v in self._read_file_dict("trophies").items()}
 74
 75    async def get_areas(self, lang: str, client: "MaimaiClient") -> dict[str, Area]:
 76        maimai_songs = await client.songs()
 77        areas = {
 78            item["id"]: Area(
 79                id=item["id"],
 80                name=item["name"],
 81                comment=item["comment"],
 82                description=item["description"],
 83                video_id=item["video_id"],
 84                characters=[
 85                    AreaCharacter(
 86                        name=char["name"],
 87                        illustrator=char["illustrator"],
 88                        description1=char["description1"],
 89                        description2=char["description2"],
 90                        team=char["team"],
 91                        props=char["props"],
 92                    )
 93                    for char in item["characters"]
 94                ],
 95                songs=[
 96                    AreaSong(
 97                        id=None,
 98                        title=song["title"],
 99                        artist=song["artist"],
100                        description=song["description"],
101                        illustrator=song["illustrator"],
102                        movie=song["movie"],
103                    )
104                    for song in item["songs"]
105                ],
106            )
107            for item in self._read_file_list(f"areas_{lang}")
108        }
109        for area in areas.values():
110            for song in area.songs:
111                maimai_song = await maimai_songs.by_title(song.title)
112                song.id = maimai_song.id if maimai_song else None
113        return areas

The provider that fetches data from the local storage.

Most of the data are stored in JSON files in the same directory as this file.

 22class LXNSProvider(ISongProvider, IPlayerProvider, IScoreProvider, IScoreUpdateProvider, IAliasProvider, IItemListProvider):
 23    """The provider that fetches data from the LXNS.
 24
 25    LXNS: https://maimai.lxns.net/
 26    """
 27
 28    developer_token: Optional[str]
 29    """The developer token used to access the LXNS API."""
 30    base_url = "https://maimai.lxns.net/"
 31    """The base URL for the LXNS API."""
 32
 33    @property
 34    def headers(self):
 35        """@private"""
 36        if not self.developer_token:
 37            raise InvalidDeveloperTokenError("Developer token is not provided.")
 38        return {"Authorization": self.developer_token}
 39
 40    def __init__(self, developer_token: Optional[str] = None):
 41        """Initializes the LXNSProvider.
 42
 43        Args:
 44            developer_token: The developer token used to access the LXNS API.
 45        """
 46        self.developer_token = developer_token
 47
 48    def _hash(self) -> str:
 49        return hashlib.md5(b"lxns").hexdigest()
 50
 51    async def _ensure_friend_code(self, client: "MaimaiClient", identifier: PlayerIdentifier) -> None:
 52        if identifier.friend_code is None:
 53            if identifier.qq is not None:
 54                resp = await client._client.get(self.base_url + f"api/v0/maimai/player/qq/{identifier.qq}", headers=self.headers)
 55                if not resp.json()["success"]:
 56                    raise InvalidPlayerIdentifierError(resp.json()["message"])
 57                identifier.friend_code = resp.json()["data"]["friend_code"]
 58
 59    async def _build_player_request(self, path: str, identifier: PlayerIdentifier, client: "MaimaiClient") -> tuple[str, dict[str, str], bool]:
 60        use_user_api = identifier.credentials is not None and isinstance(identifier.credentials, str)
 61        if use_user_api:
 62            # user-level API takes the precedence: If personal token provided, use it first
 63            assert isinstance(identifier.credentials, str)
 64            entrypoint = f"api/v0/user/maimai/player/{path}"
 65            headers = {"X-User-Token": identifier.credentials}
 66        else:
 67            await self._ensure_friend_code(client, identifier)
 68            entrypoint = f"api/v0/maimai/player/{identifier.friend_code}/{path}"
 69            headers = self.headers
 70        entrypoint = entrypoint.removesuffix("/")
 71        return self.base_url + entrypoint, headers, use_user_api
 72
 73    @staticmethod
 74    def _deser_note(diff: dict, key: str) -> int:
 75        if "notes" in diff:
 76            if "is_buddy" in diff and diff["is_buddy"]:
 77                return diff["notes"]["left"][key] + diff["notes"]["right"][key]
 78            return diff["notes"][key]
 79        return 0
 80
 81    @staticmethod
 82    def _deser_item(item: dict, cls: type) -> Any:
 83        return cls(
 84            id=item["id"],
 85            name=item["name"],
 86            description=item["description"] if "description" in item else None,
 87            genre=item["genre"] if "genre" in item else None,
 88        )
 89
 90    @staticmethod
 91    def _deser_song(song: dict) -> Song:
 92        return Song(
 93            id=song["id"],
 94            title=song["title"],
 95            artist=song["artist"],
 96            genre=name_to_genre[song["genre"]],
 97            bpm=song["bpm"],
 98            aliases=song["aliases"] if "aliases" in song else None,
 99            map=song["map"] if "map" in song else None,
100            version=song["version"],
101            rights=song["rights"] if "rights" in song else None,
102            disabled=song["disabled"] if "disabled" in song else False,
103            difficulties=SongDifficulties(standard=[], dx=[], utage=[]),
104        )
105
106    @staticmethod
107    def _deser_diff(difficulty: dict) -> SongDifficulty:
108        return SongDifficulty(
109            type=SongType[difficulty["type"].upper()],
110            level=difficulty["level"],
111            level_value=difficulty["level_value"],
112            level_index=LevelIndex(difficulty["difficulty"]),
113            note_designer=difficulty["note_designer"],
114            version=difficulty["version"],
115            tap_num=LXNSProvider._deser_note(difficulty, "tap"),
116            hold_num=LXNSProvider._deser_note(difficulty, "hold"),
117            slide_num=LXNSProvider._deser_note(difficulty, "slide"),
118            touch_num=LXNSProvider._deser_note(difficulty, "touch"),
119            break_num=LXNSProvider._deser_note(difficulty, "break"),
120            curve=None,
121        )
122
123    @staticmethod
124    def _deser_diff_utage(difficulty: dict) -> SongDifficultyUtage:
125        return SongDifficultyUtage(
126            **dataclasses.asdict(LXNSProvider._deser_diff(difficulty)),
127            kanji=difficulty["kanji"],
128            description=difficulty["description"],
129            is_buddy=difficulty["is_buddy"],
130        )
131
132    @staticmethod
133    def _deser_score(score: dict) -> Score:
134        return Score(
135            id=score["id"],
136            level=score["level"],
137            level_index=LevelIndex(score["level_index"]),
138            achievements=score["achievements"] if "achievements" in score else None,
139            fc=FCType[score["fc"].upper()] if score["fc"] else None,
140            fs=FSType[score["fs"].upper()] if score["fs"] else None,
141            dx_score=score["dx_score"] if "dx_score" in score else None,
142            dx_rating=int(score["dx_rating"]) if "dx_rating" in score else None,
143            play_count=None,
144            rate=RateType[score["rate"].upper()],
145            type=SongType[score["type"].upper()],
146        )
147
148    @staticmethod
149    async def _ser_score(score: Score, songs: "MaimaiSongs") -> Optional[dict]:
150        song_title = song.title if (song := await songs.by_id(score.id)) else None
151        if song_title is not None:
152            return {
153                "id": score.id,
154                "song_name": song_title,
155                "level": score.level,
156                "level_index": score.level_index.value,
157                "achievements": score.achievements,
158                "fc": score.fc.name.lower() if score.fc else None,
159                "fs": score.fs.name.lower() if score.fs else None,
160                "dx_score": score.dx_score,
161                "dx_rating": score.dx_rating,
162                "rate": score.rate.name.lower(),
163                "type": score.type.name.lower(),
164            }
165
166    def _check_response_player(self, resp: Response) -> dict:
167        try:
168            resp_json = resp.json()
169            if not resp_json["success"]:
170                if resp_json["code"] in [400, 404]:
171                    raise InvalidPlayerIdentifierError(resp_json["message"])
172                elif resp_json["code"] in [403]:
173                    raise PrivacyLimitationError(resp_json["message"])
174                elif resp_json["code"] in [401]:
175                    raise InvalidDeveloperTokenError(resp_json["message"])
176                elif resp.status_code in [400, 401]:
177                    raise InvalidPlayerIdentifierError(resp_json["message"])
178                elif not resp.is_success:
179                    resp.raise_for_status()
180            return resp_json
181        except JSONDecodeError as exc:
182            raise InvalidJsonError(resp.text) from exc
183        except HTTPStatusError as exc:
184            raise MaimaiPyError(exc) from exc
185
186    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
187    async def get_songs(self, client: "MaimaiClient") -> list[Song]:
188        resp = await client._client.get(self.base_url + "api/v0/maimai/song/list?notes=true")
189        resp.raise_for_status()
190        resp_json = resp.json()
191        unique_songs: dict[int, Song] = {}
192        for song in resp_json["songs"]:
193            unique_key = int(song["id"]) % 10000
194            if unique_key not in unique_songs:
195                unique_songs[unique_key] = LXNSProvider._deser_song(song)
196            difficulties = unique_songs[unique_key].difficulties
197            difficulties.standard.extend(LXNSProvider._deser_diff(difficulty) for difficulty in song["difficulties"].get("standard", []))
198            difficulties.dx.extend(LXNSProvider._deser_diff(difficulty) for difficulty in song["difficulties"].get("dx", []))
199            difficulties.utage.extend(LXNSProvider._deser_diff_utage(difficulty) for difficulty in song["difficulties"].get("utage", []))
200        return list(unique_songs.values())
201
202    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
203    async def get_player(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> LXNSPlayer:
204        maimai_frames = await client.items(PlayerFrame)
205        maimai_icons = await client.items(PlayerIcon)
206        maimai_trophies = await client.items(PlayerTrophy)
207        maimai_nameplates = await client.items(PlayerNamePlate)
208        url, headers, _ = await self._build_player_request("", identifier, client)
209        resp = await client._client.get(url, headers=headers)
210        resp_data = self._check_response_player(resp)["data"]
211        return LXNSPlayer(
212            name=resp_data["name"],
213            rating=resp_data["rating"],
214            friend_code=resp_data["friend_code"],
215            course_rank=resp_data["course_rank"],
216            class_rank=resp_data["class_rank"],
217            star=resp_data["star"],
218            frame=await maimai_frames.by_id(resp_data["frame"]["id"]) if "frame" in resp_data else None,
219            icon=await maimai_icons.by_id(resp_data["icon"]["id"]) if "icon" in resp_data else None,
220            trophy=await maimai_trophies.by_id(resp_data["trophy"]["id"]) if "trophy" in resp_data else None,
221            name_plate=await maimai_nameplates.by_id(resp_data["name_plate"]["id"]) if "name_plate" in resp_data else None,
222            upload_time=resp_data["upload_time"],
223        )
224
225    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
226    async def get_scores_all(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> list[Score]:
227        url, headers, use_user_api = await self._build_player_request("scores", identifier, client)
228        resp = await client._client.get(url, headers=headers)
229        resp_data = self._check_response_player(resp)["data"]
230        scores = [s for score in resp_data if (s := LXNSProvider._deser_score(score))]
231        if not use_user_api:
232            # LXNSProvider's developer-level API scores are incomplete, which doesn't contain dx_rating and achievements, leading to sorting difficulties.
233            # In this case, we should always fetch the b35 and b15 scores for LXNSProvider.
234            scores.extend(await self.get_scores_best(identifier, client))
235        return scores
236
237    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
238    async def get_scores_best(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> list[Score]:
239        if identifier.friend_code is None:
240            return await self.get_scores_all(identifier, client)
241        await self._ensure_friend_code(client, identifier)
242        entrypoint = f"api/v0/maimai/player/{identifier.friend_code}/bests"
243        resp = await client._client.get(self.base_url + entrypoint, headers=self.headers)
244        resp_data = self._check_response_player(resp)["data"]
245        return [s for score in resp_data["standard"] + resp_data["dx"] if (s := LXNSProvider._deser_score(score))]
246
247    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
248    async def get_scores_one(self, identifier: PlayerIdentifier, song: Song, client: "MaimaiClient") -> list[Score]:
249        await self._ensure_friend_code(client, identifier)
250        request_tasks, create_task = [], lambda type: asyncio.create_task(
251            client._client.get(
252                self.base_url + f"api/v0/maimai/player/{identifier.friend_code}/bests",
253                params={"song_id": song.id if type != SongType.UTAGE else song.id + 100000, "song_type": type.value},
254                headers=self.headers,
255            )
256        )
257        if len(song.difficulties.standard) > 0:
258            request_tasks.append(create_task(SongType.STANDARD))
259        if len(song.difficulties.dx) > 0:
260            request_tasks.append(create_task(SongType.DX))
261        if len(song.difficulties.utage) > 0:
262            request_tasks.append(create_task(SongType.UTAGE))
263        resps = await asyncio.gather(*request_tasks)
264        resp_data = [self._check_response_player(resp)["data"] for resp in resps]
265        return [s for score in reduce(concat, resp_data) if (s := LXNSProvider._deser_score(score))]
266
267    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
268    async def update_scores(self, identifier: PlayerIdentifier, scores: Iterable[Score], client: "MaimaiClient") -> None:
269        maimai_songs = await client.songs()
270        url, headers, _ = await self._build_player_request("scores", identifier, client)
271        scores_dict = {"scores": [json for score in scores if (json := await LXNSProvider._ser_score(score, maimai_songs))]}
272        resp = await client._client.post(url, headers=headers, json=scores_dict)
273        resp.raise_for_status()
274        resp_json = resp.json()
275        if not resp_json["success"] and resp_json["code"] == 400:
276            raise ValueError(resp_json["message"])
277
278    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
279    async def get_aliases(self, client: "MaimaiClient") -> dict[int, list[str]]:
280        resp = await client._client.get(self.base_url + "api/v0/maimai/alias/list")
281        resp.raise_for_status()
282        return {item["song_id"]: item["aliases"] for item in resp.json()["aliases"]}
283
284    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
285    async def get_icons(self, client: "MaimaiClient") -> dict[int, PlayerIcon]:
286        resp = await client._client.get(self.base_url + "api/v0/maimai/icon/list")
287        resp.raise_for_status()
288        return {item["id"]: LXNSProvider._deser_item(item, PlayerIcon) for item in resp.json()["icons"]}
289
290    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
291    async def get_nameplates(self, client: "MaimaiClient") -> dict[int, PlayerNamePlate]:
292        resp = await client._client.get(self.base_url + "api/v0/maimai/plate/list")
293        resp.raise_for_status()
294        return {item["id"]: LXNSProvider._deser_item(item, PlayerNamePlate) for item in resp.json()["plates"]}
295
296    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
297    async def get_frames(self, client: "MaimaiClient") -> dict[int, PlayerFrame]:
298        resp = await client._client.get(self.base_url + "api/v0/maimai/frame/list")
299        resp.raise_for_status()
300        return {item["id"]: LXNSProvider._deser_item(item, PlayerFrame) for item in resp.json()["frames"]}

The provider that fetches data from the LXNS.

LXNS: https://maimai.lxns.net/

LXNSProvider(developer_token: Optional[str] = None)
40    def __init__(self, developer_token: Optional[str] = None):
41        """Initializes the LXNSProvider.
42
43        Args:
44            developer_token: The developer token used to access the LXNS API.
45        """
46        self.developer_token = developer_token

Initializes the LXNSProvider.

Arguments:
  • developer_token: The developer token used to access the LXNS API.
developer_token: Optional[str]

The developer token used to access the LXNS API.

base_url = 'https://maimai.lxns.net/'

The base URL for the LXNS API.

22class WechatProvider(IScoreProvider, IPlayerIdentifierProvider):
23    """The provider that fetches data from the Wahlap Wechat OffiAccount.
24
25    PlayerIdentifier must have the `credentials` attribute, we suggest you to use the `maimai.wechat()` method to get the identifier.
26
27    PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time.
28
29    Wahlap Wechat OffiAccount: https://maimai.wahlap.com/maimai-mobile/
30    """
31
32    def _hash(self) -> str:
33        return hashlib.md5(b"wechat").hexdigest()
34
35    @staticmethod
36    async def _deser_score(score: HTMLScore, songs: "MaimaiSongs") -> Optional[Score]:
37        if song := await songs.by_title(score.title):
38            is_utage = (len(song.difficulties.dx) + len(song.difficulties.standard)) == 0
39            song_type = SongType.STANDARD if score.type == "SD" else SongType.DX if score.type == "DX" and not is_utage else SongType.UTAGE
40            level_index = LevelIndex(score.level_index)
41            if diff := song.get_difficulty(song_type, level_index):
42                rating = ScoreCoefficient(score.achievements).ra(diff.level_value)
43                return Score(
44                    id=song.id,
45                    level=score.level,
46                    level_index=level_index,
47                    achievements=score.achievements,
48                    fc=FCType[score.fc.upper()] if score.fc else None,
49                    fs=FSType[score.fs.upper().replace("FDX", "FSD")] if score.fs else None,
50                    dx_score=score.dx_score,
51                    dx_rating=rating,
52                    play_count=None,
53                    rate=RateType[score.rate.upper()],
54                    type=song_type,
55                )
56
57    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
58    async def _crawl_scores_diff(self, client: "MaimaiClient", diff: int, cookies: Cookies, maimai_songs: "MaimaiSongs") -> list[Score]:
59        await asyncio.sleep(random.randint(0, 300) / 1000)  # sleep for a random amount of time between 0 and 300ms
60        resp1 = await client._client.get(f"https://maimai.wahlap.com/maimai-mobile/record/musicGenre/search/?genre=99&diff={diff}", cookies=cookies)
61        scores: list[HTMLScore] = wmdx_html2json(str(resp1.text))
62        return [r for score in scores if (r := await WechatProvider._deser_score(score, maimai_songs))]
63
64    async def _crawl_scores(self, client: "MaimaiClient", cookies: Cookies, maimai_songs: "MaimaiSongs") -> Sequence[Score]:
65        tasks = [asyncio.create_task(self._crawl_scores_diff(client, diff, cookies, maimai_songs)) for diff in [0, 1, 2, 3, 4]]
66        results = await asyncio.gather(*tasks)
67        return functools.reduce(operator.concat, results, [])
68
69    async def get_scores_all(self, identifier: PlayerIdentifier, client: "MaimaiClient") -> list[Score]:
70        maimai_songs = await client.songs()  # Ensure songs are loaded in cache
71        if not identifier.credentials or not isinstance(identifier.credentials, Cookies):
72            raise InvalidPlayerIdentifierError("Wahlap wechat cookies are required to fetch scores")
73        scores = await self._crawl_scores(client, identifier.credentials, maimai_songs)
74        return list(scores)
75
76    async def get_identifier(self, code: Union[str, dict[str, str]], client: "MaimaiClient") -> PlayerIdentifier:
77        if isinstance(code, dict) and all([code.get("r"), code.get("t"), code.get("code"), code.get("state")]):
78            headers = {
79                "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36 NetType/WIFI MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x6307001e)",
80                "Host": "tgk-wcaime.wahlap.com",
81                "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
82            }
83            resp = await client._client.get("https://tgk-wcaime.wahlap.com/wc_auth/oauth/callback/maimai-dx", params=code, headers=headers)
84            if resp.status_code == 302 and resp.next_request:
85                resp_next = await client._client.get(resp.next_request.url, headers=headers)
86                return PlayerIdentifier(credentials=resp_next.cookies)
87            else:
88                raise InvalidWechatTokenError("Invalid or expired Wechat token")
89        raise InvalidWechatTokenError("Invalid Wechat token format, expected a dict with 'r', 't', 'code', and 'state' keys")

The provider that fetches data from the Wahlap Wechat OffiAccount.

PlayerIdentifier must have the credentials attribute, we suggest you to use the maimai.wechat() method to get the identifier.

PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time.

Wahlap Wechat OffiAccount: https://maimai.wahlap.com/maimai-mobile/

class YuzuProvider(maimai_py.providers.IAliasProvider):
18class YuzuProvider(IAliasProvider):
19    """The provider that fetches song aliases from the Yuzu.
20
21    Yuzu is a bot API that provides song aliases for maimai DX.
22
23    Yuzu: https://bot.yuzuchan.moe/
24    """
25
26    base_url = "https://www.yuzuchan.moe/api/"
27    """The base URL for the Yuzu API."""
28
29    def _hash(self) -> str:
30        return hashlib.md5(b"yuzu").hexdigest()
31
32    def _check_response(self, resp: Response) -> dict:
33        try:
34            resp_json = resp.json()
35            if not resp.is_success:
36                resp.raise_for_status()
37            return resp_json
38        except JSONDecodeError as exc:
39            raise InvalidJsonError(resp.text) from exc
40        except HTTPStatusError as exc:
41            raise MaimaiPyError(exc) from exc
42
43    @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type(RequestError), reraise=True)
44    async def get_aliases(self, client: "MaimaiClient") -> dict[int, list[str]]:
45        resp = await client._client.get(self.base_url + "maimaidx/maimaidxalias")
46        resp_json = self._check_response(resp)
47        grouped = defaultdict(list)
48        for item in resp_json["content"]:
49            grouped[item["SongID"] % 10000].extend(item["Alias"])
50        return grouped

The provider that fetches song aliases from the Yuzu.

Yuzu is a bot API that provides song aliases for maimai DX.

Yuzu: https://bot.yuzuchan.moe/

base_url = 'https://www.yuzuchan.moe/api/'

The base URL for the Yuzu API.