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)
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
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)
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.
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.
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.
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)]
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")
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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())
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.
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.
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.
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.
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.
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.
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.
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.
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.
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)]
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.
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).
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.
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.
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.
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)
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}")
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.
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.
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.
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.
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
.
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.
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.
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.
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.
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.
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.
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
, andstate
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.
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
andLocalProvider
.
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.
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.
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.
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.
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.
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.