maimai_py.maimai

   1import asyncio
   2import dataclasses
   3import hashlib
   4import warnings
   5from collections import defaultdict
   6from functools import cached_property
   7from typing import Any, AsyncGenerator, Callable, Generic, Iterable, Literal, Optional, Type, TypeVar
   8
   9from aiocache import BaseCache, SimpleMemoryCache
  10from httpx import AsyncClient
  11
  12from maimai_py.models import *
  13from maimai_py.providers import *
  14from maimai_py.utils import UNSET, _UnsetSentinel
  15
  16PlayerItemType = TypeVar("PlayerItemType", bound=PlayerItem)
  17
  18T = TypeVar("T", bound=Score)
  19
  20
  21class MaimaiItems(Generic[PlayerItemType]):
  22    _client: "MaimaiClient"
  23    _namespace: str
  24
  25    def __init__(self, client: "MaimaiClient", namespace: str) -> None:
  26        """@private"""
  27        self._client = client
  28        self._namespace = namespace
  29
  30    async def _configure(self, provider: Union[IItemListProvider, _UnsetSentinel] = UNSET) -> "MaimaiItems":
  31        cache_obj, cache_ttl = self._client._cache, self._client._cache_ttl
  32        # Check if the provider is unset, which means we want to access the cache directly.
  33        if isinstance(provider, _UnsetSentinel):
  34            if await cache_obj.get("provider", None, namespace=self._namespace) is not None:
  35                return self
  36        # Really assign the unset provider to the default one.
  37        provider = LXNSProvider() if PlayerItemType in [PlayerIcon, PlayerNamePlate, PlayerFrame] else LocalProvider()
  38        # Check if the current provider hash is different from the previous one, which means we need to reconfigure.
  39        current_provider_hash = provider._hash()
  40        previous_provider_hash = await cache_obj.get("provider", "", namespace=self._namespace)
  41        # If different or previous is empty, we need to reconfigure the items.
  42        if current_provider_hash != previous_provider_hash:
  43            val: dict[int, Any] = await getattr(provider, f"get_{self._namespace}")(self._client)
  44            await asyncio.gather(
  45                cache_obj.set("provider", current_provider_hash, ttl=cache_ttl, namespace=self._namespace),  # provider
  46                cache_obj.set("ids", [key for key in val.keys()], namespace=self._namespace),  # ids
  47                cache_obj.multi_set(val.items(), namespace=self._namespace),  # items
  48            )
  49        return self
  50
  51    async def get_all(self) -> list[PlayerItemType]:
  52        """All items as list.
  53
  54        This method will iterate all items in the cache, and yield each item one by one. Unless you really need to iterate all items, you should use `by_id` or `filter` instead.
  55
  56        Returns:
  57            A list with all items in the cache, return an empty list if no item is found.
  58        """
  59        item_ids: Optional[list[int]] = await self._client._cache.get("ids", namespace=self._namespace)
  60        assert item_ids is not None, f"Items not found in cache {self._namespace}, please call configure() first."
  61        return await self._client._multi_get(item_ids, namespace=self._namespace)
  62
  63    async def get_batch(self, ids: Iterable[int]) -> list[PlayerItemType]:
  64        """Get items by their IDs.
  65
  66        Args:
  67            ids: the IDs of the items.
  68        Returns:
  69            A list of items if they exist, otherwise return an empty list.
  70        """
  71        return await self._client._multi_get(ids, namespace=self._namespace)
  72
  73    async def by_id(self, id: int) -> Optional[PlayerItemType]:
  74        """Get an item by its ID.
  75
  76        Args:
  77            id: the ID of the item.
  78        Returns:
  79            the item if it exists, otherwise return None.
  80        """
  81        return await self._client._cache.get(id, namespace=self._namespace)
  82
  83    async def filter(self, **kwargs) -> list[PlayerItemType]:
  84        """Filter items by their attributes.
  85
  86        Ensure that the attribute is of the item, and the value is of the same type. All conditions are connected by AND.
  87
  88        Args:
  89            kwargs: the attributes to filter the items by.
  90        Returns:
  91            an async generator yielding items that match all the conditions, yields no items if no item is found.
  92        """
  93        cond = lambda item: all(getattr(item, key) == value for key, value in kwargs.items() if value is not None)
  94        return [item for item in await self.get_all() if cond(item)]
  95
  96
  97class MaimaiSongs:
  98    _client: "MaimaiClient"
  99
 100    def __init__(self, client: "MaimaiClient") -> None:
 101        """@private"""
 102        self._client = client
 103
 104    async def _configure(
 105        self,
 106        provider: Union[ISongProvider, _UnsetSentinel],
 107        alias_provider: Union[IAliasProvider, None, _UnsetSentinel],
 108        curve_provider: Union[ICurveProvider, None, _UnsetSentinel],
 109    ) -> "MaimaiSongs":
 110        cache_obj, cache_ttl = self._client._cache, self._client._cache_ttl
 111        # Check if all providers are unset, which means we want to access the cache directly.
 112        if isinstance(provider, _UnsetSentinel) and isinstance(alias_provider, _UnsetSentinel) and isinstance(curve_provider, _UnsetSentinel):
 113            if await cache_obj.get("provider", None, namespace="songs") is not None:
 114                return self
 115        # Really assign the unset providers to the default ones.
 116        provider = provider if not isinstance(provider, _UnsetSentinel) else LXNSProvider()
 117        alias_provider = alias_provider if not isinstance(alias_provider, _UnsetSentinel) else YuzuProvider()
 118        curve_provider = curve_provider if not isinstance(curve_provider, _UnsetSentinel) else None  # Don't fetch curves if not provided.
 119        # Check if the current provider hash is different from the previous one, which means we need to reconfigure.
 120        current_provider_hash = hashlib.md5(
 121            (provider._hash() + (alias_provider._hash() if alias_provider else "") + (curve_provider._hash() if curve_provider else "")).encode()
 122        ).hexdigest()
 123        previous_provider_hash = await cache_obj.get("provider", "", namespace="songs")
 124        # If different or previous is empty, we need to reconfigure the songs.
 125        if current_provider_hash != previous_provider_hash:
 126            # Get the resources from the providers in parallel.
 127            songs, song_aliases, song_curves = await asyncio.gather(
 128                provider.get_songs(self._client),
 129                alias_provider.get_aliases(self._client) if alias_provider else asyncio.sleep(0, result={}),
 130                curve_provider.get_curves(self._client) if curve_provider else asyncio.sleep(0, result={}),
 131            )
 132
 133            # Build the song objects and set their aliases and curves if provided.
 134            for song in songs:
 135                if alias_provider is not None and (aliases := song_aliases.get(song.id, None)):
 136                    song.aliases = aliases
 137                if curve_provider is not None:
 138                    if curves := song_curves.get((song.id, SongType.DX), None):
 139                        diffs = song.get_difficulties(SongType.DX)
 140                        [diff.__setattr__("curve", curves[i]) for i, diff in enumerate(diffs) if i < len(curves)]
 141                    if curves := song_curves.get((song.id, SongType.STANDARD), None):
 142                        diffs = song.get_difficulties(SongType.STANDARD)
 143                        [diff.__setattr__("curve", curves[i]) for i, diff in enumerate(diffs) if i < len(curves)]
 144                    if curves := song_curves.get((song.id, SongType.UTAGE), None):
 145                        diffs = song.get_difficulties(SongType.UTAGE)
 146                        [diff.__setattr__("curve", curves[i]) for i, diff in enumerate(diffs) if i < len(curves)]
 147
 148            # Set the cache with the songs, aliases, and versions.
 149            await asyncio.gather(
 150                cache_obj.set("provider", current_provider_hash, ttl=cache_ttl, namespace="songs"),  # provider
 151                cache_obj.set("ids", [song.id for song in songs], namespace="songs"),  # ids
 152                cache_obj.multi_set(iter((song.id, song) for song in songs), namespace="songs"),  # songs
 153                cache_obj.multi_set(iter((song.title, song.id) for song in songs), namespace="tracks"),  # titles
 154                cache_obj.multi_set(iter((li, id) for id, ul in song_aliases.items() for li in ul), namespace="aliases"),  # aliases
 155                cache_obj.set(
 156                    "versions",
 157                    {f"{song.id} {diff.type} {diff.level_index}": diff.version for song in songs for diff in song.get_difficulties()},
 158                    namespace="songs",
 159                ),  # versions
 160            )
 161        return self
 162
 163    async def get_all(self) -> list[Song]:
 164        """All songs as list.
 165
 166        This method will iterate all songs in the cache, and yield each song one by one. Unless you really need to iterate all songs, you should use `by_id` or `filter` instead.
 167
 168        Returns:
 169            A list of all songs in the cache, return an empty list if no song is found.
 170        """
 171        song_ids: Optional[list[int]] = await self._client._cache.get("ids", namespace="songs")
 172        assert song_ids is not None, "Songs not found in cache, please call configure() first."
 173        return await self._client._multi_get(song_ids, namespace="songs")
 174
 175    async def get_batch(self, ids: Iterable[int]) -> list[Song]:
 176        """Get songs by their IDs.
 177
 178        Args:
 179            ids: the IDs of the songs.
 180        Returns:
 181            A list of songs if they exist, otherwise return an empty list.
 182        """
 183        return await self._client._multi_get(ids, namespace="songs")
 184
 185    async def by_id(self, id: int) -> Optional[Song]:
 186        """Get a song by its ID.
 187
 188        Args:
 189            id: the ID of the song, always smaller than `10000`, should (`% 10000`) if necessary.
 190        Returns:
 191            the song if it exists, otherwise return None.
 192        """
 193        return await self._client._cache.get(id, namespace="songs")
 194
 195    async def by_title(self, title: str) -> Optional[Song]:
 196        """Get a song by its title.
 197
 198        Args:
 199            title: the title of the song.
 200        Returns:
 201            the song if it exists, otherwise return None.
 202        """
 203        song_id = await self._client._cache.get(title, namespace="tracks")
 204        song_id = 383 if title == "Link(CoF)" else song_id
 205        return await self._client._cache.get(song_id, namespace="songs") if song_id else None
 206
 207    async def by_alias(self, alias: str) -> Optional[Song]:
 208        """Get song by one possible alias.
 209
 210        Args:
 211            alias: one possible alias of the song.
 212        Returns:
 213            the song if it exists, otherwise return None.
 214        """
 215        if song_id := await self._client._cache.get(alias, namespace="aliases"):
 216            if song := await self._client._cache.get(song_id, namespace="songs"):
 217                return song
 218
 219    async def by_artist(self, artist: str) -> list[Song]:
 220        """Get songs by their artist, case-sensitive.
 221
 222        Args:
 223            artist: the artist of the songs.
 224        Returns:
 225            an async generator yielding songs that match the artist.
 226        """
 227        return [song for song in await self.get_all() if song.artist == artist]
 228
 229    async def by_genre(self, genre: Genre) -> list[Song]:
 230        """Get songs by their genre, case-sensitive.
 231
 232        Args:
 233            genre: the genre of the songs.
 234        Returns:
 235            an async generator yielding songs that match the genre.
 236        """
 237        return [song for song in await self.get_all() if song.genre == genre]
 238
 239    async def by_bpm(self, minimum: int, maximum: int) -> list[Song]:
 240        """Get songs by their BPM.
 241
 242        Args:
 243            minimum: the minimum (inclusive) BPM of the songs.
 244            maximum: the maximum (inclusive) BPM of the songs.
 245        Returns:
 246            an async generator yielding songs that match the BPM.
 247        """
 248        return [song for song in await self.get_all() if minimum <= song.bpm <= maximum]
 249
 250    async def by_versions(self, versions: Version) -> list[Song]:
 251        """Get songs by their versions, versions are fuzzy matched version of major maimai version.
 252
 253        Args:
 254            versions: the versions of the songs.
 255        Returns:
 256            an async generator yielding songs that match the versions.
 257        """
 258        cond = lambda song: versions.value <= song.version < all_versions[all_versions.index(versions) + 1].value
 259        return [song for song in await self.get_all() if cond(song)]
 260
 261    async def by_keywords(self, keywords: str) -> list[Song]:
 262        """Get songs by their keywords, keywords are matched with song title, artist and aliases.
 263
 264        Args:
 265            keywords: the keywords to match the songs.
 266        Returns:
 267            a list of songs that match the keywords, case-insensitive.
 268        """
 269        exact_matches = []
 270        fuzzy_matches = []
 271
 272        # Process all songs in a single pass
 273        for song in await self.get_all():
 274            # Check for exact matches
 275            if (
 276                keywords.lower() == song.title.lower()
 277                or keywords.lower() == song.artist.lower()
 278                or any(keywords.lower() == alias.lower() for alias in (song.aliases or []))
 279            ):
 280                exact_matches.append(song)
 281            # Check for fuzzy matches
 282            elif keywords.lower() in f"{song.title} + {song.artist} + {''.join(a for a in (song.aliases or []))}".lower():
 283                fuzzy_matches.append(song)
 284
 285        # Return exact matches if found, otherwise return fuzzy matches
 286        return exact_matches + fuzzy_matches if exact_matches else fuzzy_matches
 287
 288    async def filter(self, **kwargs) -> list[Song]:
 289        """Filter songs by their attributes.
 290
 291        Ensure that the attribute is of the song, and the value is of the same type. All conditions are connected by AND.
 292
 293        Args:
 294            kwargs: the attributes to filter the songs by.
 295        Returns:
 296            a list of songs that match all the conditions.
 297        """
 298        cond = lambda song: all(getattr(song, key) == value for key, value in kwargs.items() if value is not None)
 299        return [song for song in await self.get_all() if cond(song)]
 300
 301
 302class MaimaiPlates:
 303    _client: "MaimaiClient"
 304
 305    _kind: str  # The kind of the plate, e.g. "将", "神".
 306    _version: str  # The version of the plate, e.g. "真", "舞".
 307    _versions: set[Version] = set()  # The matched versions set of the plate.
 308    _matched_songs: list[Song] = []
 309    _matched_scores: list[ScoreExtend] = []
 310
 311    def __init__(self, client: "MaimaiClient") -> None:
 312        """@private"""
 313        self._client = client
 314
 315    async def _configure(self, plate: str, scores: list[Score]) -> "MaimaiPlates":
 316        maimai_songs = await self._client.songs()
 317        self._version = plate_aliases.get(plate[0], plate[0])
 318        self._kind = plate_aliases.get(plate[1:], plate[1:])
 319
 320        versions = list()  # in case of invalid plate, we will raise an error
 321        if self._version == "真":
 322            versions = [plate_to_version["初"], plate_to_version["真"]]
 323        if self._version in ["霸", "舞"]:
 324            versions = [ver for ver in plate_to_version.values() if ver.value < 20000]
 325        if plate_to_version.get(self._version):
 326            versions = [plate_to_version[self._version]]
 327        if not versions or self._kind not in ["将", "者", "极", "舞舞", "神"]:
 328            raise InvalidPlateError(f"Invalid plate: {self._version}{self._kind}")
 329        versions.append([ver for ver in plate_to_version.values() if ver.value > versions[-1].value][0])
 330        self._versions = set(versions)
 331
 332        song_diff_versions: dict[str, int] = await self._client._cache.get("versions", namespace="songs") or {}
 333        versioned_matched_songs = set()
 334        for k, v in song_diff_versions.items():
 335            if any(v >= o.value and v < versions[i + 1].value for i, o in enumerate(versions[:-1])):
 336                versioned_matched_songs.add(int(k.split(" ")[0]))
 337        self._matched_songs = await self._client._multi_get(list(versioned_matched_songs), namespace="songs")
 338
 339        versioned_joined_scores: dict[str, Score] = {}
 340        for score in scores:
 341            score_key = f"{score.id} {score.type} {score.level_index}"
 342            if score_version := song_diff_versions.get(score_key, None):
 343                if any(score_version >= o.value and score_version < versions[i + 1].value for i, o in enumerate(versions[:-1])):
 344                    if not (score.level_index == LevelIndex.ReMASTER and self.no_remaster):
 345                        versioned_joined_scores[score_key] = score._join(versioned_joined_scores.get(score_key, None))
 346        self._matched_scores = await MaimaiScores._get_extended(versioned_joined_scores.values(), maimai_songs)
 347
 348        return self
 349
 350    @cached_property
 351    def _major_type(self) -> SongType:
 352        return SongType.DX if any(ver.value > 20000 for ver in self._versions) else SongType.STANDARD
 353
 354    @cached_property
 355    def no_remaster(self) -> bool:
 356        """Whether it is required to play ReMASTER levels in the plate.
 357
 358        Only 舞 and 霸 plates require ReMASTER levels, others don't.
 359        """
 360        return self._version not in ["舞", "霸"]
 361
 362    def _get_levels(self, song: Song) -> set[LevelIndex]:
 363        levels = set(diff.level_index for diff in song.get_difficulties(self._major_type))
 364        if self.no_remaster and LevelIndex.ReMASTER in levels:
 365            levels.remove(LevelIndex.ReMASTER)
 366        return levels
 367
 368    async def get_remained(self) -> list[PlateObject]:
 369        """Get the remained songs and scores of the player on this plate.
 370
 371        If player has ramained levels on one song, the song and ramained `level_index` will be included in the result, otherwise it won't.
 372
 373        The distinct scores which NOT met the plate requirement will be included in the result, the finished scores won't.
 374
 375        Returns:
 376            A list of `PlateObject` containing the song and the scores.
 377        """
 378        # Group scores by song ID to pre-fill the PlateObject.
 379        grouped = defaultdict(list)
 380        [grouped[score.id].append(score) for score in self._matched_scores]
 381        # Create PlateObject for each song with its levels and scores.
 382        results = {song.id: PlateObject(song=song, levels=self._get_levels(song), scores=grouped.get(song.id, [])) for song in self._matched_songs}
 383
 384        def extract(score: ScoreExtend) -> None:
 385            results[score.id].scores.remove(score)
 386            if score.level_index in results[score.id].levels:
 387                results[score.id].levels.remove(score.level_index)
 388
 389        if self._kind == "者":
 390            [extract(score) for score in self._matched_scores if score.rate.value <= RateType.A.value]
 391        elif self._kind == "将":
 392            [extract(score) for score in self._matched_scores if score.rate.value <= RateType.SSS.value]
 393        elif self._kind == "极":
 394            [extract(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.FC.value]
 395        elif self._kind == "舞舞":
 396            [extract(score) for score in self._matched_scores if score.fs and score.fs.value <= FSType.FSD.value]
 397        elif self._kind == "神":
 398            [extract(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.AP.value]
 399
 400        return [plate for plate in results.values() if len(plate.levels) > 0]
 401
 402    async def get_cleared(self) -> list[PlateObject]:
 403        """Get the cleared songs and scores of the player on this plate.
 404
 405        If player has levels (one or more) that met the requirement on the song, the song and cleared `level_index` will be included in the result, otherwise it won't.
 406
 407        The distinct scores which met the plate requirement will be included in the result, the unfinished scores won't.
 408
 409        Returns:
 410            A list of `PlateObject` containing the song and the scores.
 411        """
 412        results = {song.id: PlateObject(song=song, levels=set(), scores=[]) for song in self._matched_songs}
 413
 414        def insert(score: ScoreExtend) -> None:
 415            results[score.id].scores.append(score)
 416            results[score.id].levels.add(score.level_index)
 417
 418        if self._kind == "者":
 419            [insert(score) for score in self._matched_scores if score.rate.value <= RateType.A.value]
 420        elif self._kind == "将":
 421            [insert(score) for score in self._matched_scores if score.rate.value <= RateType.SSS.value]
 422        elif self._kind == "极":
 423            [insert(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.FC.value]
 424        elif self._kind == "舞舞":
 425            [insert(score) for score in self._matched_scores if score.fs and score.fs.value <= FSType.FSD.value]
 426        elif self._kind == "神":
 427            [insert(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.AP.value]
 428
 429        return [plate for plate in results.values() if len(plate.levels) > 0]
 430
 431    async def get_played(self) -> list[PlateObject]:
 432        """Get the played songs and scores of the player on this plate.
 433
 434        If player has ever played levels on the song, whether they met or not, the song and played `level_index` will be included in the result.
 435
 436        All distinct scores will be included in the result.
 437
 438        Returns:
 439            A list of `PlateObject` containing the song and the scores.
 440        """
 441        results = {song.id: PlateObject(song=song, levels=set(), scores=[]) for song in self._matched_songs}
 442
 443        for score in self._matched_scores:
 444            results[score.id].scores.append(score)
 445            results[score.id].levels.add(score.level_index)
 446
 447        return [plate for plate in results.values() if len(plate.levels) > 0]
 448
 449    async def get_all(self) -> list[PlateObject]:
 450        """Get all songs and scores on this plate, usually used for overall statistics of the plate.
 451
 452        All songs will be included in the result, with played `level_index`, whether they met or not.
 453
 454        All distinct scores will be included in the result.
 455
 456        Returns:
 457            A list of `PlateObject` containing the song and the scores.
 458        """
 459        results = {song.id: PlateObject(song=song, levels=set(), scores=[]) for song in self._matched_songs}
 460
 461        for score in self._matched_scores:
 462            results[score.id].scores.append(score)
 463            results[score.id].levels.add(score.level_index)
 464
 465        return [plate for plate in results.values()]
 466
 467    async def count_played(self) -> int:
 468        """Get the number of played levels on this plate.
 469
 470        Returns:
 471            The number of played levels on this plate.
 472        """
 473        return len([level for plate in await self.get_played() for level in plate.levels])
 474
 475    async def count_cleared(self) -> int:
 476        """Get the number of cleared levels on this plate.
 477
 478        Returns:
 479            The number of cleared levels on this plate.
 480        """
 481        return len([level for plate in await self.get_cleared() for level in plate.levels])
 482
 483    async def count_remained(self) -> int:
 484        """Get the number of remained levels on this plate.
 485
 486        Returns:
 487            The number of remained levels on this plate.
 488        """
 489        return len([level for plate in await self.get_remained() for level in plate.levels])
 490
 491    async def count_all(self) -> int:
 492        """Get the number of all levels on this plate.
 493
 494        Returns:
 495            The number of all levels on this plate.
 496        """
 497        return sum(len(self._get_levels(plate.song)) for plate in await self.get_all())
 498
 499
 500class MaimaiScores:
 501    _client: "MaimaiClient"
 502
 503    scores: list[ScoreExtend]
 504    """All scores of the player."""
 505    scores_b35: list[ScoreExtend]
 506    """The b35 scores of the player."""
 507    scores_b15: list[ScoreExtend]
 508    """The b15 scores of the player."""
 509    rating: int
 510    """The total rating of the player."""
 511    rating_b35: int
 512    """The b35 rating of the player."""
 513    rating_b15: int
 514    """The b15 rating of the player."""
 515
 516    def __init__(self, client: "MaimaiClient"):
 517        self._client = client
 518
 519    async def configure(self, scores: list[Score], b50_only: bool = False) -> "MaimaiScores":
 520        """Initialize the scores by the scores list.
 521
 522        This method will sort the scores by their dx_rating, dx_score and achievements, and split them into b35 and b15 scores.
 523
 524        Args:
 525            scores: the scores list to initialize.
 526        Returns:
 527            The MaimaiScores object with the scores initialized.
 528        """
 529        maimai_songs = await self._client.songs()  # Ensure songs are configured.
 530        song_diff_versions: dict[str, int] = await self._client._cache.get("versions", namespace="songs") or {}
 531        self.scores, self.scores_b35, self.scores_b15 = [], [], []
 532
 533        # Remove duplicates from scores based on id, type and level_index.
 534        scores_unique: dict[str, Score] = {}
 535        for score in scores:
 536            score_key = f"{score.id} {score.type} {score.level_index}"
 537            scores_unique[score_key] = score._compare(scores_unique.get(score_key, None))
 538
 539        # Extend scores and categorize them into b35 and b15 based on their versions.
 540        self.scores = await MaimaiScores._get_extended(scores_unique.values(), maimai_songs)
 541        for score in self.scores:
 542            if score_version := song_diff_versions.get(f"{score.id} {score.type} {score.level_index}", None):
 543                (self.scores_b15 if score_version >= current_version.value else self.scores_b35).append(score)
 544
 545        # Sort scores by dx_rating, dx_score and achievements, and limit the number of scores.
 546        self.scores_b35.sort(key=lambda score: (score.dx_rating or 0, score.dx_score or 0, score.achievements or 0), reverse=True)
 547        self.scores_b15.sort(key=lambda score: (score.dx_rating or 0, score.dx_score or 0, score.achievements or 0), reverse=True)
 548        self.scores_b35 = self.scores_b35[:35]
 549        self.scores_b15 = self.scores_b15[:15]
 550        self.scores = self.scores_b35 + self.scores_b15 if b50_only else self.scores
 551
 552        # Calculate the total rating.
 553        self.rating_b35 = int(sum((score.dx_rating or 0) for score in self.scores_b35))
 554        self.rating_b15 = int(sum((score.dx_rating or 0) for score in self.scores_b15))
 555        self.rating = self.rating_b35 + self.rating_b15
 556
 557        return self
 558
 559    @staticmethod
 560    async def _get_mapping(scores: Iterable[T], maimai_songs: MaimaiSongs) -> AsyncGenerator[tuple[Song, SongDifficulty, T], None]:
 561        required_songs = await maimai_songs.get_batch(set(score.id for score in scores))
 562        required_songs_dict = {song.id: song for song in required_songs if song is not None}
 563        for score in scores:
 564            song = required_songs_dict.get(score.id, None)
 565            diff = song.get_difficulty(score.type, score.level_index) if song else None
 566            if score and song and diff:
 567                yield (song, diff, score)
 568
 569    @staticmethod
 570    async def _get_extended(scores: Iterable[Score], maimai_songs: MaimaiSongs) -> list[ScoreExtend]:
 571        extended_scores = []
 572        async for song, diff, score in MaimaiScores._get_mapping(scores, maimai_songs):
 573            extended_dict = dataclasses.asdict(score)
 574            extended_dict.update(
 575                {
 576                    "level": diff.level,  # Ensure level is set correctly.
 577                    "title": song.title,
 578                    "level_value": diff.level_value,
 579                    "level_dx_score": (diff.tap_num + diff.hold_num + diff.slide_num + diff.break_num + diff.touch_num) * 3,
 580                }
 581            )
 582            extended_scores.append(ScoreExtend(**extended_dict))
 583        return extended_scores
 584
 585    async def get_mapping(self) -> list[tuple[Song, SongDifficulty, ScoreExtend]]:
 586        """Get all scores with their corresponding songs.
 587
 588        This method will return a list of tuples, each containing a song, its corresponding difficulty, and the score.
 589
 590        If the song or difficulty is not found, the whole tuple will be excluded from the result.
 591
 592        Args:
 593            override_scores: a list of scores to override the current instance scores, defaults to UNSET.
 594        Returns:
 595            A list of tuples, each containing (song, difficulty, score).
 596        """
 597        maimai_songs, result = await self._client.songs(), []
 598        async for v in self._get_mapping(self.scores, maimai_songs):
 599            result.append(v)
 600        return result
 601
 602    def get_player_bests(self) -> PlayerBests:
 603        """Get the best scores of the player.
 604
 605        This method will return a PlayerBests object containing the best scores of the player, sorted by their dx_rating, dx_score and achievements.
 606
 607        Returns:
 608            A PlayerBests object containing the best scores of the player.
 609        """
 610        return PlayerBests(
 611            rating=self.rating,
 612            rating_b35=self.rating_b35,
 613            rating_b15=self.rating_b15,
 614            scores_b35=self.scores_b35,
 615            scores_b15=self.scores_b15,
 616        )
 617
 618    def by_song(
 619        self, song_id: int, song_type: Union[SongType, _UnsetSentinel] = UNSET, level_index: Union[LevelIndex, _UnsetSentinel] = UNSET
 620    ) -> list[ScoreExtend]:
 621        """Get scores of the song on that type and level_index.
 622
 623        If song_type or level_index is not provided, it won't be filtered by that attribute.
 624
 625        Args:
 626            song_id: the ID of the song to get the scores by.
 627            song_type: the type of the song to get the scores by, defaults to None.
 628            level_index: the level index of the song to get the scores by, defaults to None.
 629        Returns:
 630            A list of scores that match the song ID, type and level index.
 631            If no score is found, an empty list will be returned.
 632        """
 633        return [
 634            score
 635            for score in self.scores
 636            if score.id == song_id
 637            and (score.type == song_type or isinstance(song_type, _UnsetSentinel))
 638            and (score.level_index == level_index or isinstance(level_index, _UnsetSentinel))
 639        ]
 640
 641    def filter(self, **kwargs) -> list[Score]:
 642        """Filter scores by their attributes.
 643
 644        Make sure the attribute is of the score, and the value is of the same type. All conditions are connected by AND.
 645
 646        Args:
 647            kwargs: the attributes to filter the scores by.
 648        Returns:
 649            an iterator of scores that match all the conditions, yields no items if no score is found.
 650        """
 651        return [score for score in self.scores if all(getattr(score, key) == value for key, value in kwargs.items() if value is not None)]
 652
 653
 654class MaimaiAreas:
 655    _client: "MaimaiClient"
 656    _lang: str
 657
 658    def __init__(self, client: "MaimaiClient") -> None:
 659        """@private"""
 660        self._client = client
 661
 662    async def _configure(self, lang: str, provider: Union[IAreaProvider, _UnsetSentinel]) -> "MaimaiAreas":
 663        self._lang = lang
 664        cache_obj, cache_ttl = self._client._cache, self._client._cache_ttl
 665        # Check if the provider is unset, which means we want to access the cache directly.
 666        if isinstance(provider, _UnsetSentinel):
 667            if await self._client._cache.get("provider", None, namespace=f"areas_{lang}") is not None:
 668                return self
 669        # Really assign the unset provider to the default one.
 670        provider = provider if not isinstance(provider, _UnsetSentinel) else LocalProvider()
 671        # Check if the current provider hash is different from the previous one, which means we need to reconfigure.
 672        current_provider_hash = provider._hash()
 673        previous_provider_hash = await cache_obj.get("provider", "", namespace=f"areas_{lang}")
 674        if current_provider_hash != previous_provider_hash:
 675            areas = await provider.get_areas(lang, self._client)
 676            await asyncio.gather(
 677                cache_obj.set("provider", hash(provider), ttl=cache_ttl, namespace=f"areas_{lang}"),  # provider
 678                cache_obj.set("ids", [area.id for area in areas.values()], namespace=f"areas_{lang}"),  # ids
 679                cache_obj.multi_set(iter((k, v) for k, v in areas.items()), namespace=f"areas_{lang}"),  # areas
 680            )
 681        return self
 682
 683    async def get_all(self) -> list[Area]:
 684        """All areas as list.
 685
 686        This method will iterate all areas in the cache. Unless you really need to iterate all areas, you should use `by_id` or `by_name` instead.
 687
 688        Returns:
 689            A list of all areas in the cache, return an empty list if no area is found.
 690        """
 691        area_ids: Optional[list[int]] = await self._client._cache.get("ids", namespace=f"areas_{self._lang}")
 692        assert area_ids is not None, "Areas not found in cache, please call configure() first."
 693        return await self._client._multi_get(area_ids, namespace=f"areas_{self._lang}")
 694
 695    async def get_batch(self, ids: Iterable[str]) -> list[Area]:
 696        """Get areas by their IDs.
 697
 698        Args:
 699            ids: the IDs of the areas.
 700        Returns:
 701            A list of areas if they exist, otherwise return an empty list.
 702        """
 703        return await self._client._multi_get(ids, namespace=f"areas_{self._lang}")
 704
 705    async def by_id(self, id: str) -> Optional[Area]:
 706        """Get an area by its ID.
 707
 708        Args:
 709            id: the ID of the area.
 710        Returns:
 711            the area if it exists, otherwise return None.
 712        """
 713        return await self._client._cache.get(id, namespace=f"areas_{self._lang}")
 714
 715    async def by_name(self, name: str) -> Optional[Area]:
 716        """Get an area by its name, language-sensitive.
 717
 718        Args:
 719            name: the name of the area.
 720        Returns:
 721            the area if it exists, otherwise return None.
 722        """
 723        return next((area for area in await self.get_all() if area.name == name), None)
 724
 725
 726class MaimaiClient:
 727    """The main client of maimai.py."""
 728
 729    _client: AsyncClient
 730    _cache: BaseCache
 731    _cache_ttl: int
 732
 733    def __new__(cls, *args, **kwargs):
 734        if hasattr(cls, "_instance"):
 735            warn_message = (
 736                "MaimaiClient is a singleton, args are ignored in this case, due to the singleton nature. "
 737                "If you think this is a mistake, please check MaimaiClientMultithreading. "
 738            )
 739            warnings.warn(warn_message, stacklevel=2)
 740            return cls._instance
 741        orig = super(MaimaiClient, cls)
 742        cls._instance = orig.__new__(cls)
 743        return cls._instance
 744
 745    def __init__(
 746        self,
 747        timeout: float = 20.0,
 748        cache: Union[BaseCache, _UnsetSentinel] = UNSET,
 749        cache_ttl: int = 60 * 60 * 24,
 750        **kwargs,
 751    ) -> None:
 752        """Initialize the maimai.py client.
 753
 754        Args:
 755            timeout: the timeout of the requests, defaults to 20.0.
 756            cache: the cache to use, defaults to `aiocache.SimpleMemoryCache()`.
 757            cache_ttl: the TTL of the cache, defaults to 60 * 60 * 24.
 758            kwargs: other arguments to pass to the `httpx.AsyncClient`.
 759        """
 760        self._client = AsyncClient(timeout=timeout, **kwargs)
 761        self._cache = SimpleMemoryCache() if isinstance(cache, _UnsetSentinel) else cache
 762        self._cache_ttl = cache_ttl
 763
 764    async def _multi_get(self, keys: Iterable[Any], namespace: Optional[str] = None) -> list[Any]:
 765        keys_list = list(keys)
 766        if len(keys_list) != 0:
 767            return await self._cache.multi_get(keys_list, namespace=namespace)
 768        return []
 769
 770    async def songs(
 771        self,
 772        provider: Union[ISongProvider, _UnsetSentinel] = UNSET,
 773        alias_provider: Union[IAliasProvider, None, _UnsetSentinel] = UNSET,
 774        curve_provider: Union[ICurveProvider, None, _UnsetSentinel] = UNSET,
 775    ) -> MaimaiSongs:
 776        """Fetch all maimai songs from the provider.
 777
 778        Available providers: `DivingFishProvider`, `LXNSProvider`.
 779
 780        Available alias providers: `YuzuProvider`, `LXNSProvider`.
 781
 782        Available curve providers: `DivingFishProvider`.
 783
 784        Args:
 785            provider: override the data source to fetch the player from, defaults to `LXNSProvider`.
 786            alias_provider: override the data source to fetch the song aliases from, defaults to `YuzuProvider`.
 787            curve_provider: override the data source to fetch the song curves from, defaults to `None`.
 788        Returns:
 789            A wrapper of the song list, for easier access and filtering.
 790        Raises:
 791            httpx.RequestError: Request failed due to network issues.
 792        """
 793        songs = MaimaiSongs(self)
 794        return await songs._configure(provider, alias_provider, curve_provider)
 795
 796    async def players(
 797        self,
 798        identifier: PlayerIdentifier,
 799        provider: IPlayerProvider = LXNSProvider(),
 800    ) -> Player:
 801        """Fetch player data from the provider.
 802
 803        Available providers: `DivingFishProvider`, `LXNSProvider`, `ArcadeProvider`.
 804
 805        Possible returns: `DivingFishPlayer`, `LXNSPlayer`, `ArcadePlayer`.
 806
 807        Args:
 808            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(username="turou")`.
 809            provider: the data source to fetch the player from, defaults to `LXNSProvider`.
 810        Returns:
 811            The player object of the player, with all the data fetched. Depending on the provider, it may contain different objects that derived from `Player`.
 812        Raises:
 813            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
 814            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
 815            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
 816            httpx.RequestError: Request failed due to network issues.
 817        Raises:
 818            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
 819            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
 820            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
 821        """
 822        return await provider.get_player(identifier, self)
 823
 824    async def scores(
 825        self,
 826        identifier: PlayerIdentifier,
 827        provider: IScoreProvider = LXNSProvider(),
 828    ) -> MaimaiScores:
 829        """Fetch player's ALL scores from the provider.
 830
 831        All scores of the player will be fetched, if you want to fetch only the best scores (for better performance), use `maimai.bests()` instead.
 832
 833        For WechatProvider, PlayerIdentifier must have the `credentials` attribute, we suggest you to use the `maimai.wechat()` method to get the identifier.
 834        Also, PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time.
 835
 836        For ArcadeProvider, PlayerIdentifier must have the `credentials` attribute, which is the player's encrypted userId, can be detrived from `maimai.qrcode()`.
 837        Credentials can be reused, since it won't expire, also, userId is encrypted, can't be used in any other cases outside the maimai.py
 838
 839        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
 840
 841        Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`, `ArcadeProvider`.
 842
 843        Args:
 844            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
 845            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
 846        Returns:
 847            The scores object of the player, with all the data fetched.
 848        Raises:
 849            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
 850            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
 851            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
 852            httpx.RequestError: Request failed due to network issues.
 853        Raises:
 854            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
 855            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
 856            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
 857        """
 858        scores = await provider.get_scores_all(identifier, self)
 859
 860        maimai_scores = MaimaiScores(self)
 861        return await maimai_scores.configure(scores)
 862
 863    async def bests(
 864        self,
 865        identifier: PlayerIdentifier,
 866        provider: IScoreProvider = LXNSProvider(),
 867    ) -> MaimaiScores:
 868        """Fetch player's B50 scores from the provider.
 869
 870        Though MaimaiScores is used, this method will only return the best 50 scores. if you want all scores, please use `maimai.scores()` method instead.
 871
 872        For WechatProvider, PlayerIdentifier must have the `credentials` attribute, we suggest you to use the `maimai.wechat()` method to get the identifier.
 873        Also, PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time.
 874
 875        For ArcadeProvider, PlayerIdentifier must have the `credentials` attribute, which is the player's encrypted userId, can be detrived from `maimai.qrcode()`.
 876        Credentials can be reused, since it won't expire, also, userId is encrypted, can't be used in any other cases outside the maimai.py
 877
 878        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
 879
 880        Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`, `ArcadeProvider`.
 881
 882        Args:
 883            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
 884            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
 885        Returns:
 886            The scores object of the player, with all the data fetched.
 887        Raises:
 888            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
 889            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
 890            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
 891            httpx.RequestError: Request failed due to network issues.
 892        Raises:
 893            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
 894            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
 895            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
 896        """
 897        maimai_scores = MaimaiScores(self)
 898        best_scores = await provider.get_scores_best(identifier, self)
 899        return await maimai_scores.configure(best_scores, b50_only=True)
 900
 901    async def minfo(
 902        self,
 903        song: Union[Song, int, str],
 904        identifier: Optional[PlayerIdentifier],
 905        provider: IScoreProvider = LXNSProvider(),
 906    ) -> Optional[PlayerSong]:
 907        """Fetch player's scores on the specific song.
 908
 909        This method will return all scores of the player on the song.
 910
 911        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
 912
 913        Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`, `ArcadeProvider`.
 914
 915        Args:
 916            song: the song to fetch the scores from, can be a `Song` object, or a song_id (int), or keywords (str).
 917            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
 918            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
 919        Returns:
 920            A wrapper of the song and the scores, with full song model, and matched player scores.
 921            If the identifier is not provided, the song will be returned as is, without scores.
 922            If the song is not found, None will be returned.
 923        Raises:
 924            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
 925            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
 926            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
 927            httpx.RequestError: Request failed due to network issues.
 928        Raises:
 929            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
 930            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
 931            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
 932        """
 933        maimai_songs, scores = await self.songs(), []
 934        if isinstance(song, str):
 935            search_result = await maimai_songs.by_keywords(song)
 936            song = search_result[0] if len(search_result) > 0 else song
 937        if isinstance(song, int):
 938            search_result = await maimai_songs.by_id(song)
 939            song = search_result if search_result is not None else song
 940        if isinstance(song, Song):
 941            extended_scores = []
 942            if identifier is not None:
 943                scores = await provider.get_scores_one(identifier, song, self)
 944                extended_scores = await MaimaiScores._get_extended(scores, maimai_songs)
 945            return PlayerSong(song, extended_scores)
 946
 947    async def regions(self, identifier: PlayerIdentifier, provider: IRegionProvider = ArcadeProvider()) -> list[PlayerRegion]:
 948        """Get the player's regions that they have played.
 949
 950        Args:
 951            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(credentials="encrypted_user_id")`.
 952            provider: the data source to fetch the player from, defaults to `ArcadeProvider`.
 953        Returns:
 954            The list of regions that the player has played.
 955        Raises:
 956            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
 957            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
 958            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
 959        """
 960        return await provider.get_regions(identifier, self)
 961
 962    async def updates(
 963        self,
 964        identifier: PlayerIdentifier,
 965        scores: Iterable[Score],
 966        provider: IScoreUpdateProvider = LXNSProvider(),
 967    ) -> None:
 968        """Update player's scores to the provider.
 969
 970        This method is used to update the player's scores to the provider, usually used for updating scores fetched from other providers.
 971
 972        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
 973
 974        Available providers: `DivingFishProvider`, `LXNSProvider`.
 975
 976        Args:
 977            identifier: the identifier of the player to update, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
 978            scores: the scores to update, usually the scores fetched from other providers.
 979            provider: the data source to update the player scores to, defaults to `LXNSProvider`.
 980        Returns:
 981            Nothing, failures will raise exceptions.
 982        Raises:
 983            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found, or the import token / password is invalid.
 984            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
 985            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
 986            httpx.RequestError: Request failed due to network issues.
 987        """
 988        await provider.update_scores(identifier, scores, self)
 989
 990    async def plates(
 991        self,
 992        identifier: PlayerIdentifier,
 993        plate: str,
 994        provider: IScoreProvider = LXNSProvider(),
 995    ) -> MaimaiPlates:
 996        """Get the plate achievement of the given player and plate.
 997
 998        Available providers: `DivingFishProvider`, `LXNSProvider`, `ArcadeProvider`.
 999
1000        Args:
1001            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
1002            plate: the name of the plate, e.g. "樱将", "真舞舞".
1003            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
1004        Returns:
1005            A wrapper of the plate achievement, with plate information, and matched player scores.
1006        Raises:
1007            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
1008            InvalidPlateError: Provided version or plate is invalid.
1009            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
1010            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
1011            httpx.RequestError: Request failed due to network issues.
1012        Raises:
1013            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
1014            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
1015            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
1016        """
1017        scores = await provider.get_scores_all(identifier, self)
1018        maimai_plates = MaimaiPlates(self)
1019        return await maimai_plates._configure(plate, scores)
1020
1021    async def identifiers(
1022        self,
1023        code: Union[str, dict[str, str]],
1024        provider: Union[IPlayerIdentifierProvider, _UnsetSentinel] = UNSET,
1025    ) -> PlayerIdentifier:
1026        """Get the player identifier from the provider.
1027
1028        This method is combination of `maimai.wechat()` and `maimai.qrcode()`, which will return the player identifier of the player.
1029
1030        For WechatProvider, code should be a dictionary with `r`, `t`, `code`, and `state` keys, or a string that contains the URL parameters.
1031
1032        For ArcadeProvider, code should be a string that begins with `SGWCMAID`, which is the QR code of the player.
1033
1034        Available providers: `WechatProvider`, `ArcadeProvider`.
1035
1036        Args:
1037            code: the code to get the player identifier, can be a string or a dictionary with `r`, `t`, `code`, and `state` keys.
1038            provider: override the default provider, defaults to `ArcadeProvider`.
1039        Returns:
1040            The player identifier of the player.
1041        Raises:
1042            InvalidWechatTokenError: Wechat token is expired, please re-authorize.
1043            AimeServerError: Maimai Aime server error, may be invalid QR code or QR code has expired.
1044            httpx.RequestError: Request failed due to network issues.
1045        """
1046        if isinstance(provider, _UnsetSentinel):
1047            provider = ArcadeProvider()
1048        return await provider.get_identifier(code, self)
1049
1050    async def items(self, item: Type[PlayerItemType], provider: Union[IItemListProvider, _UnsetSentinel] = UNSET) -> MaimaiItems[PlayerItemType]:
1051        """Fetch maimai player items from the cache default provider.
1052
1053        Available items: `PlayerIcon`, `PlayerNamePlate`, `PlayerFrame`, `PlayerTrophy`, `PlayerChara`, `PlayerPartner`.
1054
1055        Args:
1056            item: the item type to fetch, e.g. `PlayerIcon`.
1057            provider: override the default item list provider, defaults to `LXNSProvider` and `LocalProvider`.
1058        Returns:
1059            A wrapper of the item list, for easier access and filtering.
1060        Raises:
1061            FileNotFoundError: The item file is not found.
1062            httpx.RequestError: Request failed due to network issues.
1063        """
1064        maimai_items = MaimaiItems[PlayerItemType](self, item._namespace())
1065        return await maimai_items._configure(provider)
1066
1067    async def areas(self, lang: Literal["ja", "zh"] = "ja", provider: IAreaProvider = LocalProvider()) -> MaimaiAreas:
1068        """Fetch maimai areas from the provider.
1069
1070        Available providers: `LocalProvider`.
1071
1072        Args:
1073            lang: the language of the area to fetch, available languages: `ja`, `zh`.
1074            provider: override the default area provider, defaults to `ArcadeProvider`.
1075        Returns:
1076            A wrapper of the area list, for easier access and filtering.
1077        Raises:
1078            FileNotFoundError: The area file is not found.
1079        """
1080        maimai_areas = MaimaiAreas(self)
1081        return await maimai_areas._configure(lang, provider)
1082
1083    async def wechat(
1084        self,
1085        r: Optional[str] = None,
1086        t: Optional[str] = None,
1087        code: Optional[str] = None,
1088        state: Optional[str] = None,
1089    ) -> Union[str, PlayerIdentifier]:
1090        """Get the player identifier from the Wahlap Wechat OffiAccount.
1091
1092        Call the method with no parameters to get the URL, then redirect the user to the URL with your mitmproxy enabled.
1093
1094        Your mitmproxy should intercept the response from tgk-wcaime.wahlap.com, then call the method with the parameters from the intercepted response.
1095
1096        With the parameters from specific user's response, the method will return the user's player identifier.
1097
1098        Never cache or store the player identifier, as the cookies may expire at any time.
1099
1100        Args:
1101            r: the r parameter from the request, defaults to None.
1102            t: the t parameter from the request, defaults to None.
1103            code: the code parameter from the request, defaults to None.
1104            state: the state parameter from the request, defaults to None.
1105        Returns:
1106            The player identifier if all parameters are provided, otherwise return the URL to get the identifier.
1107        Raises:
1108            WechatTokenExpiredError: Wechat token is expired, please re-authorize.
1109            httpx.RequestError: Request failed due to network issues.
1110        """
1111        if r is None or t is None or code is None or state is None:
1112            resp = await self._client.get("https://tgk-wcaime.wahlap.com/wc_auth/oauth/authorize/maimai-dx")
1113            return resp.headers["location"].replace("redirect_uri=https", "redirect_uri=http")
1114        return await WechatProvider().get_identifier({"r": r, "t": t, "code": code, "state": state}, self)
1115
1116    async def qrcode(self, qrcode: str, http_proxy: Optional[str] = None) -> PlayerIdentifier:
1117        """Get the player identifier from the Wahlap QR code.
1118
1119        Player identifier is the encrypted userId, can't be used in any other cases outside the maimai.py.
1120
1121        Args:
1122            qrcode: the QR code of the player, should begin with SGWCMAID.
1123            http_proxy: the http proxy to use for the request, defaults to None.
1124        Returns:
1125            The player identifier of the player.
1126        Raises:
1127            AimeServerError: Maimai Aime server error, may be invalid QR code or QR code has expired.
1128        """
1129        provider = ArcadeProvider(http_proxy=http_proxy)
1130        return await provider.get_identifier(qrcode, self)
1131
1132    async def updates_chain(
1133        self,
1134        source: list[tuple[IScoreProvider, Optional[PlayerIdentifier], dict[str, Any]]],
1135        target: list[tuple[IScoreUpdateProvider, Optional[PlayerIdentifier], dict[str, Any]]],
1136        source_mode: Literal["fallback", "parallel"] = "fallback",
1137        target_mode: Literal["fallback", "parallel"] = "parallel",
1138        source_callback: Optional[Callable[[MaimaiScores, Optional[BaseException], dict[str, Any]], None]] = None,
1139        target_callback: Optional[Callable[[MaimaiScores, Optional[BaseException], dict[str, Any]], None]] = None,
1140    ) -> None:
1141        """Chain updates from source providers to target providers.
1142
1143        This method will fetch scores from the source providers, merge them, and then update the target providers with the merged scores.
1144
1145        The dict in source and target tuples can contain any additional context that will be passed to the callbacks.
1146
1147        Args:
1148            source: a list of tuples, each containing a source provider, an optional player identifier, and additional context.
1149                If the identifier is None, the provider will be ignored.
1150            target: a list of tuples, each containing a target provider, an optional player identifier, and additional context.
1151                If the identifier is None, the provider will be ignored.
1152            source_mode: how to handle source tasks, either "fallback" (default) or "parallel".
1153                In "fallback" mode, only the first successful source pair will be scheduled.
1154                In "parallel" mode, all source pairs will be scheduled.
1155            target_mode: how to handle target tasks, either "fallback" or "parallel" (default).
1156                In "fallback" mode, only the first successful target pair will be scheduled.
1157                In "parallel" mode, all target pairs will be scheduled.
1158            source_callback: an optional callback function that will be called with the source provider,
1159                callback with provider, fetched scores and any exception that occurred during fetching.
1160            target_callback: an optional callback function that will be called with the target provider,
1161                callback with provider, merged scores and any exception that occurred during updating.
1162        Returns:
1163            Nothing, failures will notify by callbacks.
1164        """
1165        source_tasks, target_tasks = [], []
1166
1167        # Fetch scores from the source providers.
1168        for sp, ident, kwargs in source:
1169            if ident is not None:
1170                if source_mode == "parallel" or (source_mode == "fallback" and len(source_tasks) == 0):
1171                    source_task = asyncio.create_task(self.scores(ident, sp))
1172                    if source_callback is not None:
1173                        source_task.add_done_callback(lambda t, k=kwargs: source_callback(t.result(), t.exception(), k))
1174                    source_tasks.append(source_task)
1175        source_gather_results = await asyncio.gather(*source_tasks, return_exceptions=True)
1176        maimai_scores_list = [result for result in source_gather_results if isinstance(result, MaimaiScores)]
1177
1178        # Merge scores from all maimai_scores instances.
1179        scores_unique: dict[str, Score] = {}
1180        for maimai_scores in maimai_scores_list:
1181            for score in maimai_scores.scores:
1182                score_key = f"{score.id} {score.type} {score.level_index}"
1183                scores_unique[score_key] = score._join(scores_unique.get(score_key, None))
1184        merged_scores = list(scores_unique.values())
1185        merged_maimai_scores = await MaimaiScores(self).configure(list(scores_unique.values()))
1186
1187        # Update scores to the target providers.
1188        for tp, ident, kwargs in target:
1189            if ident is not None:
1190                if target_mode == "parallel" or (target_mode == "fallback" and len(target_tasks) == 0):
1191                    target_task = asyncio.create_task(self.updates(ident, merged_scores, tp))
1192                    if target_callback is not None:
1193                        target_task.add_done_callback(lambda t, k=kwargs: target_callback(merged_maimai_scores, t.exception(), k))
1194                    target_tasks.append(target_task)
1195        await asyncio.gather(*target_tasks, return_exceptions=True)
1196
1197
1198class MaimaiClientMultithreading(MaimaiClient):
1199    """Multi-threading version of maimai.py.
1200    Introduced by issue #28. Users who want to share the same client instance across multiple threads can use this class.
1201    """
1202
1203    def __new__(cls, *args, **kwargs):
1204        # Override the singleton behavior by always creating a new instance
1205        return super(MaimaiClient, cls).__new__(cls)
class MaimaiItems(typing.Generic[~PlayerItemType]):
22class MaimaiItems(Generic[PlayerItemType]):
23    _client: "MaimaiClient"
24    _namespace: str
25
26    def __init__(self, client: "MaimaiClient", namespace: str) -> None:
27        """@private"""
28        self._client = client
29        self._namespace = namespace
30
31    async def _configure(self, provider: Union[IItemListProvider, _UnsetSentinel] = UNSET) -> "MaimaiItems":
32        cache_obj, cache_ttl = self._client._cache, self._client._cache_ttl
33        # Check if the provider is unset, which means we want to access the cache directly.
34        if isinstance(provider, _UnsetSentinel):
35            if await cache_obj.get("provider", None, namespace=self._namespace) is not None:
36                return self
37        # Really assign the unset provider to the default one.
38        provider = LXNSProvider() if PlayerItemType in [PlayerIcon, PlayerNamePlate, PlayerFrame] else LocalProvider()
39        # Check if the current provider hash is different from the previous one, which means we need to reconfigure.
40        current_provider_hash = provider._hash()
41        previous_provider_hash = await cache_obj.get("provider", "", namespace=self._namespace)
42        # If different or previous is empty, we need to reconfigure the items.
43        if current_provider_hash != previous_provider_hash:
44            val: dict[int, Any] = await getattr(provider, f"get_{self._namespace}")(self._client)
45            await asyncio.gather(
46                cache_obj.set("provider", current_provider_hash, ttl=cache_ttl, namespace=self._namespace),  # provider
47                cache_obj.set("ids", [key for key in val.keys()], namespace=self._namespace),  # ids
48                cache_obj.multi_set(val.items(), namespace=self._namespace),  # items
49            )
50        return self
51
52    async def get_all(self) -> list[PlayerItemType]:
53        """All items as list.
54
55        This method will iterate all items in the cache, and yield each item one by one. Unless you really need to iterate all items, you should use `by_id` or `filter` instead.
56
57        Returns:
58            A list with all items in the cache, return an empty list if no item is found.
59        """
60        item_ids: Optional[list[int]] = await self._client._cache.get("ids", namespace=self._namespace)
61        assert item_ids is not None, f"Items not found in cache {self._namespace}, please call configure() first."
62        return await self._client._multi_get(item_ids, namespace=self._namespace)
63
64    async def get_batch(self, ids: Iterable[int]) -> list[PlayerItemType]:
65        """Get items by their IDs.
66
67        Args:
68            ids: the IDs of the items.
69        Returns:
70            A list of items if they exist, otherwise return an empty list.
71        """
72        return await self._client._multi_get(ids, namespace=self._namespace)
73
74    async def by_id(self, id: int) -> Optional[PlayerItemType]:
75        """Get an item by its ID.
76
77        Args:
78            id: the ID of the item.
79        Returns:
80            the item if it exists, otherwise return None.
81        """
82        return await self._client._cache.get(id, namespace=self._namespace)
83
84    async def filter(self, **kwargs) -> list[PlayerItemType]:
85        """Filter items by their attributes.
86
87        Ensure that the attribute is of the item, and the value is of the same type. All conditions are connected by AND.
88
89        Args:
90            kwargs: the attributes to filter the items by.
91        Returns:
92            an async generator yielding items that match all the conditions, yields no items if no item is found.
93        """
94        cond = lambda item: all(getattr(item, key) == value for key, value in kwargs.items() if value is not None)
95        return [item for item in await self.get_all() if cond(item)]

Abstract base class for generic types.

On Python 3.12 and newer, generic classes implicitly inherit from Generic when they declare a parameter list after the class's name::

class Mapping[KT, VT]:
    def __getitem__(self, key: KT) -> VT:
        ...
    # Etc.

On older versions of Python, however, generic classes have to explicitly inherit from Generic.

After a class has been declared to be generic, it can then be used as follows::

def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT:
    try:
        return mapping[key]
    except KeyError:
        return default
async def get_all(self) -> list[~PlayerItemType]:
52    async def get_all(self) -> list[PlayerItemType]:
53        """All items as list.
54
55        This method will iterate all items in the cache, and yield each item one by one. Unless you really need to iterate all items, you should use `by_id` or `filter` instead.
56
57        Returns:
58            A list with all items in the cache, return an empty list if no item is found.
59        """
60        item_ids: Optional[list[int]] = await self._client._cache.get("ids", namespace=self._namespace)
61        assert item_ids is not None, f"Items not found in cache {self._namespace}, please call configure() first."
62        return await self._client._multi_get(item_ids, namespace=self._namespace)

All items as list.

This method will iterate all items in the cache, and yield each item one by one. Unless you really need to iterate all items, you should use by_id or filter instead.

Returns:

A list with all items in the cache, return an empty list if no item is found.

async def get_batch(self, ids: Iterable[int]) -> list[~PlayerItemType]:
64    async def get_batch(self, ids: Iterable[int]) -> list[PlayerItemType]:
65        """Get items by their IDs.
66
67        Args:
68            ids: the IDs of the items.
69        Returns:
70            A list of items if they exist, otherwise return an empty list.
71        """
72        return await self._client._multi_get(ids, namespace=self._namespace)

Get items by their IDs.

Arguments:
  • ids: the IDs of the items.
Returns:

A list of items if they exist, otherwise return an empty list.

async def by_id(self, id: int) -> Optional[~PlayerItemType]:
74    async def by_id(self, id: int) -> Optional[PlayerItemType]:
75        """Get an item by its ID.
76
77        Args:
78            id: the ID of the item.
79        Returns:
80            the item if it exists, otherwise return None.
81        """
82        return await self._client._cache.get(id, namespace=self._namespace)

Get an item by its ID.

Arguments:
  • id: the ID of the item.
Returns:

the item if it exists, otherwise return None.

async def filter(self, **kwargs) -> list[~PlayerItemType]:
84    async def filter(self, **kwargs) -> list[PlayerItemType]:
85        """Filter items by their attributes.
86
87        Ensure that the attribute is of the item, and the value is of the same type. All conditions are connected by AND.
88
89        Args:
90            kwargs: the attributes to filter the items by.
91        Returns:
92            an async generator yielding items that match all the conditions, yields no items if no item is found.
93        """
94        cond = lambda item: all(getattr(item, key) == value for key, value in kwargs.items() if value is not None)
95        return [item for item in await self.get_all() if cond(item)]

Filter items by their attributes.

Ensure that the attribute is of the item, and the value is of the same type. All conditions are connected by AND.

Arguments:
  • kwargs: the attributes to filter the items by.
Returns:

an async generator yielding items that match all the conditions, yields no items if no item is found.

class MaimaiSongs:
 98class MaimaiSongs:
 99    _client: "MaimaiClient"
100
101    def __init__(self, client: "MaimaiClient") -> None:
102        """@private"""
103        self._client = client
104
105    async def _configure(
106        self,
107        provider: Union[ISongProvider, _UnsetSentinel],
108        alias_provider: Union[IAliasProvider, None, _UnsetSentinel],
109        curve_provider: Union[ICurveProvider, None, _UnsetSentinel],
110    ) -> "MaimaiSongs":
111        cache_obj, cache_ttl = self._client._cache, self._client._cache_ttl
112        # Check if all providers are unset, which means we want to access the cache directly.
113        if isinstance(provider, _UnsetSentinel) and isinstance(alias_provider, _UnsetSentinel) and isinstance(curve_provider, _UnsetSentinel):
114            if await cache_obj.get("provider", None, namespace="songs") is not None:
115                return self
116        # Really assign the unset providers to the default ones.
117        provider = provider if not isinstance(provider, _UnsetSentinel) else LXNSProvider()
118        alias_provider = alias_provider if not isinstance(alias_provider, _UnsetSentinel) else YuzuProvider()
119        curve_provider = curve_provider if not isinstance(curve_provider, _UnsetSentinel) else None  # Don't fetch curves if not provided.
120        # Check if the current provider hash is different from the previous one, which means we need to reconfigure.
121        current_provider_hash = hashlib.md5(
122            (provider._hash() + (alias_provider._hash() if alias_provider else "") + (curve_provider._hash() if curve_provider else "")).encode()
123        ).hexdigest()
124        previous_provider_hash = await cache_obj.get("provider", "", namespace="songs")
125        # If different or previous is empty, we need to reconfigure the songs.
126        if current_provider_hash != previous_provider_hash:
127            # Get the resources from the providers in parallel.
128            songs, song_aliases, song_curves = await asyncio.gather(
129                provider.get_songs(self._client),
130                alias_provider.get_aliases(self._client) if alias_provider else asyncio.sleep(0, result={}),
131                curve_provider.get_curves(self._client) if curve_provider else asyncio.sleep(0, result={}),
132            )
133
134            # Build the song objects and set their aliases and curves if provided.
135            for song in songs:
136                if alias_provider is not None and (aliases := song_aliases.get(song.id, None)):
137                    song.aliases = aliases
138                if curve_provider is not None:
139                    if curves := song_curves.get((song.id, SongType.DX), None):
140                        diffs = song.get_difficulties(SongType.DX)
141                        [diff.__setattr__("curve", curves[i]) for i, diff in enumerate(diffs) if i < len(curves)]
142                    if curves := song_curves.get((song.id, SongType.STANDARD), None):
143                        diffs = song.get_difficulties(SongType.STANDARD)
144                        [diff.__setattr__("curve", curves[i]) for i, diff in enumerate(diffs) if i < len(curves)]
145                    if curves := song_curves.get((song.id, SongType.UTAGE), None):
146                        diffs = song.get_difficulties(SongType.UTAGE)
147                        [diff.__setattr__("curve", curves[i]) for i, diff in enumerate(diffs) if i < len(curves)]
148
149            # Set the cache with the songs, aliases, and versions.
150            await asyncio.gather(
151                cache_obj.set("provider", current_provider_hash, ttl=cache_ttl, namespace="songs"),  # provider
152                cache_obj.set("ids", [song.id for song in songs], namespace="songs"),  # ids
153                cache_obj.multi_set(iter((song.id, song) for song in songs), namespace="songs"),  # songs
154                cache_obj.multi_set(iter((song.title, song.id) for song in songs), namespace="tracks"),  # titles
155                cache_obj.multi_set(iter((li, id) for id, ul in song_aliases.items() for li in ul), namespace="aliases"),  # aliases
156                cache_obj.set(
157                    "versions",
158                    {f"{song.id} {diff.type} {diff.level_index}": diff.version for song in songs for diff in song.get_difficulties()},
159                    namespace="songs",
160                ),  # versions
161            )
162        return self
163
164    async def get_all(self) -> list[Song]:
165        """All songs as list.
166
167        This method will iterate all songs in the cache, and yield each song one by one. Unless you really need to iterate all songs, you should use `by_id` or `filter` instead.
168
169        Returns:
170            A list of all songs in the cache, return an empty list if no song is found.
171        """
172        song_ids: Optional[list[int]] = await self._client._cache.get("ids", namespace="songs")
173        assert song_ids is not None, "Songs not found in cache, please call configure() first."
174        return await self._client._multi_get(song_ids, namespace="songs")
175
176    async def get_batch(self, ids: Iterable[int]) -> list[Song]:
177        """Get songs by their IDs.
178
179        Args:
180            ids: the IDs of the songs.
181        Returns:
182            A list of songs if they exist, otherwise return an empty list.
183        """
184        return await self._client._multi_get(ids, namespace="songs")
185
186    async def by_id(self, id: int) -> Optional[Song]:
187        """Get a song by its ID.
188
189        Args:
190            id: the ID of the song, always smaller than `10000`, should (`% 10000`) if necessary.
191        Returns:
192            the song if it exists, otherwise return None.
193        """
194        return await self._client._cache.get(id, namespace="songs")
195
196    async def by_title(self, title: str) -> Optional[Song]:
197        """Get a song by its title.
198
199        Args:
200            title: the title of the song.
201        Returns:
202            the song if it exists, otherwise return None.
203        """
204        song_id = await self._client._cache.get(title, namespace="tracks")
205        song_id = 383 if title == "Link(CoF)" else song_id
206        return await self._client._cache.get(song_id, namespace="songs") if song_id else None
207
208    async def by_alias(self, alias: str) -> Optional[Song]:
209        """Get song by one possible alias.
210
211        Args:
212            alias: one possible alias of the song.
213        Returns:
214            the song if it exists, otherwise return None.
215        """
216        if song_id := await self._client._cache.get(alias, namespace="aliases"):
217            if song := await self._client._cache.get(song_id, namespace="songs"):
218                return song
219
220    async def by_artist(self, artist: str) -> list[Song]:
221        """Get songs by their artist, case-sensitive.
222
223        Args:
224            artist: the artist of the songs.
225        Returns:
226            an async generator yielding songs that match the artist.
227        """
228        return [song for song in await self.get_all() if song.artist == artist]
229
230    async def by_genre(self, genre: Genre) -> list[Song]:
231        """Get songs by their genre, case-sensitive.
232
233        Args:
234            genre: the genre of the songs.
235        Returns:
236            an async generator yielding songs that match the genre.
237        """
238        return [song for song in await self.get_all() if song.genre == genre]
239
240    async def by_bpm(self, minimum: int, maximum: int) -> list[Song]:
241        """Get songs by their BPM.
242
243        Args:
244            minimum: the minimum (inclusive) BPM of the songs.
245            maximum: the maximum (inclusive) BPM of the songs.
246        Returns:
247            an async generator yielding songs that match the BPM.
248        """
249        return [song for song in await self.get_all() if minimum <= song.bpm <= maximum]
250
251    async def by_versions(self, versions: Version) -> list[Song]:
252        """Get songs by their versions, versions are fuzzy matched version of major maimai version.
253
254        Args:
255            versions: the versions of the songs.
256        Returns:
257            an async generator yielding songs that match the versions.
258        """
259        cond = lambda song: versions.value <= song.version < all_versions[all_versions.index(versions) + 1].value
260        return [song for song in await self.get_all() if cond(song)]
261
262    async def by_keywords(self, keywords: str) -> list[Song]:
263        """Get songs by their keywords, keywords are matched with song title, artist and aliases.
264
265        Args:
266            keywords: the keywords to match the songs.
267        Returns:
268            a list of songs that match the keywords, case-insensitive.
269        """
270        exact_matches = []
271        fuzzy_matches = []
272
273        # Process all songs in a single pass
274        for song in await self.get_all():
275            # Check for exact matches
276            if (
277                keywords.lower() == song.title.lower()
278                or keywords.lower() == song.artist.lower()
279                or any(keywords.lower() == alias.lower() for alias in (song.aliases or []))
280            ):
281                exact_matches.append(song)
282            # Check for fuzzy matches
283            elif keywords.lower() in f"{song.title} + {song.artist} + {''.join(a for a in (song.aliases or []))}".lower():
284                fuzzy_matches.append(song)
285
286        # Return exact matches if found, otherwise return fuzzy matches
287        return exact_matches + fuzzy_matches if exact_matches else fuzzy_matches
288
289    async def filter(self, **kwargs) -> list[Song]:
290        """Filter songs by their attributes.
291
292        Ensure that the attribute is of the song, and the value is of the same type. All conditions are connected by AND.
293
294        Args:
295            kwargs: the attributes to filter the songs by.
296        Returns:
297            a list of songs that match all the conditions.
298        """
299        cond = lambda song: all(getattr(song, key) == value for key, value in kwargs.items() if value is not None)
300        return [song for song in await self.get_all() if cond(song)]
async def get_all(self) -> list[maimai_py.models.Song]:
164    async def get_all(self) -> list[Song]:
165        """All songs as list.
166
167        This method will iterate all songs in the cache, and yield each song one by one. Unless you really need to iterate all songs, you should use `by_id` or `filter` instead.
168
169        Returns:
170            A list of all songs in the cache, return an empty list if no song is found.
171        """
172        song_ids: Optional[list[int]] = await self._client._cache.get("ids", namespace="songs")
173        assert song_ids is not None, "Songs not found in cache, please call configure() first."
174        return await self._client._multi_get(song_ids, namespace="songs")

All songs as list.

This method will iterate all songs in the cache, and yield each song one by one. Unless you really need to iterate all songs, you should use by_id or filter instead.

Returns:

A list of all songs in the cache, return an empty list if no song is found.

async def get_batch(self, ids: Iterable[int]) -> list[maimai_py.models.Song]:
176    async def get_batch(self, ids: Iterable[int]) -> list[Song]:
177        """Get songs by their IDs.
178
179        Args:
180            ids: the IDs of the songs.
181        Returns:
182            A list of songs if they exist, otherwise return an empty list.
183        """
184        return await self._client._multi_get(ids, namespace="songs")

Get songs by their IDs.

Arguments:
  • ids: the IDs of the songs.
Returns:

A list of songs if they exist, otherwise return an empty list.

async def by_id(self, id: int) -> Optional[maimai_py.models.Song]:
186    async def by_id(self, id: int) -> Optional[Song]:
187        """Get a song by its ID.
188
189        Args:
190            id: the ID of the song, always smaller than `10000`, should (`% 10000`) if necessary.
191        Returns:
192            the song if it exists, otherwise return None.
193        """
194        return await self._client._cache.get(id, namespace="songs")

Get a song by its ID.

Arguments:
  • id: the ID of the song, always smaller than 10000, should (% 10000) if necessary.
Returns:

the song if it exists, otherwise return None.

async def by_title(self, title: str) -> Optional[maimai_py.models.Song]:
196    async def by_title(self, title: str) -> Optional[Song]:
197        """Get a song by its title.
198
199        Args:
200            title: the title of the song.
201        Returns:
202            the song if it exists, otherwise return None.
203        """
204        song_id = await self._client._cache.get(title, namespace="tracks")
205        song_id = 383 if title == "Link(CoF)" else song_id
206        return await self._client._cache.get(song_id, namespace="songs") if song_id else None

Get a song by its title.

Arguments:
  • title: the title of the song.
Returns:

the song if it exists, otherwise return None.

async def by_alias(self, alias: str) -> Optional[maimai_py.models.Song]:
208    async def by_alias(self, alias: str) -> Optional[Song]:
209        """Get song by one possible alias.
210
211        Args:
212            alias: one possible alias of the song.
213        Returns:
214            the song if it exists, otherwise return None.
215        """
216        if song_id := await self._client._cache.get(alias, namespace="aliases"):
217            if song := await self._client._cache.get(song_id, namespace="songs"):
218                return song

Get song by one possible alias.

Arguments:
  • alias: one possible alias of the song.
Returns:

the song if it exists, otherwise return None.

async def by_artist(self, artist: str) -> list[maimai_py.models.Song]:
220    async def by_artist(self, artist: str) -> list[Song]:
221        """Get songs by their artist, case-sensitive.
222
223        Args:
224            artist: the artist of the songs.
225        Returns:
226            an async generator yielding songs that match the artist.
227        """
228        return [song for song in await self.get_all() if song.artist == artist]

Get songs by their artist, case-sensitive.

Arguments:
  • artist: the artist of the songs.
Returns:

an async generator yielding songs that match the artist.

async def by_genre(self, genre: maimai_py.enums.Genre) -> list[maimai_py.models.Song]:
230    async def by_genre(self, genre: Genre) -> list[Song]:
231        """Get songs by their genre, case-sensitive.
232
233        Args:
234            genre: the genre of the songs.
235        Returns:
236            an async generator yielding songs that match the genre.
237        """
238        return [song for song in await self.get_all() if song.genre == genre]

Get songs by their genre, case-sensitive.

Arguments:
  • genre: the genre of the songs.
Returns:

an async generator yielding songs that match the genre.

async def by_bpm(self, minimum: int, maximum: int) -> list[maimai_py.models.Song]:
240    async def by_bpm(self, minimum: int, maximum: int) -> list[Song]:
241        """Get songs by their BPM.
242
243        Args:
244            minimum: the minimum (inclusive) BPM of the songs.
245            maximum: the maximum (inclusive) BPM of the songs.
246        Returns:
247            an async generator yielding songs that match the BPM.
248        """
249        return [song for song in await self.get_all() if minimum <= song.bpm <= maximum]

Get songs by their BPM.

Arguments:
  • minimum: the minimum (inclusive) BPM of the songs.
  • maximum: the maximum (inclusive) BPM of the songs.
Returns:

an async generator yielding songs that match the BPM.

async def by_versions(self, versions: maimai_py.enums.Version) -> list[maimai_py.models.Song]:
251    async def by_versions(self, versions: Version) -> list[Song]:
252        """Get songs by their versions, versions are fuzzy matched version of major maimai version.
253
254        Args:
255            versions: the versions of the songs.
256        Returns:
257            an async generator yielding songs that match the versions.
258        """
259        cond = lambda song: versions.value <= song.version < all_versions[all_versions.index(versions) + 1].value
260        return [song for song in await self.get_all() if cond(song)]

Get songs by their versions, versions are fuzzy matched version of major maimai version.

Arguments:
  • versions: the versions of the songs.
Returns:

an async generator yielding songs that match the versions.

async def by_keywords(self, keywords: str) -> list[maimai_py.models.Song]:
262    async def by_keywords(self, keywords: str) -> list[Song]:
263        """Get songs by their keywords, keywords are matched with song title, artist and aliases.
264
265        Args:
266            keywords: the keywords to match the songs.
267        Returns:
268            a list of songs that match the keywords, case-insensitive.
269        """
270        exact_matches = []
271        fuzzy_matches = []
272
273        # Process all songs in a single pass
274        for song in await self.get_all():
275            # Check for exact matches
276            if (
277                keywords.lower() == song.title.lower()
278                or keywords.lower() == song.artist.lower()
279                or any(keywords.lower() == alias.lower() for alias in (song.aliases or []))
280            ):
281                exact_matches.append(song)
282            # Check for fuzzy matches
283            elif keywords.lower() in f"{song.title} + {song.artist} + {''.join(a for a in (song.aliases or []))}".lower():
284                fuzzy_matches.append(song)
285
286        # Return exact matches if found, otherwise return fuzzy matches
287        return exact_matches + fuzzy_matches if exact_matches else fuzzy_matches

Get songs by their keywords, keywords are matched with song title, artist and aliases.

Arguments:
  • keywords: the keywords to match the songs.
Returns:

a list of songs that match the keywords, case-insensitive.

async def filter(self, **kwargs) -> list[maimai_py.models.Song]:
289    async def filter(self, **kwargs) -> list[Song]:
290        """Filter songs by their attributes.
291
292        Ensure that the attribute is of the song, and the value is of the same type. All conditions are connected by AND.
293
294        Args:
295            kwargs: the attributes to filter the songs by.
296        Returns:
297            a list of songs that match all the conditions.
298        """
299        cond = lambda song: all(getattr(song, key) == value for key, value in kwargs.items() if value is not None)
300        return [song for song in await self.get_all() if cond(song)]

Filter songs by their attributes.

Ensure that the attribute is of the song, and the value is of the same type. All conditions are connected by AND.

Arguments:
  • kwargs: the attributes to filter the songs by.
Returns:

a list of songs that match all the conditions.

class MaimaiPlates:
303class MaimaiPlates:
304    _client: "MaimaiClient"
305
306    _kind: str  # The kind of the plate, e.g. "将", "神".
307    _version: str  # The version of the plate, e.g. "真", "舞".
308    _versions: set[Version] = set()  # The matched versions set of the plate.
309    _matched_songs: list[Song] = []
310    _matched_scores: list[ScoreExtend] = []
311
312    def __init__(self, client: "MaimaiClient") -> None:
313        """@private"""
314        self._client = client
315
316    async def _configure(self, plate: str, scores: list[Score]) -> "MaimaiPlates":
317        maimai_songs = await self._client.songs()
318        self._version = plate_aliases.get(plate[0], plate[0])
319        self._kind = plate_aliases.get(plate[1:], plate[1:])
320
321        versions = list()  # in case of invalid plate, we will raise an error
322        if self._version == "真":
323            versions = [plate_to_version["初"], plate_to_version["真"]]
324        if self._version in ["霸", "舞"]:
325            versions = [ver for ver in plate_to_version.values() if ver.value < 20000]
326        if plate_to_version.get(self._version):
327            versions = [plate_to_version[self._version]]
328        if not versions or self._kind not in ["将", "者", "极", "舞舞", "神"]:
329            raise InvalidPlateError(f"Invalid plate: {self._version}{self._kind}")
330        versions.append([ver for ver in plate_to_version.values() if ver.value > versions[-1].value][0])
331        self._versions = set(versions)
332
333        song_diff_versions: dict[str, int] = await self._client._cache.get("versions", namespace="songs") or {}
334        versioned_matched_songs = set()
335        for k, v in song_diff_versions.items():
336            if any(v >= o.value and v < versions[i + 1].value for i, o in enumerate(versions[:-1])):
337                versioned_matched_songs.add(int(k.split(" ")[0]))
338        self._matched_songs = await self._client._multi_get(list(versioned_matched_songs), namespace="songs")
339
340        versioned_joined_scores: dict[str, Score] = {}
341        for score in scores:
342            score_key = f"{score.id} {score.type} {score.level_index}"
343            if score_version := song_diff_versions.get(score_key, None):
344                if any(score_version >= o.value and score_version < versions[i + 1].value for i, o in enumerate(versions[:-1])):
345                    if not (score.level_index == LevelIndex.ReMASTER and self.no_remaster):
346                        versioned_joined_scores[score_key] = score._join(versioned_joined_scores.get(score_key, None))
347        self._matched_scores = await MaimaiScores._get_extended(versioned_joined_scores.values(), maimai_songs)
348
349        return self
350
351    @cached_property
352    def _major_type(self) -> SongType:
353        return SongType.DX if any(ver.value > 20000 for ver in self._versions) else SongType.STANDARD
354
355    @cached_property
356    def no_remaster(self) -> bool:
357        """Whether it is required to play ReMASTER levels in the plate.
358
359        Only 舞 and 霸 plates require ReMASTER levels, others don't.
360        """
361        return self._version not in ["舞", "霸"]
362
363    def _get_levels(self, song: Song) -> set[LevelIndex]:
364        levels = set(diff.level_index for diff in song.get_difficulties(self._major_type))
365        if self.no_remaster and LevelIndex.ReMASTER in levels:
366            levels.remove(LevelIndex.ReMASTER)
367        return levels
368
369    async def get_remained(self) -> list[PlateObject]:
370        """Get the remained songs and scores of the player on this plate.
371
372        If player has ramained levels on one song, the song and ramained `level_index` will be included in the result, otherwise it won't.
373
374        The distinct scores which NOT met the plate requirement will be included in the result, the finished scores won't.
375
376        Returns:
377            A list of `PlateObject` containing the song and the scores.
378        """
379        # Group scores by song ID to pre-fill the PlateObject.
380        grouped = defaultdict(list)
381        [grouped[score.id].append(score) for score in self._matched_scores]
382        # Create PlateObject for each song with its levels and scores.
383        results = {song.id: PlateObject(song=song, levels=self._get_levels(song), scores=grouped.get(song.id, [])) for song in self._matched_songs}
384
385        def extract(score: ScoreExtend) -> None:
386            results[score.id].scores.remove(score)
387            if score.level_index in results[score.id].levels:
388                results[score.id].levels.remove(score.level_index)
389
390        if self._kind == "者":
391            [extract(score) for score in self._matched_scores if score.rate.value <= RateType.A.value]
392        elif self._kind == "将":
393            [extract(score) for score in self._matched_scores if score.rate.value <= RateType.SSS.value]
394        elif self._kind == "极":
395            [extract(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.FC.value]
396        elif self._kind == "舞舞":
397            [extract(score) for score in self._matched_scores if score.fs and score.fs.value <= FSType.FSD.value]
398        elif self._kind == "神":
399            [extract(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.AP.value]
400
401        return [plate for plate in results.values() if len(plate.levels) > 0]
402
403    async def get_cleared(self) -> list[PlateObject]:
404        """Get the cleared songs and scores of the player on this plate.
405
406        If player has levels (one or more) that met the requirement on the song, the song and cleared `level_index` will be included in the result, otherwise it won't.
407
408        The distinct scores which met the plate requirement will be included in the result, the unfinished scores won't.
409
410        Returns:
411            A list of `PlateObject` containing the song and the scores.
412        """
413        results = {song.id: PlateObject(song=song, levels=set(), scores=[]) for song in self._matched_songs}
414
415        def insert(score: ScoreExtend) -> None:
416            results[score.id].scores.append(score)
417            results[score.id].levels.add(score.level_index)
418
419        if self._kind == "者":
420            [insert(score) for score in self._matched_scores if score.rate.value <= RateType.A.value]
421        elif self._kind == "将":
422            [insert(score) for score in self._matched_scores if score.rate.value <= RateType.SSS.value]
423        elif self._kind == "极":
424            [insert(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.FC.value]
425        elif self._kind == "舞舞":
426            [insert(score) for score in self._matched_scores if score.fs and score.fs.value <= FSType.FSD.value]
427        elif self._kind == "神":
428            [insert(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.AP.value]
429
430        return [plate for plate in results.values() if len(plate.levels) > 0]
431
432    async def get_played(self) -> list[PlateObject]:
433        """Get the played songs and scores of the player on this plate.
434
435        If player has ever played levels on the song, whether they met or not, the song and played `level_index` will be included in the result.
436
437        All distinct scores will be included in the result.
438
439        Returns:
440            A list of `PlateObject` containing the song and the scores.
441        """
442        results = {song.id: PlateObject(song=song, levels=set(), scores=[]) for song in self._matched_songs}
443
444        for score in self._matched_scores:
445            results[score.id].scores.append(score)
446            results[score.id].levels.add(score.level_index)
447
448        return [plate for plate in results.values() if len(plate.levels) > 0]
449
450    async def get_all(self) -> list[PlateObject]:
451        """Get all songs and scores on this plate, usually used for overall statistics of the plate.
452
453        All songs will be included in the result, with played `level_index`, whether they met or not.
454
455        All distinct scores will be included in the result.
456
457        Returns:
458            A list of `PlateObject` containing the song and the scores.
459        """
460        results = {song.id: PlateObject(song=song, levels=set(), scores=[]) for song in self._matched_songs}
461
462        for score in self._matched_scores:
463            results[score.id].scores.append(score)
464            results[score.id].levels.add(score.level_index)
465
466        return [plate for plate in results.values()]
467
468    async def count_played(self) -> int:
469        """Get the number of played levels on this plate.
470
471        Returns:
472            The number of played levels on this plate.
473        """
474        return len([level for plate in await self.get_played() for level in plate.levels])
475
476    async def count_cleared(self) -> int:
477        """Get the number of cleared levels on this plate.
478
479        Returns:
480            The number of cleared levels on this plate.
481        """
482        return len([level for plate in await self.get_cleared() for level in plate.levels])
483
484    async def count_remained(self) -> int:
485        """Get the number of remained levels on this plate.
486
487        Returns:
488            The number of remained levels on this plate.
489        """
490        return len([level for plate in await self.get_remained() for level in plate.levels])
491
492    async def count_all(self) -> int:
493        """Get the number of all levels on this plate.
494
495        Returns:
496            The number of all levels on this plate.
497        """
498        return sum(len(self._get_levels(plate.song)) for plate in await self.get_all())
no_remaster: bool
355    @cached_property
356    def no_remaster(self) -> bool:
357        """Whether it is required to play ReMASTER levels in the plate.
358
359        Only 舞 and 霸 plates require ReMASTER levels, others don't.
360        """
361        return self._version not in ["舞", "霸"]

Whether it is required to play ReMASTER levels in the plate.

Only 舞 and 霸 plates require ReMASTER levels, others don't.

async def get_remained(self) -> list[maimai_py.models.PlateObject]:
369    async def get_remained(self) -> list[PlateObject]:
370        """Get the remained songs and scores of the player on this plate.
371
372        If player has ramained levels on one song, the song and ramained `level_index` will be included in the result, otherwise it won't.
373
374        The distinct scores which NOT met the plate requirement will be included in the result, the finished scores won't.
375
376        Returns:
377            A list of `PlateObject` containing the song and the scores.
378        """
379        # Group scores by song ID to pre-fill the PlateObject.
380        grouped = defaultdict(list)
381        [grouped[score.id].append(score) for score in self._matched_scores]
382        # Create PlateObject for each song with its levels and scores.
383        results = {song.id: PlateObject(song=song, levels=self._get_levels(song), scores=grouped.get(song.id, [])) for song in self._matched_songs}
384
385        def extract(score: ScoreExtend) -> None:
386            results[score.id].scores.remove(score)
387            if score.level_index in results[score.id].levels:
388                results[score.id].levels.remove(score.level_index)
389
390        if self._kind == "者":
391            [extract(score) for score in self._matched_scores if score.rate.value <= RateType.A.value]
392        elif self._kind == "将":
393            [extract(score) for score in self._matched_scores if score.rate.value <= RateType.SSS.value]
394        elif self._kind == "极":
395            [extract(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.FC.value]
396        elif self._kind == "舞舞":
397            [extract(score) for score in self._matched_scores if score.fs and score.fs.value <= FSType.FSD.value]
398        elif self._kind == "神":
399            [extract(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.AP.value]
400
401        return [plate for plate in results.values() if len(plate.levels) > 0]

Get the remained songs and scores of the player on this plate.

If player has ramained levels on one song, the song and ramained level_index will be included in the result, otherwise it won't.

The distinct scores which NOT met the plate requirement will be included in the result, the finished scores won't.

Returns:

A list of PlateObject containing the song and the scores.

async def get_cleared(self) -> list[maimai_py.models.PlateObject]:
403    async def get_cleared(self) -> list[PlateObject]:
404        """Get the cleared songs and scores of the player on this plate.
405
406        If player has levels (one or more) that met the requirement on the song, the song and cleared `level_index` will be included in the result, otherwise it won't.
407
408        The distinct scores which met the plate requirement will be included in the result, the unfinished scores won't.
409
410        Returns:
411            A list of `PlateObject` containing the song and the scores.
412        """
413        results = {song.id: PlateObject(song=song, levels=set(), scores=[]) for song in self._matched_songs}
414
415        def insert(score: ScoreExtend) -> None:
416            results[score.id].scores.append(score)
417            results[score.id].levels.add(score.level_index)
418
419        if self._kind == "者":
420            [insert(score) for score in self._matched_scores if score.rate.value <= RateType.A.value]
421        elif self._kind == "将":
422            [insert(score) for score in self._matched_scores if score.rate.value <= RateType.SSS.value]
423        elif self._kind == "极":
424            [insert(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.FC.value]
425        elif self._kind == "舞舞":
426            [insert(score) for score in self._matched_scores if score.fs and score.fs.value <= FSType.FSD.value]
427        elif self._kind == "神":
428            [insert(score) for score in self._matched_scores if score.fc and score.fc.value <= FCType.AP.value]
429
430        return [plate for plate in results.values() if len(plate.levels) > 0]

Get the cleared songs and scores of the player on this plate.

If player has levels (one or more) that met the requirement on the song, the song and cleared level_index will be included in the result, otherwise it won't.

The distinct scores which met the plate requirement will be included in the result, the unfinished scores won't.

Returns:

A list of PlateObject containing the song and the scores.

async def get_played(self) -> list[maimai_py.models.PlateObject]:
432    async def get_played(self) -> list[PlateObject]:
433        """Get the played songs and scores of the player on this plate.
434
435        If player has ever played levels on the song, whether they met or not, the song and played `level_index` will be included in the result.
436
437        All distinct scores will be included in the result.
438
439        Returns:
440            A list of `PlateObject` containing the song and the scores.
441        """
442        results = {song.id: PlateObject(song=song, levels=set(), scores=[]) for song in self._matched_songs}
443
444        for score in self._matched_scores:
445            results[score.id].scores.append(score)
446            results[score.id].levels.add(score.level_index)
447
448        return [plate for plate in results.values() if len(plate.levels) > 0]

Get the played songs and scores of the player on this plate.

If player has ever played levels on the song, whether they met or not, the song and played level_index will be included in the result.

All distinct scores will be included in the result.

Returns:

A list of PlateObject containing the song and the scores.

async def get_all(self) -> list[maimai_py.models.PlateObject]:
450    async def get_all(self) -> list[PlateObject]:
451        """Get all songs and scores on this plate, usually used for overall statistics of the plate.
452
453        All songs will be included in the result, with played `level_index`, whether they met or not.
454
455        All distinct scores will be included in the result.
456
457        Returns:
458            A list of `PlateObject` containing the song and the scores.
459        """
460        results = {song.id: PlateObject(song=song, levels=set(), scores=[]) for song in self._matched_songs}
461
462        for score in self._matched_scores:
463            results[score.id].scores.append(score)
464            results[score.id].levels.add(score.level_index)
465
466        return [plate for plate in results.values()]

Get all songs and scores on this plate, usually used for overall statistics of the plate.

All songs will be included in the result, with played level_index, whether they met or not.

All distinct scores will be included in the result.

Returns:

A list of PlateObject containing the song and the scores.

async def count_played(self) -> int:
468    async def count_played(self) -> int:
469        """Get the number of played levels on this plate.
470
471        Returns:
472            The number of played levels on this plate.
473        """
474        return len([level for plate in await self.get_played() for level in plate.levels])

Get the number of played levels on this plate.

Returns:

The number of played levels on this plate.

async def count_cleared(self) -> int:
476    async def count_cleared(self) -> int:
477        """Get the number of cleared levels on this plate.
478
479        Returns:
480            The number of cleared levels on this plate.
481        """
482        return len([level for plate in await self.get_cleared() for level in plate.levels])

Get the number of cleared levels on this plate.

Returns:

The number of cleared levels on this plate.

async def count_remained(self) -> int:
484    async def count_remained(self) -> int:
485        """Get the number of remained levels on this plate.
486
487        Returns:
488            The number of remained levels on this plate.
489        """
490        return len([level for plate in await self.get_remained() for level in plate.levels])

Get the number of remained levels on this plate.

Returns:

The number of remained levels on this plate.

async def count_all(self) -> int:
492    async def count_all(self) -> int:
493        """Get the number of all levels on this plate.
494
495        Returns:
496            The number of all levels on this plate.
497        """
498        return sum(len(self._get_levels(plate.song)) for plate in await self.get_all())

Get the number of all levels on this plate.

Returns:

The number of all levels on this plate.

class MaimaiScores:
501class MaimaiScores:
502    _client: "MaimaiClient"
503
504    scores: list[ScoreExtend]
505    """All scores of the player."""
506    scores_b35: list[ScoreExtend]
507    """The b35 scores of the player."""
508    scores_b15: list[ScoreExtend]
509    """The b15 scores of the player."""
510    rating: int
511    """The total rating of the player."""
512    rating_b35: int
513    """The b35 rating of the player."""
514    rating_b15: int
515    """The b15 rating of the player."""
516
517    def __init__(self, client: "MaimaiClient"):
518        self._client = client
519
520    async def configure(self, scores: list[Score], b50_only: bool = False) -> "MaimaiScores":
521        """Initialize the scores by the scores list.
522
523        This method will sort the scores by their dx_rating, dx_score and achievements, and split them into b35 and b15 scores.
524
525        Args:
526            scores: the scores list to initialize.
527        Returns:
528            The MaimaiScores object with the scores initialized.
529        """
530        maimai_songs = await self._client.songs()  # Ensure songs are configured.
531        song_diff_versions: dict[str, int] = await self._client._cache.get("versions", namespace="songs") or {}
532        self.scores, self.scores_b35, self.scores_b15 = [], [], []
533
534        # Remove duplicates from scores based on id, type and level_index.
535        scores_unique: dict[str, Score] = {}
536        for score in scores:
537            score_key = f"{score.id} {score.type} {score.level_index}"
538            scores_unique[score_key] = score._compare(scores_unique.get(score_key, None))
539
540        # Extend scores and categorize them into b35 and b15 based on their versions.
541        self.scores = await MaimaiScores._get_extended(scores_unique.values(), maimai_songs)
542        for score in self.scores:
543            if score_version := song_diff_versions.get(f"{score.id} {score.type} {score.level_index}", None):
544                (self.scores_b15 if score_version >= current_version.value else self.scores_b35).append(score)
545
546        # Sort scores by dx_rating, dx_score and achievements, and limit the number of scores.
547        self.scores_b35.sort(key=lambda score: (score.dx_rating or 0, score.dx_score or 0, score.achievements or 0), reverse=True)
548        self.scores_b15.sort(key=lambda score: (score.dx_rating or 0, score.dx_score or 0, score.achievements or 0), reverse=True)
549        self.scores_b35 = self.scores_b35[:35]
550        self.scores_b15 = self.scores_b15[:15]
551        self.scores = self.scores_b35 + self.scores_b15 if b50_only else self.scores
552
553        # Calculate the total rating.
554        self.rating_b35 = int(sum((score.dx_rating or 0) for score in self.scores_b35))
555        self.rating_b15 = int(sum((score.dx_rating or 0) for score in self.scores_b15))
556        self.rating = self.rating_b35 + self.rating_b15
557
558        return self
559
560    @staticmethod
561    async def _get_mapping(scores: Iterable[T], maimai_songs: MaimaiSongs) -> AsyncGenerator[tuple[Song, SongDifficulty, T], None]:
562        required_songs = await maimai_songs.get_batch(set(score.id for score in scores))
563        required_songs_dict = {song.id: song for song in required_songs if song is not None}
564        for score in scores:
565            song = required_songs_dict.get(score.id, None)
566            diff = song.get_difficulty(score.type, score.level_index) if song else None
567            if score and song and diff:
568                yield (song, diff, score)
569
570    @staticmethod
571    async def _get_extended(scores: Iterable[Score], maimai_songs: MaimaiSongs) -> list[ScoreExtend]:
572        extended_scores = []
573        async for song, diff, score in MaimaiScores._get_mapping(scores, maimai_songs):
574            extended_dict = dataclasses.asdict(score)
575            extended_dict.update(
576                {
577                    "level": diff.level,  # Ensure level is set correctly.
578                    "title": song.title,
579                    "level_value": diff.level_value,
580                    "level_dx_score": (diff.tap_num + diff.hold_num + diff.slide_num + diff.break_num + diff.touch_num) * 3,
581                }
582            )
583            extended_scores.append(ScoreExtend(**extended_dict))
584        return extended_scores
585
586    async def get_mapping(self) -> list[tuple[Song, SongDifficulty, ScoreExtend]]:
587        """Get all scores with their corresponding songs.
588
589        This method will return a list of tuples, each containing a song, its corresponding difficulty, and the score.
590
591        If the song or difficulty is not found, the whole tuple will be excluded from the result.
592
593        Args:
594            override_scores: a list of scores to override the current instance scores, defaults to UNSET.
595        Returns:
596            A list of tuples, each containing (song, difficulty, score).
597        """
598        maimai_songs, result = await self._client.songs(), []
599        async for v in self._get_mapping(self.scores, maimai_songs):
600            result.append(v)
601        return result
602
603    def get_player_bests(self) -> PlayerBests:
604        """Get the best scores of the player.
605
606        This method will return a PlayerBests object containing the best scores of the player, sorted by their dx_rating, dx_score and achievements.
607
608        Returns:
609            A PlayerBests object containing the best scores of the player.
610        """
611        return PlayerBests(
612            rating=self.rating,
613            rating_b35=self.rating_b35,
614            rating_b15=self.rating_b15,
615            scores_b35=self.scores_b35,
616            scores_b15=self.scores_b15,
617        )
618
619    def by_song(
620        self, song_id: int, song_type: Union[SongType, _UnsetSentinel] = UNSET, level_index: Union[LevelIndex, _UnsetSentinel] = UNSET
621    ) -> list[ScoreExtend]:
622        """Get scores of the song on that type and level_index.
623
624        If song_type or level_index is not provided, it won't be filtered by that attribute.
625
626        Args:
627            song_id: the ID of the song to get the scores by.
628            song_type: the type of the song to get the scores by, defaults to None.
629            level_index: the level index of the song to get the scores by, defaults to None.
630        Returns:
631            A list of scores that match the song ID, type and level index.
632            If no score is found, an empty list will be returned.
633        """
634        return [
635            score
636            for score in self.scores
637            if score.id == song_id
638            and (score.type == song_type or isinstance(song_type, _UnsetSentinel))
639            and (score.level_index == level_index or isinstance(level_index, _UnsetSentinel))
640        ]
641
642    def filter(self, **kwargs) -> list[Score]:
643        """Filter scores by their attributes.
644
645        Make sure the attribute is of the score, and the value is of the same type. All conditions are connected by AND.
646
647        Args:
648            kwargs: the attributes to filter the scores by.
649        Returns:
650            an iterator of scores that match all the conditions, yields no items if no score is found.
651        """
652        return [score for score in self.scores if all(getattr(score, key) == value for key, value in kwargs.items() if value is not None)]
MaimaiScores(client: MaimaiClient)
517    def __init__(self, client: "MaimaiClient"):
518        self._client = client

All scores of the player.

scores_b35: list[maimai_py.models.ScoreExtend]

The b35 scores of the player.

scores_b15: list[maimai_py.models.ScoreExtend]

The b15 scores of the player.

rating: int

The total rating of the player.

rating_b35: int

The b35 rating of the player.

rating_b15: int

The b15 rating of the player.

async def configure( self, scores: list[maimai_py.models.Score], b50_only: bool = False) -> MaimaiScores:
520    async def configure(self, scores: list[Score], b50_only: bool = False) -> "MaimaiScores":
521        """Initialize the scores by the scores list.
522
523        This method will sort the scores by their dx_rating, dx_score and achievements, and split them into b35 and b15 scores.
524
525        Args:
526            scores: the scores list to initialize.
527        Returns:
528            The MaimaiScores object with the scores initialized.
529        """
530        maimai_songs = await self._client.songs()  # Ensure songs are configured.
531        song_diff_versions: dict[str, int] = await self._client._cache.get("versions", namespace="songs") or {}
532        self.scores, self.scores_b35, self.scores_b15 = [], [], []
533
534        # Remove duplicates from scores based on id, type and level_index.
535        scores_unique: dict[str, Score] = {}
536        for score in scores:
537            score_key = f"{score.id} {score.type} {score.level_index}"
538            scores_unique[score_key] = score._compare(scores_unique.get(score_key, None))
539
540        # Extend scores and categorize them into b35 and b15 based on their versions.
541        self.scores = await MaimaiScores._get_extended(scores_unique.values(), maimai_songs)
542        for score in self.scores:
543            if score_version := song_diff_versions.get(f"{score.id} {score.type} {score.level_index}", None):
544                (self.scores_b15 if score_version >= current_version.value else self.scores_b35).append(score)
545
546        # Sort scores by dx_rating, dx_score and achievements, and limit the number of scores.
547        self.scores_b35.sort(key=lambda score: (score.dx_rating or 0, score.dx_score or 0, score.achievements or 0), reverse=True)
548        self.scores_b15.sort(key=lambda score: (score.dx_rating or 0, score.dx_score or 0, score.achievements or 0), reverse=True)
549        self.scores_b35 = self.scores_b35[:35]
550        self.scores_b15 = self.scores_b15[:15]
551        self.scores = self.scores_b35 + self.scores_b15 if b50_only else self.scores
552
553        # Calculate the total rating.
554        self.rating_b35 = int(sum((score.dx_rating or 0) for score in self.scores_b35))
555        self.rating_b15 = int(sum((score.dx_rating or 0) for score in self.scores_b15))
556        self.rating = self.rating_b35 + self.rating_b15
557
558        return self

Initialize the scores by the scores list.

This method will sort the scores by their dx_rating, dx_score and achievements, and split them into b35 and b15 scores.

Arguments:
  • scores: the scores list to initialize.
Returns:

The MaimaiScores object with the scores initialized.

async def get_mapping( self) -> list[tuple[maimai_py.models.Song, maimai_py.models.SongDifficulty, maimai_py.models.ScoreExtend]]:
586    async def get_mapping(self) -> list[tuple[Song, SongDifficulty, ScoreExtend]]:
587        """Get all scores with their corresponding songs.
588
589        This method will return a list of tuples, each containing a song, its corresponding difficulty, and the score.
590
591        If the song or difficulty is not found, the whole tuple will be excluded from the result.
592
593        Args:
594            override_scores: a list of scores to override the current instance scores, defaults to UNSET.
595        Returns:
596            A list of tuples, each containing (song, difficulty, score).
597        """
598        maimai_songs, result = await self._client.songs(), []
599        async for v in self._get_mapping(self.scores, maimai_songs):
600            result.append(v)
601        return result

Get all scores with their corresponding songs.

This method will return a list of tuples, each containing a song, its corresponding difficulty, and the score.

If the song or difficulty is not found, the whole tuple will be excluded from the result.

Arguments:
  • override_scores: a list of scores to override the current instance scores, defaults to UNSET.
Returns:

A list of tuples, each containing (song, difficulty, score).

def get_player_bests(self) -> maimai_py.models.PlayerBests:
603    def get_player_bests(self) -> PlayerBests:
604        """Get the best scores of the player.
605
606        This method will return a PlayerBests object containing the best scores of the player, sorted by their dx_rating, dx_score and achievements.
607
608        Returns:
609            A PlayerBests object containing the best scores of the player.
610        """
611        return PlayerBests(
612            rating=self.rating,
613            rating_b35=self.rating_b35,
614            rating_b15=self.rating_b15,
615            scores_b35=self.scores_b35,
616            scores_b15=self.scores_b15,
617        )

Get the best scores of the player.

This method will return a PlayerBests object containing the best scores of the player, sorted by their dx_rating, dx_score and achievements.

Returns:

A PlayerBests object containing the best scores of the player.

def by_song( self, song_id: int, song_type: Union[maimai_py.enums.SongType, maimai_py.utils.sentinel._UnsetSentinel] = Unset, level_index: Union[maimai_py.enums.LevelIndex, maimai_py.utils.sentinel._UnsetSentinel] = Unset) -> list[maimai_py.models.ScoreExtend]:
619    def by_song(
620        self, song_id: int, song_type: Union[SongType, _UnsetSentinel] = UNSET, level_index: Union[LevelIndex, _UnsetSentinel] = UNSET
621    ) -> list[ScoreExtend]:
622        """Get scores of the song on that type and level_index.
623
624        If song_type or level_index is not provided, it won't be filtered by that attribute.
625
626        Args:
627            song_id: the ID of the song to get the scores by.
628            song_type: the type of the song to get the scores by, defaults to None.
629            level_index: the level index of the song to get the scores by, defaults to None.
630        Returns:
631            A list of scores that match the song ID, type and level index.
632            If no score is found, an empty list will be returned.
633        """
634        return [
635            score
636            for score in self.scores
637            if score.id == song_id
638            and (score.type == song_type or isinstance(song_type, _UnsetSentinel))
639            and (score.level_index == level_index or isinstance(level_index, _UnsetSentinel))
640        ]

Get scores of the song on that type and level_index.

If song_type or level_index is not provided, it won't be filtered by that attribute.

Arguments:
  • song_id: the ID of the song to get the scores by.
  • song_type: the type of the song to get the scores by, defaults to None.
  • level_index: the level index of the song to get the scores by, defaults to None.
Returns:

A list of scores that match the song ID, type and level index. If no score is found, an empty list will be returned.

def filter(self, **kwargs) -> list[maimai_py.models.Score]:
642    def filter(self, **kwargs) -> list[Score]:
643        """Filter scores by their attributes.
644
645        Make sure the attribute is of the score, and the value is of the same type. All conditions are connected by AND.
646
647        Args:
648            kwargs: the attributes to filter the scores by.
649        Returns:
650            an iterator of scores that match all the conditions, yields no items if no score is found.
651        """
652        return [score for score in self.scores if all(getattr(score, key) == value for key, value in kwargs.items() if value is not None)]

Filter scores by their attributes.

Make sure the attribute is of the score, and the value is of the same type. All conditions are connected by AND.

Arguments:
  • kwargs: the attributes to filter the scores by.
Returns:

an iterator of scores that match all the conditions, yields no items if no score is found.

class MaimaiAreas:
655class MaimaiAreas:
656    _client: "MaimaiClient"
657    _lang: str
658
659    def __init__(self, client: "MaimaiClient") -> None:
660        """@private"""
661        self._client = client
662
663    async def _configure(self, lang: str, provider: Union[IAreaProvider, _UnsetSentinel]) -> "MaimaiAreas":
664        self._lang = lang
665        cache_obj, cache_ttl = self._client._cache, self._client._cache_ttl
666        # Check if the provider is unset, which means we want to access the cache directly.
667        if isinstance(provider, _UnsetSentinel):
668            if await self._client._cache.get("provider", None, namespace=f"areas_{lang}") is not None:
669                return self
670        # Really assign the unset provider to the default one.
671        provider = provider if not isinstance(provider, _UnsetSentinel) else LocalProvider()
672        # Check if the current provider hash is different from the previous one, which means we need to reconfigure.
673        current_provider_hash = provider._hash()
674        previous_provider_hash = await cache_obj.get("provider", "", namespace=f"areas_{lang}")
675        if current_provider_hash != previous_provider_hash:
676            areas = await provider.get_areas(lang, self._client)
677            await asyncio.gather(
678                cache_obj.set("provider", hash(provider), ttl=cache_ttl, namespace=f"areas_{lang}"),  # provider
679                cache_obj.set("ids", [area.id for area in areas.values()], namespace=f"areas_{lang}"),  # ids
680                cache_obj.multi_set(iter((k, v) for k, v in areas.items()), namespace=f"areas_{lang}"),  # areas
681            )
682        return self
683
684    async def get_all(self) -> list[Area]:
685        """All areas as list.
686
687        This method will iterate all areas in the cache. Unless you really need to iterate all areas, you should use `by_id` or `by_name` instead.
688
689        Returns:
690            A list of all areas in the cache, return an empty list if no area is found.
691        """
692        area_ids: Optional[list[int]] = await self._client._cache.get("ids", namespace=f"areas_{self._lang}")
693        assert area_ids is not None, "Areas not found in cache, please call configure() first."
694        return await self._client._multi_get(area_ids, namespace=f"areas_{self._lang}")
695
696    async def get_batch(self, ids: Iterable[str]) -> list[Area]:
697        """Get areas by their IDs.
698
699        Args:
700            ids: the IDs of the areas.
701        Returns:
702            A list of areas if they exist, otherwise return an empty list.
703        """
704        return await self._client._multi_get(ids, namespace=f"areas_{self._lang}")
705
706    async def by_id(self, id: str) -> Optional[Area]:
707        """Get an area by its ID.
708
709        Args:
710            id: the ID of the area.
711        Returns:
712            the area if it exists, otherwise return None.
713        """
714        return await self._client._cache.get(id, namespace=f"areas_{self._lang}")
715
716    async def by_name(self, name: str) -> Optional[Area]:
717        """Get an area by its name, language-sensitive.
718
719        Args:
720            name: the name of the area.
721        Returns:
722            the area if it exists, otherwise return None.
723        """
724        return next((area for area in await self.get_all() if area.name == name), None)
async def get_all(self) -> list[maimai_py.models.Area]:
684    async def get_all(self) -> list[Area]:
685        """All areas as list.
686
687        This method will iterate all areas in the cache. Unless you really need to iterate all areas, you should use `by_id` or `by_name` instead.
688
689        Returns:
690            A list of all areas in the cache, return an empty list if no area is found.
691        """
692        area_ids: Optional[list[int]] = await self._client._cache.get("ids", namespace=f"areas_{self._lang}")
693        assert area_ids is not None, "Areas not found in cache, please call configure() first."
694        return await self._client._multi_get(area_ids, namespace=f"areas_{self._lang}")

All areas as list.

This method will iterate all areas in the cache. Unless you really need to iterate all areas, you should use by_id or by_name instead.

Returns:

A list of all areas in the cache, return an empty list if no area is found.

async def get_batch(self, ids: Iterable[str]) -> list[maimai_py.models.Area]:
696    async def get_batch(self, ids: Iterable[str]) -> list[Area]:
697        """Get areas by their IDs.
698
699        Args:
700            ids: the IDs of the areas.
701        Returns:
702            A list of areas if they exist, otherwise return an empty list.
703        """
704        return await self._client._multi_get(ids, namespace=f"areas_{self._lang}")

Get areas by their IDs.

Arguments:
  • ids: the IDs of the areas.
Returns:

A list of areas if they exist, otherwise return an empty list.

async def by_id(self, id: str) -> Optional[maimai_py.models.Area]:
706    async def by_id(self, id: str) -> Optional[Area]:
707        """Get an area by its ID.
708
709        Args:
710            id: the ID of the area.
711        Returns:
712            the area if it exists, otherwise return None.
713        """
714        return await self._client._cache.get(id, namespace=f"areas_{self._lang}")

Get an area by its ID.

Arguments:
  • id: the ID of the area.
Returns:

the area if it exists, otherwise return None.

async def by_name(self, name: str) -> Optional[maimai_py.models.Area]:
716    async def by_name(self, name: str) -> Optional[Area]:
717        """Get an area by its name, language-sensitive.
718
719        Args:
720            name: the name of the area.
721        Returns:
722            the area if it exists, otherwise return None.
723        """
724        return next((area for area in await self.get_all() if area.name == name), None)

Get an area by its name, language-sensitive.

Arguments:
  • name: the name of the area.
Returns:

the area if it exists, otherwise return None.

class MaimaiClient:
 727class MaimaiClient:
 728    """The main client of maimai.py."""
 729
 730    _client: AsyncClient
 731    _cache: BaseCache
 732    _cache_ttl: int
 733
 734    def __new__(cls, *args, **kwargs):
 735        if hasattr(cls, "_instance"):
 736            warn_message = (
 737                "MaimaiClient is a singleton, args are ignored in this case, due to the singleton nature. "
 738                "If you think this is a mistake, please check MaimaiClientMultithreading. "
 739            )
 740            warnings.warn(warn_message, stacklevel=2)
 741            return cls._instance
 742        orig = super(MaimaiClient, cls)
 743        cls._instance = orig.__new__(cls)
 744        return cls._instance
 745
 746    def __init__(
 747        self,
 748        timeout: float = 20.0,
 749        cache: Union[BaseCache, _UnsetSentinel] = UNSET,
 750        cache_ttl: int = 60 * 60 * 24,
 751        **kwargs,
 752    ) -> None:
 753        """Initialize the maimai.py client.
 754
 755        Args:
 756            timeout: the timeout of the requests, defaults to 20.0.
 757            cache: the cache to use, defaults to `aiocache.SimpleMemoryCache()`.
 758            cache_ttl: the TTL of the cache, defaults to 60 * 60 * 24.
 759            kwargs: other arguments to pass to the `httpx.AsyncClient`.
 760        """
 761        self._client = AsyncClient(timeout=timeout, **kwargs)
 762        self._cache = SimpleMemoryCache() if isinstance(cache, _UnsetSentinel) else cache
 763        self._cache_ttl = cache_ttl
 764
 765    async def _multi_get(self, keys: Iterable[Any], namespace: Optional[str] = None) -> list[Any]:
 766        keys_list = list(keys)
 767        if len(keys_list) != 0:
 768            return await self._cache.multi_get(keys_list, namespace=namespace)
 769        return []
 770
 771    async def songs(
 772        self,
 773        provider: Union[ISongProvider, _UnsetSentinel] = UNSET,
 774        alias_provider: Union[IAliasProvider, None, _UnsetSentinel] = UNSET,
 775        curve_provider: Union[ICurveProvider, None, _UnsetSentinel] = UNSET,
 776    ) -> MaimaiSongs:
 777        """Fetch all maimai songs from the provider.
 778
 779        Available providers: `DivingFishProvider`, `LXNSProvider`.
 780
 781        Available alias providers: `YuzuProvider`, `LXNSProvider`.
 782
 783        Available curve providers: `DivingFishProvider`.
 784
 785        Args:
 786            provider: override the data source to fetch the player from, defaults to `LXNSProvider`.
 787            alias_provider: override the data source to fetch the song aliases from, defaults to `YuzuProvider`.
 788            curve_provider: override the data source to fetch the song curves from, defaults to `None`.
 789        Returns:
 790            A wrapper of the song list, for easier access and filtering.
 791        Raises:
 792            httpx.RequestError: Request failed due to network issues.
 793        """
 794        songs = MaimaiSongs(self)
 795        return await songs._configure(provider, alias_provider, curve_provider)
 796
 797    async def players(
 798        self,
 799        identifier: PlayerIdentifier,
 800        provider: IPlayerProvider = LXNSProvider(),
 801    ) -> Player:
 802        """Fetch player data from the provider.
 803
 804        Available providers: `DivingFishProvider`, `LXNSProvider`, `ArcadeProvider`.
 805
 806        Possible returns: `DivingFishPlayer`, `LXNSPlayer`, `ArcadePlayer`.
 807
 808        Args:
 809            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(username="turou")`.
 810            provider: the data source to fetch the player from, defaults to `LXNSProvider`.
 811        Returns:
 812            The player object of the player, with all the data fetched. Depending on the provider, it may contain different objects that derived from `Player`.
 813        Raises:
 814            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
 815            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
 816            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
 817            httpx.RequestError: Request failed due to network issues.
 818        Raises:
 819            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
 820            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
 821            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
 822        """
 823        return await provider.get_player(identifier, self)
 824
 825    async def scores(
 826        self,
 827        identifier: PlayerIdentifier,
 828        provider: IScoreProvider = LXNSProvider(),
 829    ) -> MaimaiScores:
 830        """Fetch player's ALL scores from the provider.
 831
 832        All scores of the player will be fetched, if you want to fetch only the best scores (for better performance), use `maimai.bests()` instead.
 833
 834        For WechatProvider, PlayerIdentifier must have the `credentials` attribute, we suggest you to use the `maimai.wechat()` method to get the identifier.
 835        Also, PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time.
 836
 837        For ArcadeProvider, PlayerIdentifier must have the `credentials` attribute, which is the player's encrypted userId, can be detrived from `maimai.qrcode()`.
 838        Credentials can be reused, since it won't expire, also, userId is encrypted, can't be used in any other cases outside the maimai.py
 839
 840        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
 841
 842        Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`, `ArcadeProvider`.
 843
 844        Args:
 845            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
 846            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
 847        Returns:
 848            The scores object of the player, with all the data fetched.
 849        Raises:
 850            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
 851            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
 852            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
 853            httpx.RequestError: Request failed due to network issues.
 854        Raises:
 855            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
 856            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
 857            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
 858        """
 859        scores = await provider.get_scores_all(identifier, self)
 860
 861        maimai_scores = MaimaiScores(self)
 862        return await maimai_scores.configure(scores)
 863
 864    async def bests(
 865        self,
 866        identifier: PlayerIdentifier,
 867        provider: IScoreProvider = LXNSProvider(),
 868    ) -> MaimaiScores:
 869        """Fetch player's B50 scores from the provider.
 870
 871        Though MaimaiScores is used, this method will only return the best 50 scores. if you want all scores, please use `maimai.scores()` method instead.
 872
 873        For WechatProvider, PlayerIdentifier must have the `credentials` attribute, we suggest you to use the `maimai.wechat()` method to get the identifier.
 874        Also, PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time.
 875
 876        For ArcadeProvider, PlayerIdentifier must have the `credentials` attribute, which is the player's encrypted userId, can be detrived from `maimai.qrcode()`.
 877        Credentials can be reused, since it won't expire, also, userId is encrypted, can't be used in any other cases outside the maimai.py
 878
 879        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
 880
 881        Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`, `ArcadeProvider`.
 882
 883        Args:
 884            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
 885            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
 886        Returns:
 887            The scores object of the player, with all the data fetched.
 888        Raises:
 889            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
 890            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
 891            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
 892            httpx.RequestError: Request failed due to network issues.
 893        Raises:
 894            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
 895            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
 896            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
 897        """
 898        maimai_scores = MaimaiScores(self)
 899        best_scores = await provider.get_scores_best(identifier, self)
 900        return await maimai_scores.configure(best_scores, b50_only=True)
 901
 902    async def minfo(
 903        self,
 904        song: Union[Song, int, str],
 905        identifier: Optional[PlayerIdentifier],
 906        provider: IScoreProvider = LXNSProvider(),
 907    ) -> Optional[PlayerSong]:
 908        """Fetch player's scores on the specific song.
 909
 910        This method will return all scores of the player on the song.
 911
 912        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
 913
 914        Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`, `ArcadeProvider`.
 915
 916        Args:
 917            song: the song to fetch the scores from, can be a `Song` object, or a song_id (int), or keywords (str).
 918            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
 919            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
 920        Returns:
 921            A wrapper of the song and the scores, with full song model, and matched player scores.
 922            If the identifier is not provided, the song will be returned as is, without scores.
 923            If the song is not found, None will be returned.
 924        Raises:
 925            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
 926            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
 927            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
 928            httpx.RequestError: Request failed due to network issues.
 929        Raises:
 930            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
 931            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
 932            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
 933        """
 934        maimai_songs, scores = await self.songs(), []
 935        if isinstance(song, str):
 936            search_result = await maimai_songs.by_keywords(song)
 937            song = search_result[0] if len(search_result) > 0 else song
 938        if isinstance(song, int):
 939            search_result = await maimai_songs.by_id(song)
 940            song = search_result if search_result is not None else song
 941        if isinstance(song, Song):
 942            extended_scores = []
 943            if identifier is not None:
 944                scores = await provider.get_scores_one(identifier, song, self)
 945                extended_scores = await MaimaiScores._get_extended(scores, maimai_songs)
 946            return PlayerSong(song, extended_scores)
 947
 948    async def regions(self, identifier: PlayerIdentifier, provider: IRegionProvider = ArcadeProvider()) -> list[PlayerRegion]:
 949        """Get the player's regions that they have played.
 950
 951        Args:
 952            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(credentials="encrypted_user_id")`.
 953            provider: the data source to fetch the player from, defaults to `ArcadeProvider`.
 954        Returns:
 955            The list of regions that the player has played.
 956        Raises:
 957            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
 958            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
 959            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
 960        """
 961        return await provider.get_regions(identifier, self)
 962
 963    async def updates(
 964        self,
 965        identifier: PlayerIdentifier,
 966        scores: Iterable[Score],
 967        provider: IScoreUpdateProvider = LXNSProvider(),
 968    ) -> None:
 969        """Update player's scores to the provider.
 970
 971        This method is used to update the player's scores to the provider, usually used for updating scores fetched from other providers.
 972
 973        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
 974
 975        Available providers: `DivingFishProvider`, `LXNSProvider`.
 976
 977        Args:
 978            identifier: the identifier of the player to update, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
 979            scores: the scores to update, usually the scores fetched from other providers.
 980            provider: the data source to update the player scores to, defaults to `LXNSProvider`.
 981        Returns:
 982            Nothing, failures will raise exceptions.
 983        Raises:
 984            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found, or the import token / password is invalid.
 985            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
 986            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
 987            httpx.RequestError: Request failed due to network issues.
 988        """
 989        await provider.update_scores(identifier, scores, self)
 990
 991    async def plates(
 992        self,
 993        identifier: PlayerIdentifier,
 994        plate: str,
 995        provider: IScoreProvider = LXNSProvider(),
 996    ) -> MaimaiPlates:
 997        """Get the plate achievement of the given player and plate.
 998
 999        Available providers: `DivingFishProvider`, `LXNSProvider`, `ArcadeProvider`.
1000
1001        Args:
1002            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
1003            plate: the name of the plate, e.g. "樱将", "真舞舞".
1004            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
1005        Returns:
1006            A wrapper of the plate achievement, with plate information, and matched player scores.
1007        Raises:
1008            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
1009            InvalidPlateError: Provided version or plate is invalid.
1010            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
1011            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
1012            httpx.RequestError: Request failed due to network issues.
1013        Raises:
1014            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
1015            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
1016            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
1017        """
1018        scores = await provider.get_scores_all(identifier, self)
1019        maimai_plates = MaimaiPlates(self)
1020        return await maimai_plates._configure(plate, scores)
1021
1022    async def identifiers(
1023        self,
1024        code: Union[str, dict[str, str]],
1025        provider: Union[IPlayerIdentifierProvider, _UnsetSentinel] = UNSET,
1026    ) -> PlayerIdentifier:
1027        """Get the player identifier from the provider.
1028
1029        This method is combination of `maimai.wechat()` and `maimai.qrcode()`, which will return the player identifier of the player.
1030
1031        For WechatProvider, code should be a dictionary with `r`, `t`, `code`, and `state` keys, or a string that contains the URL parameters.
1032
1033        For ArcadeProvider, code should be a string that begins with `SGWCMAID`, which is the QR code of the player.
1034
1035        Available providers: `WechatProvider`, `ArcadeProvider`.
1036
1037        Args:
1038            code: the code to get the player identifier, can be a string or a dictionary with `r`, `t`, `code`, and `state` keys.
1039            provider: override the default provider, defaults to `ArcadeProvider`.
1040        Returns:
1041            The player identifier of the player.
1042        Raises:
1043            InvalidWechatTokenError: Wechat token is expired, please re-authorize.
1044            AimeServerError: Maimai Aime server error, may be invalid QR code or QR code has expired.
1045            httpx.RequestError: Request failed due to network issues.
1046        """
1047        if isinstance(provider, _UnsetSentinel):
1048            provider = ArcadeProvider()
1049        return await provider.get_identifier(code, self)
1050
1051    async def items(self, item: Type[PlayerItemType], provider: Union[IItemListProvider, _UnsetSentinel] = UNSET) -> MaimaiItems[PlayerItemType]:
1052        """Fetch maimai player items from the cache default provider.
1053
1054        Available items: `PlayerIcon`, `PlayerNamePlate`, `PlayerFrame`, `PlayerTrophy`, `PlayerChara`, `PlayerPartner`.
1055
1056        Args:
1057            item: the item type to fetch, e.g. `PlayerIcon`.
1058            provider: override the default item list provider, defaults to `LXNSProvider` and `LocalProvider`.
1059        Returns:
1060            A wrapper of the item list, for easier access and filtering.
1061        Raises:
1062            FileNotFoundError: The item file is not found.
1063            httpx.RequestError: Request failed due to network issues.
1064        """
1065        maimai_items = MaimaiItems[PlayerItemType](self, item._namespace())
1066        return await maimai_items._configure(provider)
1067
1068    async def areas(self, lang: Literal["ja", "zh"] = "ja", provider: IAreaProvider = LocalProvider()) -> MaimaiAreas:
1069        """Fetch maimai areas from the provider.
1070
1071        Available providers: `LocalProvider`.
1072
1073        Args:
1074            lang: the language of the area to fetch, available languages: `ja`, `zh`.
1075            provider: override the default area provider, defaults to `ArcadeProvider`.
1076        Returns:
1077            A wrapper of the area list, for easier access and filtering.
1078        Raises:
1079            FileNotFoundError: The area file is not found.
1080        """
1081        maimai_areas = MaimaiAreas(self)
1082        return await maimai_areas._configure(lang, provider)
1083
1084    async def wechat(
1085        self,
1086        r: Optional[str] = None,
1087        t: Optional[str] = None,
1088        code: Optional[str] = None,
1089        state: Optional[str] = None,
1090    ) -> Union[str, PlayerIdentifier]:
1091        """Get the player identifier from the Wahlap Wechat OffiAccount.
1092
1093        Call the method with no parameters to get the URL, then redirect the user to the URL with your mitmproxy enabled.
1094
1095        Your mitmproxy should intercept the response from tgk-wcaime.wahlap.com, then call the method with the parameters from the intercepted response.
1096
1097        With the parameters from specific user's response, the method will return the user's player identifier.
1098
1099        Never cache or store the player identifier, as the cookies may expire at any time.
1100
1101        Args:
1102            r: the r parameter from the request, defaults to None.
1103            t: the t parameter from the request, defaults to None.
1104            code: the code parameter from the request, defaults to None.
1105            state: the state parameter from the request, defaults to None.
1106        Returns:
1107            The player identifier if all parameters are provided, otherwise return the URL to get the identifier.
1108        Raises:
1109            WechatTokenExpiredError: Wechat token is expired, please re-authorize.
1110            httpx.RequestError: Request failed due to network issues.
1111        """
1112        if r is None or t is None or code is None or state is None:
1113            resp = await self._client.get("https://tgk-wcaime.wahlap.com/wc_auth/oauth/authorize/maimai-dx")
1114            return resp.headers["location"].replace("redirect_uri=https", "redirect_uri=http")
1115        return await WechatProvider().get_identifier({"r": r, "t": t, "code": code, "state": state}, self)
1116
1117    async def qrcode(self, qrcode: str, http_proxy: Optional[str] = None) -> PlayerIdentifier:
1118        """Get the player identifier from the Wahlap QR code.
1119
1120        Player identifier is the encrypted userId, can't be used in any other cases outside the maimai.py.
1121
1122        Args:
1123            qrcode: the QR code of the player, should begin with SGWCMAID.
1124            http_proxy: the http proxy to use for the request, defaults to None.
1125        Returns:
1126            The player identifier of the player.
1127        Raises:
1128            AimeServerError: Maimai Aime server error, may be invalid QR code or QR code has expired.
1129        """
1130        provider = ArcadeProvider(http_proxy=http_proxy)
1131        return await provider.get_identifier(qrcode, self)
1132
1133    async def updates_chain(
1134        self,
1135        source: list[tuple[IScoreProvider, Optional[PlayerIdentifier], dict[str, Any]]],
1136        target: list[tuple[IScoreUpdateProvider, Optional[PlayerIdentifier], dict[str, Any]]],
1137        source_mode: Literal["fallback", "parallel"] = "fallback",
1138        target_mode: Literal["fallback", "parallel"] = "parallel",
1139        source_callback: Optional[Callable[[MaimaiScores, Optional[BaseException], dict[str, Any]], None]] = None,
1140        target_callback: Optional[Callable[[MaimaiScores, Optional[BaseException], dict[str, Any]], None]] = None,
1141    ) -> None:
1142        """Chain updates from source providers to target providers.
1143
1144        This method will fetch scores from the source providers, merge them, and then update the target providers with the merged scores.
1145
1146        The dict in source and target tuples can contain any additional context that will be passed to the callbacks.
1147
1148        Args:
1149            source: a list of tuples, each containing a source provider, an optional player identifier, and additional context.
1150                If the identifier is None, the provider will be ignored.
1151            target: a list of tuples, each containing a target provider, an optional player identifier, and additional context.
1152                If the identifier is None, the provider will be ignored.
1153            source_mode: how to handle source tasks, either "fallback" (default) or "parallel".
1154                In "fallback" mode, only the first successful source pair will be scheduled.
1155                In "parallel" mode, all source pairs will be scheduled.
1156            target_mode: how to handle target tasks, either "fallback" or "parallel" (default).
1157                In "fallback" mode, only the first successful target pair will be scheduled.
1158                In "parallel" mode, all target pairs will be scheduled.
1159            source_callback: an optional callback function that will be called with the source provider,
1160                callback with provider, fetched scores and any exception that occurred during fetching.
1161            target_callback: an optional callback function that will be called with the target provider,
1162                callback with provider, merged scores and any exception that occurred during updating.
1163        Returns:
1164            Nothing, failures will notify by callbacks.
1165        """
1166        source_tasks, target_tasks = [], []
1167
1168        # Fetch scores from the source providers.
1169        for sp, ident, kwargs in source:
1170            if ident is not None:
1171                if source_mode == "parallel" or (source_mode == "fallback" and len(source_tasks) == 0):
1172                    source_task = asyncio.create_task(self.scores(ident, sp))
1173                    if source_callback is not None:
1174                        source_task.add_done_callback(lambda t, k=kwargs: source_callback(t.result(), t.exception(), k))
1175                    source_tasks.append(source_task)
1176        source_gather_results = await asyncio.gather(*source_tasks, return_exceptions=True)
1177        maimai_scores_list = [result for result in source_gather_results if isinstance(result, MaimaiScores)]
1178
1179        # Merge scores from all maimai_scores instances.
1180        scores_unique: dict[str, Score] = {}
1181        for maimai_scores in maimai_scores_list:
1182            for score in maimai_scores.scores:
1183                score_key = f"{score.id} {score.type} {score.level_index}"
1184                scores_unique[score_key] = score._join(scores_unique.get(score_key, None))
1185        merged_scores = list(scores_unique.values())
1186        merged_maimai_scores = await MaimaiScores(self).configure(list(scores_unique.values()))
1187
1188        # Update scores to the target providers.
1189        for tp, ident, kwargs in target:
1190            if ident is not None:
1191                if target_mode == "parallel" or (target_mode == "fallback" and len(target_tasks) == 0):
1192                    target_task = asyncio.create_task(self.updates(ident, merged_scores, tp))
1193                    if target_callback is not None:
1194                        target_task.add_done_callback(lambda t, k=kwargs: target_callback(merged_maimai_scores, t.exception(), k))
1195                    target_tasks.append(target_task)
1196        await asyncio.gather(*target_tasks, return_exceptions=True)

The main client of maimai.py.

MaimaiClient( timeout: float = 20.0, cache: Union[aiocache.base.BaseCache, maimai_py.utils.sentinel._UnsetSentinel] = Unset, cache_ttl: int = 86400, **kwargs)
746    def __init__(
747        self,
748        timeout: float = 20.0,
749        cache: Union[BaseCache, _UnsetSentinel] = UNSET,
750        cache_ttl: int = 60 * 60 * 24,
751        **kwargs,
752    ) -> None:
753        """Initialize the maimai.py client.
754
755        Args:
756            timeout: the timeout of the requests, defaults to 20.0.
757            cache: the cache to use, defaults to `aiocache.SimpleMemoryCache()`.
758            cache_ttl: the TTL of the cache, defaults to 60 * 60 * 24.
759            kwargs: other arguments to pass to the `httpx.AsyncClient`.
760        """
761        self._client = AsyncClient(timeout=timeout, **kwargs)
762        self._cache = SimpleMemoryCache() if isinstance(cache, _UnsetSentinel) else cache
763        self._cache_ttl = cache_ttl

Initialize the maimai.py client.

Arguments:
  • timeout: the timeout of the requests, defaults to 20.0.
  • cache: the cache to use, defaults to aiocache.SimpleMemoryCache().
  • cache_ttl: the TTL of the cache, defaults to 60 * 60 * 24.
  • kwargs: other arguments to pass to the httpx.AsyncClient.
async def songs( self, provider: Union[maimai_py.providers.ISongProvider, maimai_py.utils.sentinel._UnsetSentinel] = Unset, alias_provider: Union[maimai_py.providers.IAliasProvider, NoneType, maimai_py.utils.sentinel._UnsetSentinel] = Unset, curve_provider: Union[maimai_py.providers.ICurveProvider, NoneType, maimai_py.utils.sentinel._UnsetSentinel] = Unset) -> MaimaiSongs:
771    async def songs(
772        self,
773        provider: Union[ISongProvider, _UnsetSentinel] = UNSET,
774        alias_provider: Union[IAliasProvider, None, _UnsetSentinel] = UNSET,
775        curve_provider: Union[ICurveProvider, None, _UnsetSentinel] = UNSET,
776    ) -> MaimaiSongs:
777        """Fetch all maimai songs from the provider.
778
779        Available providers: `DivingFishProvider`, `LXNSProvider`.
780
781        Available alias providers: `YuzuProvider`, `LXNSProvider`.
782
783        Available curve providers: `DivingFishProvider`.
784
785        Args:
786            provider: override the data source to fetch the player from, defaults to `LXNSProvider`.
787            alias_provider: override the data source to fetch the song aliases from, defaults to `YuzuProvider`.
788            curve_provider: override the data source to fetch the song curves from, defaults to `None`.
789        Returns:
790            A wrapper of the song list, for easier access and filtering.
791        Raises:
792            httpx.RequestError: Request failed due to network issues.
793        """
794        songs = MaimaiSongs(self)
795        return await songs._configure(provider, alias_provider, curve_provider)

Fetch all maimai songs from the provider.

Available providers: DivingFishProvider, LXNSProvider.

Available alias providers: YuzuProvider, LXNSProvider.

Available curve providers: DivingFishProvider.

Arguments:
  • provider: override the data source to fetch the player from, defaults to LXNSProvider.
  • alias_provider: override the data source to fetch the song aliases from, defaults to YuzuProvider.
  • curve_provider: override the data source to fetch the song curves from, defaults to None.
Returns:

A wrapper of the song list, for easier access and filtering.

Raises:
  • httpx.RequestError: Request failed due to network issues.
797    async def players(
798        self,
799        identifier: PlayerIdentifier,
800        provider: IPlayerProvider = LXNSProvider(),
801    ) -> Player:
802        """Fetch player data from the provider.
803
804        Available providers: `DivingFishProvider`, `LXNSProvider`, `ArcadeProvider`.
805
806        Possible returns: `DivingFishPlayer`, `LXNSPlayer`, `ArcadePlayer`.
807
808        Args:
809            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(username="turou")`.
810            provider: the data source to fetch the player from, defaults to `LXNSProvider`.
811        Returns:
812            The player object of the player, with all the data fetched. Depending on the provider, it may contain different objects that derived from `Player`.
813        Raises:
814            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
815            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
816            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
817            httpx.RequestError: Request failed due to network issues.
818        Raises:
819            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
820            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
821            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
822        """
823        return await provider.get_player(identifier, self)

Fetch player data from the provider.

Available providers: DivingFishProvider, LXNSProvider, ArcadeProvider.

Possible returns: DivingFishPlayer, LXNSPlayer, ArcadePlayer.

Arguments:
  • identifier: the identifier of the player to fetch, e.g. PlayerIdentifier(username="turou").
  • provider: the data source to fetch the player from, defaults to LXNSProvider.
Returns:

The player object of the player, with all the data fetched. Depending on the provider, it may contain different objects that derived from Player.

Raises:
  • InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
  • InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
  • PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
  • httpx.RequestError: Request failed due to network issues.
Raises:
  • TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
  • TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
  • ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
async def scores( self, identifier: maimai_py.models.PlayerIdentifier, provider: maimai_py.providers.IScoreProvider = <maimai_py.providers.LXNSProvider object>) -> MaimaiScores:
825    async def scores(
826        self,
827        identifier: PlayerIdentifier,
828        provider: IScoreProvider = LXNSProvider(),
829    ) -> MaimaiScores:
830        """Fetch player's ALL scores from the provider.
831
832        All scores of the player will be fetched, if you want to fetch only the best scores (for better performance), use `maimai.bests()` instead.
833
834        For WechatProvider, PlayerIdentifier must have the `credentials` attribute, we suggest you to use the `maimai.wechat()` method to get the identifier.
835        Also, PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time.
836
837        For ArcadeProvider, PlayerIdentifier must have the `credentials` attribute, which is the player's encrypted userId, can be detrived from `maimai.qrcode()`.
838        Credentials can be reused, since it won't expire, also, userId is encrypted, can't be used in any other cases outside the maimai.py
839
840        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
841
842        Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`, `ArcadeProvider`.
843
844        Args:
845            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
846            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
847        Returns:
848            The scores object of the player, with all the data fetched.
849        Raises:
850            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
851            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
852            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
853            httpx.RequestError: Request failed due to network issues.
854        Raises:
855            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
856            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
857            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
858        """
859        scores = await provider.get_scores_all(identifier, self)
860
861        maimai_scores = MaimaiScores(self)
862        return await maimai_scores.configure(scores)

Fetch player's ALL scores from the provider.

All scores of the player will be fetched, if you want to fetch only the best scores (for better performance), use maimai.bests() instead.

For WechatProvider, PlayerIdentifier must have the credentials attribute, we suggest you to use the maimai.wechat() method to get the identifier. Also, PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time.

For ArcadeProvider, PlayerIdentifier must have the credentials attribute, which is the player's encrypted userId, can be detrived from maimai.qrcode(). Credentials can be reused, since it won't expire, also, userId is encrypted, can't be used in any other cases outside the maimai.py

For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.

Available providers: DivingFishProvider, LXNSProvider, WechatProvider, ArcadeProvider.

Arguments:
  • identifier: the identifier of the player to fetch, e.g. PlayerIdentifier(friend_code=664994421382429).
  • provider: the data source to fetch the player and scores from, defaults to LXNSProvider.
Returns:

The scores object of the player, with all the data fetched.

Raises:
  • InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
  • InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
  • PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
  • httpx.RequestError: Request failed due to network issues.
Raises:
  • TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
  • TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
  • ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
async def bests( self, identifier: maimai_py.models.PlayerIdentifier, provider: maimai_py.providers.IScoreProvider = <maimai_py.providers.LXNSProvider object>) -> MaimaiScores:
864    async def bests(
865        self,
866        identifier: PlayerIdentifier,
867        provider: IScoreProvider = LXNSProvider(),
868    ) -> MaimaiScores:
869        """Fetch player's B50 scores from the provider.
870
871        Though MaimaiScores is used, this method will only return the best 50 scores. if you want all scores, please use `maimai.scores()` method instead.
872
873        For WechatProvider, PlayerIdentifier must have the `credentials` attribute, we suggest you to use the `maimai.wechat()` method to get the identifier.
874        Also, PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time.
875
876        For ArcadeProvider, PlayerIdentifier must have the `credentials` attribute, which is the player's encrypted userId, can be detrived from `maimai.qrcode()`.
877        Credentials can be reused, since it won't expire, also, userId is encrypted, can't be used in any other cases outside the maimai.py
878
879        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
880
881        Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`, `ArcadeProvider`.
882
883        Args:
884            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
885            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
886        Returns:
887            The scores object of the player, with all the data fetched.
888        Raises:
889            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
890            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
891            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
892            httpx.RequestError: Request failed due to network issues.
893        Raises:
894            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
895            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
896            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
897        """
898        maimai_scores = MaimaiScores(self)
899        best_scores = await provider.get_scores_best(identifier, self)
900        return await maimai_scores.configure(best_scores, b50_only=True)

Fetch player's B50 scores from the provider.

Though MaimaiScores is used, this method will only return the best 50 scores. if you want all scores, please use maimai.scores() method instead.

For WechatProvider, PlayerIdentifier must have the credentials attribute, we suggest you to use the maimai.wechat() method to get the identifier. Also, PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time.

For ArcadeProvider, PlayerIdentifier must have the credentials attribute, which is the player's encrypted userId, can be detrived from maimai.qrcode(). Credentials can be reused, since it won't expire, also, userId is encrypted, can't be used in any other cases outside the maimai.py

For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.

Available providers: DivingFishProvider, LXNSProvider, WechatProvider, ArcadeProvider.

Arguments:
  • identifier: the identifier of the player to fetch, e.g. PlayerIdentifier(friend_code=664994421382429).
  • provider: the data source to fetch the player and scores from, defaults to LXNSProvider.
Returns:

The scores object of the player, with all the data fetched.

Raises:
  • InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
  • InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
  • PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
  • httpx.RequestError: Request failed due to network issues.
Raises:
  • TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
  • TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
  • ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
async def minfo( self, song: Union[maimai_py.models.Song, int, str], identifier: Optional[maimai_py.models.PlayerIdentifier], provider: maimai_py.providers.IScoreProvider = <maimai_py.providers.LXNSProvider object>) -> Optional[maimai_py.models.PlayerSong]:
902    async def minfo(
903        self,
904        song: Union[Song, int, str],
905        identifier: Optional[PlayerIdentifier],
906        provider: IScoreProvider = LXNSProvider(),
907    ) -> Optional[PlayerSong]:
908        """Fetch player's scores on the specific song.
909
910        This method will return all scores of the player on the song.
911
912        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
913
914        Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`, `ArcadeProvider`.
915
916        Args:
917            song: the song to fetch the scores from, can be a `Song` object, or a song_id (int), or keywords (str).
918            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
919            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
920        Returns:
921            A wrapper of the song and the scores, with full song model, and matched player scores.
922            If the identifier is not provided, the song will be returned as is, without scores.
923            If the song is not found, None will be returned.
924        Raises:
925            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
926            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
927            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
928            httpx.RequestError: Request failed due to network issues.
929        Raises:
930            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
931            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
932            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
933        """
934        maimai_songs, scores = await self.songs(), []
935        if isinstance(song, str):
936            search_result = await maimai_songs.by_keywords(song)
937            song = search_result[0] if len(search_result) > 0 else song
938        if isinstance(song, int):
939            search_result = await maimai_songs.by_id(song)
940            song = search_result if search_result is not None else song
941        if isinstance(song, Song):
942            extended_scores = []
943            if identifier is not None:
944                scores = await provider.get_scores_one(identifier, song, self)
945                extended_scores = await MaimaiScores._get_extended(scores, maimai_songs)
946            return PlayerSong(song, extended_scores)

Fetch player's scores on the specific song.

This method will return all scores of the player on the song.

For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.

Available providers: DivingFishProvider, LXNSProvider, WechatProvider, ArcadeProvider.

Arguments:
  • song: the song to fetch the scores from, can be a Song object, or a song_id (int), or keywords (str).
  • identifier: the identifier of the player to fetch, e.g. PlayerIdentifier(friend_code=664994421382429).
  • provider: the data source to fetch the player and scores from, defaults to LXNSProvider.
Returns:

A wrapper of the song and the scores, with full song model, and matched player scores. If the identifier is not provided, the song will be returned as is, without scores. If the song is not found, None will be returned.

Raises:
  • InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
  • InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
  • PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
  • httpx.RequestError: Request failed due to network issues.
Raises:
  • TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
  • TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
  • ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
948    async def regions(self, identifier: PlayerIdentifier, provider: IRegionProvider = ArcadeProvider()) -> list[PlayerRegion]:
949        """Get the player's regions that they have played.
950
951        Args:
952            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(credentials="encrypted_user_id")`.
953            provider: the data source to fetch the player from, defaults to `ArcadeProvider`.
954        Returns:
955            The list of regions that the player has played.
956        Raises:
957            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
958            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
959            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
960        """
961        return await provider.get_regions(identifier, self)

Get the player's regions that they have played.

Arguments:
  • identifier: the identifier of the player to fetch, e.g. PlayerIdentifier(credentials="encrypted_user_id").
  • provider: the data source to fetch the player from, defaults to ArcadeProvider.
Returns:

The list of regions that the player has played.

Raises:
  • TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
  • TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
  • ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
async def updates( self, identifier: maimai_py.models.PlayerIdentifier, scores: Iterable[maimai_py.models.Score], provider: maimai_py.providers.IScoreUpdateProvider = <maimai_py.providers.LXNSProvider object>) -> None:
963    async def updates(
964        self,
965        identifier: PlayerIdentifier,
966        scores: Iterable[Score],
967        provider: IScoreUpdateProvider = LXNSProvider(),
968    ) -> None:
969        """Update player's scores to the provider.
970
971        This method is used to update the player's scores to the provider, usually used for updating scores fetched from other providers.
972
973        For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.
974
975        Available providers: `DivingFishProvider`, `LXNSProvider`.
976
977        Args:
978            identifier: the identifier of the player to update, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
979            scores: the scores to update, usually the scores fetched from other providers.
980            provider: the data source to update the player scores to, defaults to `LXNSProvider`.
981        Returns:
982            Nothing, failures will raise exceptions.
983        Raises:
984            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found, or the import token / password is invalid.
985            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
986            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
987            httpx.RequestError: Request failed due to network issues.
988        """
989        await provider.update_scores(identifier, scores, self)

Update player's scores to the provider.

This method is used to update the player's scores to the provider, usually used for updating scores fetched from other providers.

For more information about the PlayerIdentifier of providers, please refer to the documentation of each provider.

Available providers: DivingFishProvider, LXNSProvider.

Arguments:
  • identifier: the identifier of the player to update, e.g. PlayerIdentifier(friend_code=664994421382429).
  • scores: the scores to update, usually the scores fetched from other providers.
  • provider: the data source to update the player scores to, defaults to LXNSProvider.
Returns:

Nothing, failures will raise exceptions.

Raises:
  • InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found, or the import token / password is invalid.
  • InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
  • PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
  • httpx.RequestError: Request failed due to network issues.
async def plates( self, identifier: maimai_py.models.PlayerIdentifier, plate: str, provider: maimai_py.providers.IScoreProvider = <maimai_py.providers.LXNSProvider object>) -> MaimaiPlates:
 991    async def plates(
 992        self,
 993        identifier: PlayerIdentifier,
 994        plate: str,
 995        provider: IScoreProvider = LXNSProvider(),
 996    ) -> MaimaiPlates:
 997        """Get the plate achievement of the given player and plate.
 998
 999        Available providers: `DivingFishProvider`, `LXNSProvider`, `ArcadeProvider`.
1000
1001        Args:
1002            identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`.
1003            plate: the name of the plate, e.g. "樱将", "真舞舞".
1004            provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`.
1005        Returns:
1006            A wrapper of the plate achievement, with plate information, and matched player scores.
1007        Raises:
1008            InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
1009            InvalidPlateError: Provided version or plate is invalid.
1010            InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
1011            PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
1012            httpx.RequestError: Request failed due to network issues.
1013        Raises:
1014            TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
1015            TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
1016            ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
1017        """
1018        scores = await provider.get_scores_all(identifier, self)
1019        maimai_plates = MaimaiPlates(self)
1020        return await maimai_plates._configure(plate, scores)

Get the plate achievement of the given player and plate.

Available providers: DivingFishProvider, LXNSProvider, ArcadeProvider.

Arguments:
  • identifier: the identifier of the player to fetch, e.g. PlayerIdentifier(friend_code=664994421382429).
  • plate: the name of the plate, e.g. "樱将", "真舞舞".
  • provider: the data source to fetch the player and scores from, defaults to LXNSProvider.
Returns:

A wrapper of the plate achievement, with plate information, and matched player scores.

Raises:
  • InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
  • InvalidPlateError: Provided version or plate is invalid.
  • InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
  • PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
  • httpx.RequestError: Request failed due to network issues.
Raises:
  • TitleServerNetworkError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
  • TitleServerBlockedError: Only for ArcadeProvider, maimai title server blocked the request, possibly due to ip filtered.
  • ArcadeIdentifierError: Only for ArcadeProvider, maimai user id is invalid, or the user is not found.
async def identifiers( self, code: Union[str, dict[str, str]], provider: Union[maimai_py.providers.IPlayerIdentifierProvider, maimai_py.utils.sentinel._UnsetSentinel] = Unset) -> maimai_py.models.PlayerIdentifier:
1022    async def identifiers(
1023        self,
1024        code: Union[str, dict[str, str]],
1025        provider: Union[IPlayerIdentifierProvider, _UnsetSentinel] = UNSET,
1026    ) -> PlayerIdentifier:
1027        """Get the player identifier from the provider.
1028
1029        This method is combination of `maimai.wechat()` and `maimai.qrcode()`, which will return the player identifier of the player.
1030
1031        For WechatProvider, code should be a dictionary with `r`, `t`, `code`, and `state` keys, or a string that contains the URL parameters.
1032
1033        For ArcadeProvider, code should be a string that begins with `SGWCMAID`, which is the QR code of the player.
1034
1035        Available providers: `WechatProvider`, `ArcadeProvider`.
1036
1037        Args:
1038            code: the code to get the player identifier, can be a string or a dictionary with `r`, `t`, `code`, and `state` keys.
1039            provider: override the default provider, defaults to `ArcadeProvider`.
1040        Returns:
1041            The player identifier of the player.
1042        Raises:
1043            InvalidWechatTokenError: Wechat token is expired, please re-authorize.
1044            AimeServerError: Maimai Aime server error, may be invalid QR code or QR code has expired.
1045            httpx.RequestError: Request failed due to network issues.
1046        """
1047        if isinstance(provider, _UnsetSentinel):
1048            provider = ArcadeProvider()
1049        return await provider.get_identifier(code, self)

Get the player identifier from the provider.

This method is combination of maimai.wechat() and maimai.qrcode(), which will return the player identifier of the player.

For WechatProvider, code should be a dictionary with r, t, code, and state keys, or a string that contains the URL parameters.

For ArcadeProvider, code should be a string that begins with SGWCMAID, which is the QR code of the player.

Available providers: WechatProvider, ArcadeProvider.

Arguments:
  • code: the code to get the player identifier, can be a string or a dictionary with r, t, code, and state keys.
  • provider: override the default provider, defaults to ArcadeProvider.
Returns:

The player identifier of the player.

Raises:
  • InvalidWechatTokenError: Wechat token is expired, please re-authorize.
  • AimeServerError: Maimai Aime server error, may be invalid QR code or QR code has expired.
  • httpx.RequestError: Request failed due to network issues.
async def items( self, item: Type[~PlayerItemType], provider: Union[maimai_py.providers.IItemListProvider, maimai_py.utils.sentinel._UnsetSentinel] = Unset) -> MaimaiItems[~PlayerItemType]:
1051    async def items(self, item: Type[PlayerItemType], provider: Union[IItemListProvider, _UnsetSentinel] = UNSET) -> MaimaiItems[PlayerItemType]:
1052        """Fetch maimai player items from the cache default provider.
1053
1054        Available items: `PlayerIcon`, `PlayerNamePlate`, `PlayerFrame`, `PlayerTrophy`, `PlayerChara`, `PlayerPartner`.
1055
1056        Args:
1057            item: the item type to fetch, e.g. `PlayerIcon`.
1058            provider: override the default item list provider, defaults to `LXNSProvider` and `LocalProvider`.
1059        Returns:
1060            A wrapper of the item list, for easier access and filtering.
1061        Raises:
1062            FileNotFoundError: The item file is not found.
1063            httpx.RequestError: Request failed due to network issues.
1064        """
1065        maimai_items = MaimaiItems[PlayerItemType](self, item._namespace())
1066        return await maimai_items._configure(provider)

Fetch maimai player items from the cache default provider.

Available items: PlayerIcon, PlayerNamePlate, PlayerFrame, PlayerTrophy, PlayerChara, PlayerPartner.

Arguments:
  • item: the item type to fetch, e.g. PlayerIcon.
  • provider: override the default item list provider, defaults to LXNSProvider and LocalProvider.
Returns:

A wrapper of the item list, for easier access and filtering.

Raises:
  • FileNotFoundError: The item file is not found.
  • httpx.RequestError: Request failed due to network issues.
async def areas( self, lang: Literal['ja', 'zh'] = 'ja', provider: maimai_py.providers.IAreaProvider = <maimai_py.providers.LocalProvider object>) -> MaimaiAreas:
1068    async def areas(self, lang: Literal["ja", "zh"] = "ja", provider: IAreaProvider = LocalProvider()) -> MaimaiAreas:
1069        """Fetch maimai areas from the provider.
1070
1071        Available providers: `LocalProvider`.
1072
1073        Args:
1074            lang: the language of the area to fetch, available languages: `ja`, `zh`.
1075            provider: override the default area provider, defaults to `ArcadeProvider`.
1076        Returns:
1077            A wrapper of the area list, for easier access and filtering.
1078        Raises:
1079            FileNotFoundError: The area file is not found.
1080        """
1081        maimai_areas = MaimaiAreas(self)
1082        return await maimai_areas._configure(lang, provider)

Fetch maimai areas from the provider.

Available providers: LocalProvider.

Arguments:
  • lang: the language of the area to fetch, available languages: ja, zh.
  • provider: override the default area provider, defaults to ArcadeProvider.
Returns:

A wrapper of the area list, for easier access and filtering.

Raises:
  • FileNotFoundError: The area file is not found.
async def wechat( self, r: Optional[str] = None, t: Optional[str] = None, code: Optional[str] = None, state: Optional[str] = None) -> Union[str, maimai_py.models.PlayerIdentifier]:
1084    async def wechat(
1085        self,
1086        r: Optional[str] = None,
1087        t: Optional[str] = None,
1088        code: Optional[str] = None,
1089        state: Optional[str] = None,
1090    ) -> Union[str, PlayerIdentifier]:
1091        """Get the player identifier from the Wahlap Wechat OffiAccount.
1092
1093        Call the method with no parameters to get the URL, then redirect the user to the URL with your mitmproxy enabled.
1094
1095        Your mitmproxy should intercept the response from tgk-wcaime.wahlap.com, then call the method with the parameters from the intercepted response.
1096
1097        With the parameters from specific user's response, the method will return the user's player identifier.
1098
1099        Never cache or store the player identifier, as the cookies may expire at any time.
1100
1101        Args:
1102            r: the r parameter from the request, defaults to None.
1103            t: the t parameter from the request, defaults to None.
1104            code: the code parameter from the request, defaults to None.
1105            state: the state parameter from the request, defaults to None.
1106        Returns:
1107            The player identifier if all parameters are provided, otherwise return the URL to get the identifier.
1108        Raises:
1109            WechatTokenExpiredError: Wechat token is expired, please re-authorize.
1110            httpx.RequestError: Request failed due to network issues.
1111        """
1112        if r is None or t is None or code is None or state is None:
1113            resp = await self._client.get("https://tgk-wcaime.wahlap.com/wc_auth/oauth/authorize/maimai-dx")
1114            return resp.headers["location"].replace("redirect_uri=https", "redirect_uri=http")
1115        return await WechatProvider().get_identifier({"r": r, "t": t, "code": code, "state": state}, self)

Get the player identifier from the Wahlap Wechat OffiAccount.

Call the method with no parameters to get the URL, then redirect the user to the URL with your mitmproxy enabled.

Your mitmproxy should intercept the response from tgk-wcaime.wahlap.com, then call the method with the parameters from the intercepted response.

With the parameters from specific user's response, the method will return the user's player identifier.

Never cache or store the player identifier, as the cookies may expire at any time.

Arguments:
  • r: the r parameter from the request, defaults to None.
  • t: the t parameter from the request, defaults to None.
  • code: the code parameter from the request, defaults to None.
  • state: the state parameter from the request, defaults to None.
Returns:

The player identifier if all parameters are provided, otherwise return the URL to get the identifier.

Raises:
  • WechatTokenExpiredError: Wechat token is expired, please re-authorize.
  • httpx.RequestError: Request failed due to network issues.
async def qrcode( self, qrcode: str, http_proxy: Optional[str] = None) -> maimai_py.models.PlayerIdentifier:
1117    async def qrcode(self, qrcode: str, http_proxy: Optional[str] = None) -> PlayerIdentifier:
1118        """Get the player identifier from the Wahlap QR code.
1119
1120        Player identifier is the encrypted userId, can't be used in any other cases outside the maimai.py.
1121
1122        Args:
1123            qrcode: the QR code of the player, should begin with SGWCMAID.
1124            http_proxy: the http proxy to use for the request, defaults to None.
1125        Returns:
1126            The player identifier of the player.
1127        Raises:
1128            AimeServerError: Maimai Aime server error, may be invalid QR code or QR code has expired.
1129        """
1130        provider = ArcadeProvider(http_proxy=http_proxy)
1131        return await provider.get_identifier(qrcode, self)

Get the player identifier from the Wahlap QR code.

Player identifier is the encrypted userId, can't be used in any other cases outside the maimai.py.

Arguments:
  • qrcode: the QR code of the player, should begin with SGWCMAID.
  • http_proxy: the http proxy to use for the request, defaults to None.
Returns:

The player identifier of the player.

Raises:
  • AimeServerError: Maimai Aime server error, may be invalid QR code or QR code has expired.
async def updates_chain( self, source: list[tuple[maimai_py.providers.IScoreProvider, typing.Optional[maimai_py.models.PlayerIdentifier], dict[str, typing.Any]]], target: list[tuple[maimai_py.providers.IScoreUpdateProvider, typing.Optional[maimai_py.models.PlayerIdentifier], dict[str, typing.Any]]], source_mode: Literal['fallback', 'parallel'] = 'fallback', target_mode: Literal['fallback', 'parallel'] = 'parallel', source_callback: Optional[Callable[[MaimaiScores, Optional[BaseException], dict[str, Any]], NoneType]] = None, target_callback: Optional[Callable[[MaimaiScores, Optional[BaseException], dict[str, Any]], NoneType]] = None) -> None:
1133    async def updates_chain(
1134        self,
1135        source: list[tuple[IScoreProvider, Optional[PlayerIdentifier], dict[str, Any]]],
1136        target: list[tuple[IScoreUpdateProvider, Optional[PlayerIdentifier], dict[str, Any]]],
1137        source_mode: Literal["fallback", "parallel"] = "fallback",
1138        target_mode: Literal["fallback", "parallel"] = "parallel",
1139        source_callback: Optional[Callable[[MaimaiScores, Optional[BaseException], dict[str, Any]], None]] = None,
1140        target_callback: Optional[Callable[[MaimaiScores, Optional[BaseException], dict[str, Any]], None]] = None,
1141    ) -> None:
1142        """Chain updates from source providers to target providers.
1143
1144        This method will fetch scores from the source providers, merge them, and then update the target providers with the merged scores.
1145
1146        The dict in source and target tuples can contain any additional context that will be passed to the callbacks.
1147
1148        Args:
1149            source: a list of tuples, each containing a source provider, an optional player identifier, and additional context.
1150                If the identifier is None, the provider will be ignored.
1151            target: a list of tuples, each containing a target provider, an optional player identifier, and additional context.
1152                If the identifier is None, the provider will be ignored.
1153            source_mode: how to handle source tasks, either "fallback" (default) or "parallel".
1154                In "fallback" mode, only the first successful source pair will be scheduled.
1155                In "parallel" mode, all source pairs will be scheduled.
1156            target_mode: how to handle target tasks, either "fallback" or "parallel" (default).
1157                In "fallback" mode, only the first successful target pair will be scheduled.
1158                In "parallel" mode, all target pairs will be scheduled.
1159            source_callback: an optional callback function that will be called with the source provider,
1160                callback with provider, fetched scores and any exception that occurred during fetching.
1161            target_callback: an optional callback function that will be called with the target provider,
1162                callback with provider, merged scores and any exception that occurred during updating.
1163        Returns:
1164            Nothing, failures will notify by callbacks.
1165        """
1166        source_tasks, target_tasks = [], []
1167
1168        # Fetch scores from the source providers.
1169        for sp, ident, kwargs in source:
1170            if ident is not None:
1171                if source_mode == "parallel" or (source_mode == "fallback" and len(source_tasks) == 0):
1172                    source_task = asyncio.create_task(self.scores(ident, sp))
1173                    if source_callback is not None:
1174                        source_task.add_done_callback(lambda t, k=kwargs: source_callback(t.result(), t.exception(), k))
1175                    source_tasks.append(source_task)
1176        source_gather_results = await asyncio.gather(*source_tasks, return_exceptions=True)
1177        maimai_scores_list = [result for result in source_gather_results if isinstance(result, MaimaiScores)]
1178
1179        # Merge scores from all maimai_scores instances.
1180        scores_unique: dict[str, Score] = {}
1181        for maimai_scores in maimai_scores_list:
1182            for score in maimai_scores.scores:
1183                score_key = f"{score.id} {score.type} {score.level_index}"
1184                scores_unique[score_key] = score._join(scores_unique.get(score_key, None))
1185        merged_scores = list(scores_unique.values())
1186        merged_maimai_scores = await MaimaiScores(self).configure(list(scores_unique.values()))
1187
1188        # Update scores to the target providers.
1189        for tp, ident, kwargs in target:
1190            if ident is not None:
1191                if target_mode == "parallel" or (target_mode == "fallback" and len(target_tasks) == 0):
1192                    target_task = asyncio.create_task(self.updates(ident, merged_scores, tp))
1193                    if target_callback is not None:
1194                        target_task.add_done_callback(lambda t, k=kwargs: target_callback(merged_maimai_scores, t.exception(), k))
1195                    target_tasks.append(target_task)
1196        await asyncio.gather(*target_tasks, return_exceptions=True)

Chain updates from source providers to target providers.

This method will fetch scores from the source providers, merge them, and then update the target providers with the merged scores.

The dict in source and target tuples can contain any additional context that will be passed to the callbacks.

Arguments:
  • source: a list of tuples, each containing a source provider, an optional player identifier, and additional context. If the identifier is None, the provider will be ignored.
  • target: a list of tuples, each containing a target provider, an optional player identifier, and additional context. If the identifier is None, the provider will be ignored.
  • source_mode: how to handle source tasks, either "fallback" (default) or "parallel". In "fallback" mode, only the first successful source pair will be scheduled. In "parallel" mode, all source pairs will be scheduled.
  • target_mode: how to handle target tasks, either "fallback" or "parallel" (default). In "fallback" mode, only the first successful target pair will be scheduled. In "parallel" mode, all target pairs will be scheduled.
  • source_callback: an optional callback function that will be called with the source provider, callback with provider, fetched scores and any exception that occurred during fetching.
  • target_callback: an optional callback function that will be called with the target provider, callback with provider, merged scores and any exception that occurred during updating.
Returns:

Nothing, failures will notify by callbacks.

class MaimaiClientMultithreading(MaimaiClient):
1199class MaimaiClientMultithreading(MaimaiClient):
1200    """Multi-threading version of maimai.py.
1201    Introduced by issue #28. Users who want to share the same client instance across multiple threads can use this class.
1202    """
1203
1204    def __new__(cls, *args, **kwargs):
1205        # Override the singleton behavior by always creating a new instance
1206        return super(MaimaiClient, cls).__new__(cls)

Multi-threading version of maimai.py. Introduced by issue #28. Users who want to share the same client instance across multiple threads can use this class.