maimai_py.models
1import asyncio 2from abc import abstractmethod 3from dataclasses import dataclass 4from datetime import datetime 5from functools import cached_property 6from typing import Any, Callable, Generic, Iterator, Sequence, TypeVar 7from httpx import AsyncClient, Cookies 8 9from maimai_py.enums import * 10from maimai_py.caches import default_caches 11from maimai_py.exceptions import InvalidPlateError, InvalidPlayerIdentifierError, AimeServerError, ArcadeError, TitleServerError 12from maimai_py.utils.sentinel import UNSET, _UnsetSentinel 13 14 15@dataclass 16class Song: 17 id: int 18 title: str 19 artist: str 20 genre: Genre 21 bpm: int 22 map: str | None 23 version: int 24 rights: str | None 25 aliases: list[str] | None 26 disabled: bool 27 difficulties: "SongDifficulties" 28 29 def _get_level_indexes(self, song_type: SongType, exclude_remaster: bool = False) -> list[LevelIndex]: 30 """@private""" 31 results = [diff.level_index for diff in self.difficulties._get_children(song_type)] 32 if exclude_remaster and LevelIndex.ReMASTER in results: 33 results.remove(LevelIndex.ReMASTER) 34 return results 35 36 def get_difficulty(self, type: SongType, level_index: LevelIndex | None) -> "SongDifficulty | None": 37 if type == SongType.DX: 38 return next((diff for diff in self.difficulties.dx if diff.level_index == level_index), None) 39 if type == SongType.STANDARD: 40 return next((diff for diff in self.difficulties.standard if diff.level_index == level_index), None) 41 if type == SongType.UTAGE: 42 return next(iter(self.difficulties.utage), None) 43 44 45@dataclass 46class SongDifficulties: 47 standard: list["SongDifficulty"] 48 dx: list["SongDifficulty"] 49 utage: list["SongDifficultyUtage"] 50 51 def _get_children(self, song_type: SongType | _UnsetSentinel = UNSET) -> Sequence["SongDifficulty"]: 52 if song_type == UNSET: 53 return self.standard + self.dx + self.utage 54 return self.dx if song_type == SongType.DX else self.standard if song_type == SongType.STANDARD else self.utage 55 56 57@dataclass 58class CurveObject: 59 sample_size: int 60 fit_level_value: float 61 avg_achievements: float 62 stdev_achievements: float 63 avg_dx_score: float 64 rate_sample_size: dict[RateType, int] 65 fc_sample_size: dict[FCType, int] 66 67 68@dataclass 69class SongDifficulty: 70 type: SongType 71 level: str 72 level_value: float 73 level_index: LevelIndex 74 note_designer: str 75 version: int 76 tap_num: int 77 hold_num: int 78 slide_num: int 79 touch_num: int 80 break_num: int 81 curve: CurveObject | None 82 83 84@dataclass 85class SongDifficultyUtage(SongDifficulty): 86 kanji: str 87 description: str 88 is_buddy: bool 89 90 91@dataclass 92class SongAlias: 93 """@private""" 94 95 song_id: int 96 aliases: list[str] 97 98 99@dataclass 100class PlayerIdentifier: 101 qq: int | None = None 102 username: str | None = None 103 friend_code: int | None = None 104 credentials: str | Cookies | None = None 105 106 def __post_init__(self): 107 if self.qq is None and self.username is None and self.friend_code is None and self.credentials is None: 108 raise InvalidPlayerIdentifierError("At least one of the following must be provided: qq, username, friend_code, credentials") 109 110 def _as_diving_fish(self) -> dict[str, Any]: 111 if self.qq: 112 return {"qq": str(self.qq)} 113 elif self.username: 114 return {"username": self.username} 115 elif self.friend_code: 116 raise InvalidPlayerIdentifierError("Friend code is not applicable for Diving Fish") 117 else: 118 raise InvalidPlayerIdentifierError("No valid identifier provided") 119 120 def _as_lxns(self) -> str: 121 if self.friend_code: 122 return str(self.friend_code) 123 elif self.qq: 124 return f"qq/{str(self.qq)}" 125 elif self.username: 126 raise InvalidPlayerIdentifierError("Username is not applicable for LXNS") 127 else: 128 raise InvalidPlayerIdentifierError("No valid identifier provided") 129 130 131@dataclass 132class ArcadeResponse: 133 """@private""" 134 135 errno: int | None = None 136 errmsg: str | None = None 137 data: dict[str, Any] | bytes | list[Any] | None = None 138 139 @staticmethod 140 def _raise_for_error(resp: "ArcadeResponse") -> None: 141 if resp.errno and resp.errno != 0: 142 if resp.errno > 1000: 143 raise ArcadeError(resp.errmsg) 144 elif resp.errno > 100: 145 raise TitleServerError(resp.errmsg) 146 elif resp.errno > 0: 147 raise AimeServerError(resp.errmsg) 148 149 150@dataclass 151class CachedModel: 152 @staticmethod 153 def _cache_key() -> str: 154 raise NotImplementedError 155 156 157@dataclass 158class PlayerTrophy(CachedModel): 159 id: int 160 name: str 161 color: str 162 163 @staticmethod 164 def _cache_key(): 165 return "trophies" 166 167 168@dataclass 169class PlayerIcon(CachedModel): 170 id: int 171 name: str 172 description: str | None = None 173 genre: str | None = None 174 175 @staticmethod 176 def _cache_key(): 177 return "icons" 178 179 180@dataclass 181class PlayerNamePlate(CachedModel): 182 id: int 183 name: str 184 description: str | None = None 185 genre: str | None = None 186 187 @staticmethod 188 def _cache_key(): 189 return "nameplates" 190 191 192@dataclass 193class PlayerFrame(CachedModel): 194 id: int 195 name: str 196 description: str | None = None 197 genre: str | None = None 198 199 @staticmethod 200 def _cache_key(): 201 return "frames" 202 203 204@dataclass 205class PlayerPartner(CachedModel): 206 id: int 207 name: str 208 209 @staticmethod 210 def _cache_key(): 211 return "partners" 212 213 214@dataclass 215class PlayerChara(CachedModel): 216 id: int 217 name: str 218 219 @staticmethod 220 def _cache_key(): 221 return "charas" 222 223 224@dataclass 225class PlayerRegion: 226 region_id: int 227 region_name: str 228 play_count: int 229 created_at: datetime 230 231 232@dataclass 233class Player: 234 name: str 235 rating: int 236 237 238@dataclass 239class DivingFishPlayer(Player): 240 nickname: str 241 plate: str 242 additional_rating: int 243 244 245@dataclass 246class LXNSPlayer(Player): 247 friend_code: int 248 trophy: PlayerTrophy 249 course_rank: int 250 class_rank: int 251 star: int 252 icon: PlayerIcon | None 253 name_plate: PlayerNamePlate | None 254 frame: PlayerFrame | None 255 upload_time: str 256 257 258@dataclass 259class ArcadePlayer(Player): 260 is_login: bool 261 name_plate: PlayerNamePlate | None 262 icon: PlayerIcon | None 263 trophy: PlayerFrame | None 264 265 266@dataclass 267class AreaCharacter: 268 name: str 269 illustrator: str 270 description1: str 271 description2: str 272 team: str 273 props: dict[str, str] 274 275 276@dataclass 277class AreaSong: 278 id: int 279 title: str 280 artist: str 281 description: str 282 illustrator: str | None 283 movie: str | None 284 285 286@dataclass 287class Area: 288 id: str 289 name: str 290 comment: str 291 description: str 292 video_id: str 293 characters: list[AreaCharacter] 294 songs: list[AreaSong] 295 296 297@dataclass 298class Score: 299 id: int 300 song_name: str 301 level: str 302 level_index: LevelIndex 303 achievements: float | None 304 fc: FCType | None 305 fs: FSType | None 306 dx_score: int | None 307 dx_rating: float | None 308 rate: RateType 309 type: SongType 310 311 def _compare(self, other: "Score | None") -> "Score": 312 if other is None: 313 return self 314 if self.dx_score != other.dx_score: # larger value is better 315 return self if (self.dx_score or 0) > (other.dx_score or 0) else other 316 if self.achievements != other.achievements: # larger value is better 317 return self if (self.achievements or 0) > (other.achievements or 0) else other 318 if self.rate != other.rate: # smaller value is better 319 self_rate = self.rate.value if self.rate is not None else 100 320 other_rate = other.rate.value if other.rate is not None else 100 321 return self if self_rate < other_rate else other 322 if self.fc != other.fc: # smaller value is better 323 self_fc = self.fc.value if self.fc is not None else 100 324 other_fc = other.fc.value if other.fc is not None else 100 325 return self if self_fc < other_fc else other 326 if self.fs != other.fs: # bigger value is better 327 self_fs = self.fs.value if self.fs is not None else -1 328 other_fs = other.fs.value if other.fs is not None else -1 329 return self if self_fs > other_fs else other 330 return self # we consider they are equal 331 332 @property 333 def song(self) -> Song | None: 334 songs: MaimaiSongs = default_caches._caches["msongs"] 335 assert songs is not None and isinstance(songs, MaimaiSongs) 336 return songs.by_id(self.id) 337 338 @property 339 def difficulty(self) -> SongDifficulty | None: 340 if self.song: 341 return self.song.get_difficulty(self.type, self.level_index) 342 343 344@dataclass 345class PlateObject: 346 song: Song 347 levels: list[LevelIndex] 348 scores: list[Score] 349 350 351CachedType = TypeVar("CachedType", bound=CachedModel) 352 353 354class MaimaiItems(Generic[CachedType]): 355 _cached_items: dict[int, CachedType] 356 357 def __init__(self, items: dict[int, CachedType]) -> None: 358 """@private""" 359 self._cached_items = items 360 361 @property 362 def values(self) -> Iterator[CachedType]: 363 """All items as list.""" 364 return iter(self._cached_items.values()) 365 366 def by_id(self, id: int) -> CachedType | None: 367 """Get an item by its ID. 368 369 Args: 370 id: the ID of the item. 371 Returns: 372 the item if it exists, otherwise return None. 373 """ 374 return self._cached_items.get(id, None) 375 376 def filter(self, **kwargs) -> list[CachedType]: 377 """Filter items by their attributes. 378 379 Ensure that the attribute is of the item, and the value is of the same type. All conditions are connected by AND. 380 381 Args: 382 kwargs: the attributes to filter the items by. 383 Returns: 384 the list of items that match all the conditions, return an empty list if no item is found. 385 """ 386 return [item for item in self.values if all(getattr(item, key) == value for key, value in kwargs.items() if value is not None)] 387 388 389class MaimaiSongs: 390 _cached_songs: list[Song] 391 _cached_aliases: list[SongAlias] 392 _cached_curves: dict[str, list[CurveObject | None]] 393 394 _song_id_dict: dict[int, Song] # song_id: song 395 _alias_entry_dict: dict[str, Song] # alias_entry: song 396 _keywords_dict: dict[str, Song] # keywords: song 397 398 def __init__(self, songs: list[Song], aliases: list[SongAlias] | None, curves: dict[str, list[CurveObject | None]] | None) -> None: 399 """@private""" 400 self._cached_songs = songs 401 self._cached_aliases = aliases or [] 402 self._cached_curves = curves or {} 403 self._song_id_dict = {} 404 self._alias_entry_dict = {} 405 self._keywords_dict = {} 406 self._flush() 407 408 def _flush(self) -> None: 409 self._song_id_dict = {song.id: song for song in self._cached_songs} 410 self._keywords_dict = {} 411 default_caches._caches["lxns_detailed_songs"] = {} 412 for alias in self._cached_aliases or []: 413 if song := self._song_id_dict.get(alias.song_id): 414 song.aliases = alias.aliases 415 for alias_entry in alias.aliases: 416 self._alias_entry_dict[alias_entry] = song 417 for idx, curve_list in (self._cached_curves or {}).items(): 418 song_type: SongType = SongType._from_id(int(idx)) 419 song_id = int(idx) % 10000 420 if song := self._song_id_dict.get(song_id): 421 diffs = song.difficulties._get_children(song_type) 422 if len(diffs) < len(curve_list): 423 # ignore the extra curves, diving fish may return more curves than the song has, which is a bug 424 curve_list = curve_list[: len(diffs)] 425 [diffs[i].__setattr__("curve", curve) for i, curve in enumerate(curve_list)] 426 for song in self._cached_songs: 427 keywords = song.title.lower() + song.artist.lower() + "".join(alias.lower() for alias in (song.aliases or [])) 428 self._keywords_dict[keywords] = song 429 430 @staticmethod 431 async def _get_or_fetch(client: AsyncClient, flush=False) -> "MaimaiSongs": 432 if "msongs" not in default_caches._caches or flush: 433 tasks = [ 434 default_caches.get_or_fetch("songs", client, flush=flush), 435 default_caches.get_or_fetch("aliases", client, flush=flush), 436 default_caches.get_or_fetch("curves", client, flush=flush), 437 ] 438 songs, aliases, curves = await asyncio.gather(*tasks) 439 default_caches._caches["msongs"] = MaimaiSongs(songs, aliases, curves) 440 return default_caches._caches["msongs"] 441 442 @property 443 def songs(self) -> Iterator[Song]: 444 """All songs as list.""" 445 return iter(self._song_id_dict.values()) 446 447 def by_id(self, id: int) -> Song | None: 448 """Get a song by its ID. 449 450 Args: 451 id: the ID of the song, always smaller than `10000`, should (`% 10000`) if necessary. 452 Returns: 453 the song if it exists, otherwise return None. 454 """ 455 return self._song_id_dict.get(id, None) 456 457 def by_title(self, title: str) -> Song | None: 458 """Get a song by its title. 459 460 Args: 461 title: the title of the song. 462 Returns: 463 the song if it exists, otherwise return None. 464 """ 465 if title == "Link(CoF)": 466 return self.by_id(383) 467 return next((song for song in self.songs if song.title == title), None) 468 469 def by_alias(self, alias: str) -> Song | None: 470 """Get song by one possible alias. 471 472 Args: 473 alias: one possible alias of the song. 474 Returns: 475 the song if it exists, otherwise return None. 476 """ 477 return self._alias_entry_dict.get(alias, None) 478 479 def by_artist(self, artist: str) -> list[Song]: 480 """Get songs by their artist, case-sensitive. 481 482 Args: 483 artist: the artist of the songs. 484 Returns: 485 the list of songs that match the artist, return an empty list if no song is found. 486 """ 487 return [song for song in self.songs if song.artist == artist] 488 489 def by_genre(self, genre: Genre) -> list[Song]: 490 """Get songs by their genre, case-sensitive. 491 492 Args: 493 genre: the genre of the songs. 494 Returns: 495 the list of songs that match the genre, return an empty list if no song is found. 496 """ 497 498 return [song for song in self.songs if song.genre == genre] 499 500 def by_bpm(self, minimum: int, maximum: int) -> list[Song]: 501 """Get songs by their BPM. 502 503 Args: 504 minimum: the minimum (inclusive) BPM of the songs. 505 maximum: the maximum (inclusive) BPM of the songs. 506 Returns: 507 the list of songs that match the BPM range, return an empty list if no song is found. 508 """ 509 return [song for song in self.songs if minimum <= song.bpm <= maximum] 510 511 def by_versions(self, versions: Version) -> list[Song]: 512 """Get songs by their versions, versions are fuzzy matched version of major maimai version. 513 514 Args: 515 versions: the versions of the songs. 516 Returns: 517 the list of songs that match the versions, return an empty list if no song is found. 518 """ 519 520 versions_func: Callable[[Song], bool] = lambda song: versions.value <= song.version < all_versions[all_versions.index(versions) + 1].value 521 return list(filter(versions_func, self.songs)) 522 523 def by_keywords(self, keywords: str) -> list[Song]: 524 """Get songs by their keywords, keywords are matched with song title, artist and aliases. 525 526 Args: 527 keywords: the keywords to match the songs. 528 Returns: 529 the list of songs that match the keywords, return an empty list if no song is found. 530 """ 531 return [v for k, v in self._keywords_dict.items() if keywords.lower() in k] 532 533 def filter(self, **kwargs) -> list[Song]: 534 """Filter songs by their attributes. 535 536 Ensure that the attribute is of the song, and the value is of the same type. All conditions are connected by AND. 537 538 Args: 539 kwargs: the attributes to filter the songs by. 540 Returns: 541 the list of songs that match all the conditions, return an empty list if no song is found. 542 """ 543 if "id" in kwargs and kwargs["id"] is not None: 544 # if id is provided, ignore other attributes, as id is unique 545 return [item] if (item := self.by_id(kwargs["id"])) else [] 546 return [song for song in self.songs if all(getattr(song, key) == value for key, value in kwargs.items() if value is not None)] 547 548 549class MaimaiPlates: 550 scores: list[Score] 551 """The scores that match the plate version and kind.""" 552 songs: list[Song] 553 """The songs that match the plate version and kind.""" 554 version: str 555 """The version of the plate, e.g. "真", "舞".""" 556 kind: str 557 """The kind of the plate, e.g. "将", "神".""" 558 559 _versions: list[Version] = [] 560 561 def __init__(self, scores: list[Score], version_str: str, kind: str, songs: MaimaiSongs) -> None: 562 """@private""" 563 self.scores = [] 564 self.songs = [] 565 self.version = plate_aliases.get(version_str, version_str) 566 self.kind = plate_aliases.get(kind, kind) 567 versions = [] # in case of invalid plate, we will raise an error 568 if self.version == "真": 569 versions = [plate_to_version["初"], plate_to_version["真"]] 570 if self.version in ["霸", "舞"]: 571 versions = [ver for ver in plate_to_version.values() if ver.value < 20000] 572 if plate_to_version.get(self.version): 573 versions = [plate_to_version[self.version]] 574 if not versions or self.kind not in ["将", "者", "极", "舞舞", "神"]: 575 raise InvalidPlateError(f"Invalid plate: {self.version}{self.kind}") 576 versions.append([ver for ver in plate_to_version.values() if ver.value > versions[-1].value][0]) 577 self._versions = versions 578 579 scores_unique = {} 580 for score in scores: 581 if song := songs.by_id(score.id): 582 score_key = f"{score.id} {score.type} {score.level_index}" 583 if difficulty := song.get_difficulty(score.type, score.level_index): 584 score_version = difficulty.version 585 if score.level_index == LevelIndex.ReMASTER and self.no_remaster: 586 continue # skip ReMASTER levels if not required, e.g. in 霸 and 舞 plates 587 if any(score_version >= o.value and score_version < versions[i + 1].value for i, o in enumerate(versions[:-1])): 588 scores_unique[score_key] = score._compare(scores_unique.get(score_key, None)) 589 590 for song in songs.songs: 591 diffs = song.difficulties._get_children() 592 if any(diff.version >= o.value and diff.version < versions[i + 1].value for i, o in enumerate(versions[:-1]) for diff in diffs): 593 self.songs.append(song) 594 595 self.scores = list(scores_unique.values()) 596 597 @cached_property 598 def no_remaster(self) -> bool: 599 """Whether it is required to play ReMASTER levels in the plate. 600 601 Only 舞 and 霸 plates require ReMASTER levels, others don't. 602 """ 603 604 return self.version not in ["舞", "霸"] 605 606 @cached_property 607 def major_type(self) -> SongType: 608 """The major song type of the plate, usually for identifying the levels. 609 610 Only 舞 and 霸 plates require ReMASTER levels, others don't. 611 """ 612 return SongType.DX if any(ver.value > 20000 for ver in self._versions) else SongType.STANDARD 613 614 @cached_property 615 def remained(self) -> list[PlateObject]: 616 """Get the remained songs and scores of the player on this plate. 617 618 If player has ramained levels on one song, the song and ramained `level_index` will be included in the result, otherwise it won't. 619 620 The distinct scores which NOT met the plate requirement will be included in the result, the finished scores won't. 621 """ 622 scores_dict: dict[int, list[Score]] = {} 623 [scores_dict.setdefault(score.id, []).append(score) for score in self.scores] 624 results = { 625 song.id: PlateObject(song=song, levels=song._get_level_indexes(self.major_type, self.no_remaster), scores=scores_dict.get(song.id, [])) 626 for song in self.songs 627 } 628 629 def extract(score: Score) -> None: 630 results[score.id].scores.remove(score) 631 if score.level_index in results[score.id].levels: 632 results[score.id].levels.remove(score.level_index) 633 634 if self.kind == "者": 635 [extract(score) for score in self.scores if score.rate.value <= RateType.A.value] 636 elif self.kind == "将": 637 [extract(score) for score in self.scores if score.rate.value <= RateType.SSS.value] 638 elif self.kind == "极": 639 [extract(score) for score in self.scores if score.fc and score.fc.value <= FCType.FC.value] 640 elif self.kind == "舞舞": 641 [extract(score) for score in self.scores if score.fs and score.fs.value <= FSType.FSD.value] 642 elif self.kind == "神": 643 [extract(score) for score in self.scores if score.fc and score.fc.value <= FCType.AP.value] 644 645 return [plate for plate in results.values() if plate.levels != []] 646 647 @cached_property 648 def cleared(self) -> list[PlateObject]: 649 """Get the cleared songs and scores of the player on this plate. 650 651 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. 652 653 The distinct scores which met the plate requirement will be included in the result, the unfinished scores won't. 654 """ 655 results = {song.id: PlateObject(song=song, levels=[], scores=[]) for song in self.songs} 656 657 def insert(score: Score) -> None: 658 results[score.id].scores.append(score) 659 results[score.id].levels.append(score.level_index) 660 661 if self.kind == "者": 662 [insert(score) for score in self.scores if score.rate.value <= RateType.A.value] 663 elif self.kind == "将": 664 [insert(score) for score in self.scores if score.rate.value <= RateType.SSS.value] 665 elif self.kind == "极": 666 [insert(score) for score in self.scores if score.fc and score.fc.value <= FCType.FC.value] 667 elif self.kind == "舞舞": 668 [insert(score) for score in self.scores if score.fs and score.fs.value <= FSType.FSD.value] 669 elif self.kind == "神": 670 [insert(score) for score in self.scores if score.fc and score.fc.value <= FCType.AP.value] 671 672 return [plate for plate in results.values() if plate.levels != []] 673 674 @cached_property 675 def played(self) -> list[PlateObject]: 676 """Get the played songs and scores of the player on this plate. 677 678 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. 679 680 All distinct scores will be included in the result. 681 """ 682 results = {song.id: PlateObject(song=song, levels=[], scores=[]) for song in self.songs} 683 for score in self.scores: 684 results[score.id].scores.append(score) 685 results[score.id].levels.append(score.level_index) 686 return [plate for plate in results.values() if plate.levels != []] 687 688 @cached_property 689 def all(self) -> Iterator[PlateObject]: 690 """Get all songs on this plate, usually used for statistics of the plate. 691 692 All songs will be included in the result, with all levels, whether they met or not. 693 694 No scores will be included in the result, use played, cleared, remained to get the scores. 695 """ 696 697 return iter(PlateObject(song=song, levels=song._get_level_indexes(self.major_type, self.no_remaster), scores=[]) for song in self.songs) 698 699 @cached_property 700 def played_num(self) -> int: 701 """Get the number of played levels on this plate.""" 702 return len([level for plate in self.played for level in plate.levels]) 703 704 @cached_property 705 def cleared_num(self) -> int: 706 """Get the number of cleared levels on this plate.""" 707 return len([level for plate in self.cleared for level in plate.levels]) 708 709 @cached_property 710 def remained_num(self) -> int: 711 """Get the number of remained levels on this plate.""" 712 return len([level for plate in self.remained for level in plate.levels]) 713 714 @cached_property 715 def all_num(self) -> int: 716 """Get the number of all levels on this plate. 717 718 This is the total number of levels on the plate, should equal to `cleared_num + remained_num`. 719 """ 720 return len([level for plate in self.all for level in plate.levels]) 721 722 723class MaimaiScores: 724 scores: list[Score] 725 """All scores of the player when `ScoreKind.ALL`, otherwise only the b50 scores.""" 726 scores_b35: list[Score] 727 """The b35 scores of the player.""" 728 scores_b15: list[Score] 729 """The b15 scores of the player.""" 730 rating: int 731 """The total rating of the player.""" 732 rating_b35: int 733 """The b35 rating of the player.""" 734 rating_b15: int 735 """The b15 rating of the player.""" 736 737 @staticmethod 738 def _get_distinct_scores(scores: list[Score]) -> list[Score]: 739 scores_unique = {} 740 for score in scores: 741 score_key = f"{score.id} {score.type} {score.level_index}" 742 scores_unique[score_key] = score._compare(scores_unique.get(score_key, None)) 743 return list(scores_unique.values()) 744 745 def __init__( 746 self, b35: list[Score] | None = None, b15: list[Score] | None = None, all: list[Score] | None = None, songs: MaimaiSongs | None = None 747 ): 748 self.scores = all or (b35 + b15 if b35 and b15 else None) or [] 749 # if b35 and b15 are not provided, try to calculate them from all scores 750 if (not b35 or not b15) and all: 751 distinct_scores = MaimaiScores._get_distinct_scores(all) # scores have to be distinct to calculate the bests 752 scores_new: list[Score] = [] 753 scores_old: list[Score] = [] 754 for score in distinct_scores: 755 if songs and (diff := score.difficulty): 756 (scores_new if diff.version >= current_version.value else scores_old).append(score) 757 scores_old.sort(key=lambda score: (score.dx_rating, score.dx_score, score.achievements), reverse=True) 758 scores_new.sort(key=lambda score: (score.dx_rating, score.dx_score, score.achievements), reverse=True) 759 b35 = scores_old[:35] 760 b15 = scores_new[:15] 761 self.scores_b35 = b35 or [] 762 self.scores_b15 = b15 or [] 763 self.rating_b35 = int(sum((score.dx_rating or 0) for score in b35) if b35 else 0) 764 self.rating_b15 = int(sum((score.dx_rating or 0) for score in b15) if b15 else 0) 765 self.rating = self.rating_b35 + self.rating_b15 766 767 @property 768 def as_distinct(self) -> "MaimaiScores": 769 """Get the distinct scores. 770 771 Normally, player has more than one score for the same song and level, this method will return a new `MaimaiScores` object with the highest scores for each song and level. 772 773 This method won't modify the original scores object, it will return a new one. 774 775 If ScoreKind is BEST, this won't make any difference, because the scores are already the best ones. 776 """ 777 distinct_scores = MaimaiScores._get_distinct_scores(self.scores) 778 songs: MaimaiSongs = default_caches._caches["msongs"] 779 assert songs is not None and isinstance(songs, MaimaiSongs) 780 return MaimaiScores(b35=self.scores_b35, b15=self.scores_b15, all=distinct_scores, songs=songs) 781 782 def by_song( 783 self, song_id: int, song_type: SongType | _UnsetSentinel = UNSET, level_index: LevelIndex | _UnsetSentinel = UNSET 784 ) -> Iterator[Score]: 785 """Get scores of the song on that type and level_index. 786 787 If song_type or level_index is not provided, all scores of the song will be returned. 788 789 Args: 790 song_id: the ID of the song to get the scores by. 791 song_type: the type of the song to get the scores by, defaults to None. 792 level_index: the level index of the song to get the scores by, defaults to None. 793 Returns: 794 the list of scores of the song, return an empty list if no score is found. 795 """ 796 for score in self.scores: 797 if score.id != song_id: 798 continue 799 if song_type is not UNSET and score.type != song_type: 800 continue 801 if level_index is not UNSET and score.level_index != level_index: 802 continue 803 yield score 804 805 def filter(self, **kwargs) -> list[Score]: 806 """Filter scores by their attributes. 807 808 Make sure the attribute is of the score, and the value is of the same type. All conditions are connected by AND. 809 810 Args: 811 kwargs: the attributes to filter the scores by. 812 Returns: 813 the list of scores that match all the conditions, return an empty list if no score is found. 814 """ 815 return [score for score in self.scores if all(getattr(score, key) == value for key, value in kwargs.items())] 816 817 818class MaimaiAreas: 819 lang: str 820 """The language of the areas.""" 821 822 _area_id_dict: dict[str, Area] # area_id: area 823 824 def __init__(self, lang: str, areas: dict[str, Area]) -> None: 825 """@private""" 826 self.lang = lang 827 self._area_id_dict = areas 828 829 @property 830 def values(self) -> Iterator[Area]: 831 """All areas as list.""" 832 return iter(self._area_id_dict.values()) 833 834 def by_id(self, id: str) -> Area | None: 835 """Get an area by its ID. 836 837 Args: 838 id: the ID of the area. 839 Returns: 840 the area if it exists, otherwise return None. 841 """ 842 return self._area_id_dict.get(id, None) 843 844 def by_name(self, name: str) -> Area | None: 845 """Get an area by its name, language-sensitive. 846 847 Args: 848 name: the name of the area. 849 Returns: 850 the area if it exists, otherwise return None. 851 """ 852 return next((area for area in self.values if area.name == name), None)
16@dataclass 17class Song: 18 id: int 19 title: str 20 artist: str 21 genre: Genre 22 bpm: int 23 map: str | None 24 version: int 25 rights: str | None 26 aliases: list[str] | None 27 disabled: bool 28 difficulties: "SongDifficulties" 29 30 def _get_level_indexes(self, song_type: SongType, exclude_remaster: bool = False) -> list[LevelIndex]: 31 """@private""" 32 results = [diff.level_index for diff in self.difficulties._get_children(song_type)] 33 if exclude_remaster and LevelIndex.ReMASTER in results: 34 results.remove(LevelIndex.ReMASTER) 35 return results 36 37 def get_difficulty(self, type: SongType, level_index: LevelIndex | None) -> "SongDifficulty | None": 38 if type == SongType.DX: 39 return next((diff for diff in self.difficulties.dx if diff.level_index == level_index), None) 40 if type == SongType.STANDARD: 41 return next((diff for diff in self.difficulties.standard if diff.level_index == level_index), None) 42 if type == SongType.UTAGE: 43 return next(iter(self.difficulties.utage), None)
37 def get_difficulty(self, type: SongType, level_index: LevelIndex | None) -> "SongDifficulty | None": 38 if type == SongType.DX: 39 return next((diff for diff in self.difficulties.dx if diff.level_index == level_index), None) 40 if type == SongType.STANDARD: 41 return next((diff for diff in self.difficulties.standard if diff.level_index == level_index), None) 42 if type == SongType.UTAGE: 43 return next(iter(self.difficulties.utage), None)
46@dataclass 47class SongDifficulties: 48 standard: list["SongDifficulty"] 49 dx: list["SongDifficulty"] 50 utage: list["SongDifficultyUtage"] 51 52 def _get_children(self, song_type: SongType | _UnsetSentinel = UNSET) -> Sequence["SongDifficulty"]: 53 if song_type == UNSET: 54 return self.standard + self.dx + self.utage 55 return self.dx if song_type == SongType.DX else self.standard if song_type == SongType.STANDARD else self.utage
58@dataclass 59class CurveObject: 60 sample_size: int 61 fit_level_value: float 62 avg_achievements: float 63 stdev_achievements: float 64 avg_dx_score: float 65 rate_sample_size: dict[RateType, int] 66 fc_sample_size: dict[FCType, int]
69@dataclass 70class SongDifficulty: 71 type: SongType 72 level: str 73 level_value: float 74 level_index: LevelIndex 75 note_designer: str 76 version: int 77 tap_num: int 78 hold_num: int 79 slide_num: int 80 touch_num: int 81 break_num: int 82 curve: CurveObject | None
85@dataclass 86class SongDifficultyUtage(SongDifficulty): 87 kanji: str 88 description: str 89 is_buddy: bool
Inherited Members
100@dataclass 101class PlayerIdentifier: 102 qq: int | None = None 103 username: str | None = None 104 friend_code: int | None = None 105 credentials: str | Cookies | None = None 106 107 def __post_init__(self): 108 if self.qq is None and self.username is None and self.friend_code is None and self.credentials is None: 109 raise InvalidPlayerIdentifierError("At least one of the following must be provided: qq, username, friend_code, credentials") 110 111 def _as_diving_fish(self) -> dict[str, Any]: 112 if self.qq: 113 return {"qq": str(self.qq)} 114 elif self.username: 115 return {"username": self.username} 116 elif self.friend_code: 117 raise InvalidPlayerIdentifierError("Friend code is not applicable for Diving Fish") 118 else: 119 raise InvalidPlayerIdentifierError("No valid identifier provided") 120 121 def _as_lxns(self) -> str: 122 if self.friend_code: 123 return str(self.friend_code) 124 elif self.qq: 125 return f"qq/{str(self.qq)}" 126 elif self.username: 127 raise InvalidPlayerIdentifierError("Username is not applicable for LXNS") 128 else: 129 raise InvalidPlayerIdentifierError("No valid identifier provided")
246@dataclass 247class LXNSPlayer(Player): 248 friend_code: int 249 trophy: PlayerTrophy 250 course_rank: int 251 class_rank: int 252 star: int 253 icon: PlayerIcon | None 254 name_plate: PlayerNamePlate | None 255 frame: PlayerFrame | None 256 upload_time: str
259@dataclass 260class ArcadePlayer(Player): 261 is_login: bool 262 name_plate: PlayerNamePlate | None 263 icon: PlayerIcon | None 264 trophy: PlayerFrame | None
267@dataclass 268class AreaCharacter: 269 name: str 270 illustrator: str 271 description1: str 272 description2: str 273 team: str 274 props: dict[str, str]
277@dataclass 278class AreaSong: 279 id: int 280 title: str 281 artist: str 282 description: str 283 illustrator: str | None 284 movie: str | None
287@dataclass 288class Area: 289 id: str 290 name: str 291 comment: str 292 description: str 293 video_id: str 294 characters: list[AreaCharacter] 295 songs: list[AreaSong]
298@dataclass 299class Score: 300 id: int 301 song_name: str 302 level: str 303 level_index: LevelIndex 304 achievements: float | None 305 fc: FCType | None 306 fs: FSType | None 307 dx_score: int | None 308 dx_rating: float | None 309 rate: RateType 310 type: SongType 311 312 def _compare(self, other: "Score | None") -> "Score": 313 if other is None: 314 return self 315 if self.dx_score != other.dx_score: # larger value is better 316 return self if (self.dx_score or 0) > (other.dx_score or 0) else other 317 if self.achievements != other.achievements: # larger value is better 318 return self if (self.achievements or 0) > (other.achievements or 0) else other 319 if self.rate != other.rate: # smaller value is better 320 self_rate = self.rate.value if self.rate is not None else 100 321 other_rate = other.rate.value if other.rate is not None else 100 322 return self if self_rate < other_rate else other 323 if self.fc != other.fc: # smaller value is better 324 self_fc = self.fc.value if self.fc is not None else 100 325 other_fc = other.fc.value if other.fc is not None else 100 326 return self if self_fc < other_fc else other 327 if self.fs != other.fs: # bigger value is better 328 self_fs = self.fs.value if self.fs is not None else -1 329 other_fs = other.fs.value if other.fs is not None else -1 330 return self if self_fs > other_fs else other 331 return self # we consider they are equal 332 333 @property 334 def song(self) -> Song | None: 335 songs: MaimaiSongs = default_caches._caches["msongs"] 336 assert songs is not None and isinstance(songs, MaimaiSongs) 337 return songs.by_id(self.id) 338 339 @property 340 def difficulty(self) -> SongDifficulty | None: 341 if self.song: 342 return self.song.get_difficulty(self.type, self.level_index)
345@dataclass 346class PlateObject: 347 song: Song 348 levels: list[LevelIndex] 349 scores: list[Score]
355class MaimaiItems(Generic[CachedType]): 356 _cached_items: dict[int, CachedType] 357 358 def __init__(self, items: dict[int, CachedType]) -> None: 359 """@private""" 360 self._cached_items = items 361 362 @property 363 def values(self) -> Iterator[CachedType]: 364 """All items as list.""" 365 return iter(self._cached_items.values()) 366 367 def by_id(self, id: int) -> CachedType | None: 368 """Get an item by its ID. 369 370 Args: 371 id: the ID of the item. 372 Returns: 373 the item if it exists, otherwise return None. 374 """ 375 return self._cached_items.get(id, None) 376 377 def filter(self, **kwargs) -> list[CachedType]: 378 """Filter items by their attributes. 379 380 Ensure that the attribute is of the item, and the value is of the same type. All conditions are connected by AND. 381 382 Args: 383 kwargs: the attributes to filter the items by. 384 Returns: 385 the list of items that match all the conditions, return an empty list if no item is found. 386 """ 387 return [item for item in self.values if all(getattr(item, key) == value for key, value in kwargs.items() if value is not None)]
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
362 @property 363 def values(self) -> Iterator[CachedType]: 364 """All items as list.""" 365 return iter(self._cached_items.values())
All items as list.
367 def by_id(self, id: int) -> CachedType | None: 368 """Get an item by its ID. 369 370 Args: 371 id: the ID of the item. 372 Returns: 373 the item if it exists, otherwise return None. 374 """ 375 return self._cached_items.get(id, None)
Get an item by its ID.
Arguments:
- id: the ID of the item.
Returns:
the item if it exists, otherwise return None.
377 def filter(self, **kwargs) -> list[CachedType]: 378 """Filter items by their attributes. 379 380 Ensure that the attribute is of the item, and the value is of the same type. All conditions are connected by AND. 381 382 Args: 383 kwargs: the attributes to filter the items by. 384 Returns: 385 the list of items that match all the conditions, return an empty list if no item is found. 386 """ 387 return [item for item in self.values if all(getattr(item, key) == value for key, value in kwargs.items() if value is not None)]
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:
the list of items that match all the conditions, return an empty list if no item is found.
390class MaimaiSongs: 391 _cached_songs: list[Song] 392 _cached_aliases: list[SongAlias] 393 _cached_curves: dict[str, list[CurveObject | None]] 394 395 _song_id_dict: dict[int, Song] # song_id: song 396 _alias_entry_dict: dict[str, Song] # alias_entry: song 397 _keywords_dict: dict[str, Song] # keywords: song 398 399 def __init__(self, songs: list[Song], aliases: list[SongAlias] | None, curves: dict[str, list[CurveObject | None]] | None) -> None: 400 """@private""" 401 self._cached_songs = songs 402 self._cached_aliases = aliases or [] 403 self._cached_curves = curves or {} 404 self._song_id_dict = {} 405 self._alias_entry_dict = {} 406 self._keywords_dict = {} 407 self._flush() 408 409 def _flush(self) -> None: 410 self._song_id_dict = {song.id: song for song in self._cached_songs} 411 self._keywords_dict = {} 412 default_caches._caches["lxns_detailed_songs"] = {} 413 for alias in self._cached_aliases or []: 414 if song := self._song_id_dict.get(alias.song_id): 415 song.aliases = alias.aliases 416 for alias_entry in alias.aliases: 417 self._alias_entry_dict[alias_entry] = song 418 for idx, curve_list in (self._cached_curves or {}).items(): 419 song_type: SongType = SongType._from_id(int(idx)) 420 song_id = int(idx) % 10000 421 if song := self._song_id_dict.get(song_id): 422 diffs = song.difficulties._get_children(song_type) 423 if len(diffs) < len(curve_list): 424 # ignore the extra curves, diving fish may return more curves than the song has, which is a bug 425 curve_list = curve_list[: len(diffs)] 426 [diffs[i].__setattr__("curve", curve) for i, curve in enumerate(curve_list)] 427 for song in self._cached_songs: 428 keywords = song.title.lower() + song.artist.lower() + "".join(alias.lower() for alias in (song.aliases or [])) 429 self._keywords_dict[keywords] = song 430 431 @staticmethod 432 async def _get_or_fetch(client: AsyncClient, flush=False) -> "MaimaiSongs": 433 if "msongs" not in default_caches._caches or flush: 434 tasks = [ 435 default_caches.get_or_fetch("songs", client, flush=flush), 436 default_caches.get_or_fetch("aliases", client, flush=flush), 437 default_caches.get_or_fetch("curves", client, flush=flush), 438 ] 439 songs, aliases, curves = await asyncio.gather(*tasks) 440 default_caches._caches["msongs"] = MaimaiSongs(songs, aliases, curves) 441 return default_caches._caches["msongs"] 442 443 @property 444 def songs(self) -> Iterator[Song]: 445 """All songs as list.""" 446 return iter(self._song_id_dict.values()) 447 448 def by_id(self, id: int) -> Song | None: 449 """Get a song by its ID. 450 451 Args: 452 id: the ID of the song, always smaller than `10000`, should (`% 10000`) if necessary. 453 Returns: 454 the song if it exists, otherwise return None. 455 """ 456 return self._song_id_dict.get(id, None) 457 458 def by_title(self, title: str) -> Song | None: 459 """Get a song by its title. 460 461 Args: 462 title: the title of the song. 463 Returns: 464 the song if it exists, otherwise return None. 465 """ 466 if title == "Link(CoF)": 467 return self.by_id(383) 468 return next((song for song in self.songs if song.title == title), None) 469 470 def by_alias(self, alias: str) -> Song | None: 471 """Get song by one possible alias. 472 473 Args: 474 alias: one possible alias of the song. 475 Returns: 476 the song if it exists, otherwise return None. 477 """ 478 return self._alias_entry_dict.get(alias, None) 479 480 def by_artist(self, artist: str) -> list[Song]: 481 """Get songs by their artist, case-sensitive. 482 483 Args: 484 artist: the artist of the songs. 485 Returns: 486 the list of songs that match the artist, return an empty list if no song is found. 487 """ 488 return [song for song in self.songs if song.artist == artist] 489 490 def by_genre(self, genre: Genre) -> list[Song]: 491 """Get songs by their genre, case-sensitive. 492 493 Args: 494 genre: the genre of the songs. 495 Returns: 496 the list of songs that match the genre, return an empty list if no song is found. 497 """ 498 499 return [song for song in self.songs if song.genre == genre] 500 501 def by_bpm(self, minimum: int, maximum: int) -> list[Song]: 502 """Get songs by their BPM. 503 504 Args: 505 minimum: the minimum (inclusive) BPM of the songs. 506 maximum: the maximum (inclusive) BPM of the songs. 507 Returns: 508 the list of songs that match the BPM range, return an empty list if no song is found. 509 """ 510 return [song for song in self.songs if minimum <= song.bpm <= maximum] 511 512 def by_versions(self, versions: Version) -> list[Song]: 513 """Get songs by their versions, versions are fuzzy matched version of major maimai version. 514 515 Args: 516 versions: the versions of the songs. 517 Returns: 518 the list of songs that match the versions, return an empty list if no song is found. 519 """ 520 521 versions_func: Callable[[Song], bool] = lambda song: versions.value <= song.version < all_versions[all_versions.index(versions) + 1].value 522 return list(filter(versions_func, self.songs)) 523 524 def by_keywords(self, keywords: str) -> list[Song]: 525 """Get songs by their keywords, keywords are matched with song title, artist and aliases. 526 527 Args: 528 keywords: the keywords to match the songs. 529 Returns: 530 the list of songs that match the keywords, return an empty list if no song is found. 531 """ 532 return [v for k, v in self._keywords_dict.items() if keywords.lower() in k] 533 534 def filter(self, **kwargs) -> list[Song]: 535 """Filter songs by their attributes. 536 537 Ensure that the attribute is of the song, and the value is of the same type. All conditions are connected by AND. 538 539 Args: 540 kwargs: the attributes to filter the songs by. 541 Returns: 542 the list of songs that match all the conditions, return an empty list if no song is found. 543 """ 544 if "id" in kwargs and kwargs["id"] is not None: 545 # if id is provided, ignore other attributes, as id is unique 546 return [item] if (item := self.by_id(kwargs["id"])) else [] 547 return [song for song in self.songs if all(getattr(song, key) == value for key, value in kwargs.items() if value is not None)]
443 @property 444 def songs(self) -> Iterator[Song]: 445 """All songs as list.""" 446 return iter(self._song_id_dict.values())
All songs as list.
448 def by_id(self, id: int) -> Song | None: 449 """Get a song by its ID. 450 451 Args: 452 id: the ID of the song, always smaller than `10000`, should (`% 10000`) if necessary. 453 Returns: 454 the song if it exists, otherwise return None. 455 """ 456 return self._song_id_dict.get(id, None)
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.
458 def by_title(self, title: str) -> Song | None: 459 """Get a song by its title. 460 461 Args: 462 title: the title of the song. 463 Returns: 464 the song if it exists, otherwise return None. 465 """ 466 if title == "Link(CoF)": 467 return self.by_id(383) 468 return next((song for song in self.songs if song.title == title), None)
Get a song by its title.
Arguments:
- title: the title of the song.
Returns:
the song if it exists, otherwise return None.
470 def by_alias(self, alias: str) -> Song | None: 471 """Get song by one possible alias. 472 473 Args: 474 alias: one possible alias of the song. 475 Returns: 476 the song if it exists, otherwise return None. 477 """ 478 return self._alias_entry_dict.get(alias, None)
Get song by one possible alias.
Arguments:
- alias: one possible alias of the song.
Returns:
the song if it exists, otherwise return None.
480 def by_artist(self, artist: str) -> list[Song]: 481 """Get songs by their artist, case-sensitive. 482 483 Args: 484 artist: the artist of the songs. 485 Returns: 486 the list of songs that match the artist, return an empty list if no song is found. 487 """ 488 return [song for song in self.songs if song.artist == artist]
Get songs by their artist, case-sensitive.
Arguments:
- artist: the artist of the songs.
Returns:
the list of songs that match the artist, return an empty list if no song is found.
490 def by_genre(self, genre: Genre) -> list[Song]: 491 """Get songs by their genre, case-sensitive. 492 493 Args: 494 genre: the genre of the songs. 495 Returns: 496 the list of songs that match the genre, return an empty list if no song is found. 497 """ 498 499 return [song for song in self.songs if song.genre == genre]
Get songs by their genre, case-sensitive.
Arguments:
- genre: the genre of the songs.
Returns:
the list of songs that match the genre, return an empty list if no song is found.
501 def by_bpm(self, minimum: int, maximum: int) -> list[Song]: 502 """Get songs by their BPM. 503 504 Args: 505 minimum: the minimum (inclusive) BPM of the songs. 506 maximum: the maximum (inclusive) BPM of the songs. 507 Returns: 508 the list of songs that match the BPM range, return an empty list if no song is found. 509 """ 510 return [song for song in self.songs 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:
the list of songs that match the BPM range, return an empty list if no song is found.
512 def by_versions(self, versions: Version) -> list[Song]: 513 """Get songs by their versions, versions are fuzzy matched version of major maimai version. 514 515 Args: 516 versions: the versions of the songs. 517 Returns: 518 the list of songs that match the versions, return an empty list if no song is found. 519 """ 520 521 versions_func: Callable[[Song], bool] = lambda song: versions.value <= song.version < all_versions[all_versions.index(versions) + 1].value 522 return list(filter(versions_func, self.songs))
Get songs by their versions, versions are fuzzy matched version of major maimai version.
Arguments:
- versions: the versions of the songs.
Returns:
the list of songs that match the versions, return an empty list if no song is found.
524 def by_keywords(self, keywords: str) -> list[Song]: 525 """Get songs by their keywords, keywords are matched with song title, artist and aliases. 526 527 Args: 528 keywords: the keywords to match the songs. 529 Returns: 530 the list of songs that match the keywords, return an empty list if no song is found. 531 """ 532 return [v for k, v in self._keywords_dict.items() if keywords.lower() in k]
Get songs by their keywords, keywords are matched with song title, artist and aliases.
Arguments:
- keywords: the keywords to match the songs.
Returns:
the list of songs that match the keywords, return an empty list if no song is found.
534 def filter(self, **kwargs) -> list[Song]: 535 """Filter songs by their attributes. 536 537 Ensure that the attribute is of the song, and the value is of the same type. All conditions are connected by AND. 538 539 Args: 540 kwargs: the attributes to filter the songs by. 541 Returns: 542 the list of songs that match all the conditions, return an empty list if no song is found. 543 """ 544 if "id" in kwargs and kwargs["id"] is not None: 545 # if id is provided, ignore other attributes, as id is unique 546 return [item] if (item := self.by_id(kwargs["id"])) else [] 547 return [song for song in self.songs if all(getattr(song, key) == value for key, value in kwargs.items() if value is not None)]
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:
the list of songs that match all the conditions, return an empty list if no song is found.
550class MaimaiPlates: 551 scores: list[Score] 552 """The scores that match the plate version and kind.""" 553 songs: list[Song] 554 """The songs that match the plate version and kind.""" 555 version: str 556 """The version of the plate, e.g. "真", "舞".""" 557 kind: str 558 """The kind of the plate, e.g. "将", "神".""" 559 560 _versions: list[Version] = [] 561 562 def __init__(self, scores: list[Score], version_str: str, kind: str, songs: MaimaiSongs) -> None: 563 """@private""" 564 self.scores = [] 565 self.songs = [] 566 self.version = plate_aliases.get(version_str, version_str) 567 self.kind = plate_aliases.get(kind, kind) 568 versions = [] # in case of invalid plate, we will raise an error 569 if self.version == "真": 570 versions = [plate_to_version["初"], plate_to_version["真"]] 571 if self.version in ["霸", "舞"]: 572 versions = [ver for ver in plate_to_version.values() if ver.value < 20000] 573 if plate_to_version.get(self.version): 574 versions = [plate_to_version[self.version]] 575 if not versions or self.kind not in ["将", "者", "极", "舞舞", "神"]: 576 raise InvalidPlateError(f"Invalid plate: {self.version}{self.kind}") 577 versions.append([ver for ver in plate_to_version.values() if ver.value > versions[-1].value][0]) 578 self._versions = versions 579 580 scores_unique = {} 581 for score in scores: 582 if song := songs.by_id(score.id): 583 score_key = f"{score.id} {score.type} {score.level_index}" 584 if difficulty := song.get_difficulty(score.type, score.level_index): 585 score_version = difficulty.version 586 if score.level_index == LevelIndex.ReMASTER and self.no_remaster: 587 continue # skip ReMASTER levels if not required, e.g. in 霸 and 舞 plates 588 if any(score_version >= o.value and score_version < versions[i + 1].value for i, o in enumerate(versions[:-1])): 589 scores_unique[score_key] = score._compare(scores_unique.get(score_key, None)) 590 591 for song in songs.songs: 592 diffs = song.difficulties._get_children() 593 if any(diff.version >= o.value and diff.version < versions[i + 1].value for i, o in enumerate(versions[:-1]) for diff in diffs): 594 self.songs.append(song) 595 596 self.scores = list(scores_unique.values()) 597 598 @cached_property 599 def no_remaster(self) -> bool: 600 """Whether it is required to play ReMASTER levels in the plate. 601 602 Only 舞 and 霸 plates require ReMASTER levels, others don't. 603 """ 604 605 return self.version not in ["舞", "霸"] 606 607 @cached_property 608 def major_type(self) -> SongType: 609 """The major song type of the plate, usually for identifying the levels. 610 611 Only 舞 and 霸 plates require ReMASTER levels, others don't. 612 """ 613 return SongType.DX if any(ver.value > 20000 for ver in self._versions) else SongType.STANDARD 614 615 @cached_property 616 def remained(self) -> list[PlateObject]: 617 """Get the remained songs and scores of the player on this plate. 618 619 If player has ramained levels on one song, the song and ramained `level_index` will be included in the result, otherwise it won't. 620 621 The distinct scores which NOT met the plate requirement will be included in the result, the finished scores won't. 622 """ 623 scores_dict: dict[int, list[Score]] = {} 624 [scores_dict.setdefault(score.id, []).append(score) for score in self.scores] 625 results = { 626 song.id: PlateObject(song=song, levels=song._get_level_indexes(self.major_type, self.no_remaster), scores=scores_dict.get(song.id, [])) 627 for song in self.songs 628 } 629 630 def extract(score: Score) -> None: 631 results[score.id].scores.remove(score) 632 if score.level_index in results[score.id].levels: 633 results[score.id].levels.remove(score.level_index) 634 635 if self.kind == "者": 636 [extract(score) for score in self.scores if score.rate.value <= RateType.A.value] 637 elif self.kind == "将": 638 [extract(score) for score in self.scores if score.rate.value <= RateType.SSS.value] 639 elif self.kind == "极": 640 [extract(score) for score in self.scores if score.fc and score.fc.value <= FCType.FC.value] 641 elif self.kind == "舞舞": 642 [extract(score) for score in self.scores if score.fs and score.fs.value <= FSType.FSD.value] 643 elif self.kind == "神": 644 [extract(score) for score in self.scores if score.fc and score.fc.value <= FCType.AP.value] 645 646 return [plate for plate in results.values() if plate.levels != []] 647 648 @cached_property 649 def cleared(self) -> list[PlateObject]: 650 """Get the cleared songs and scores of the player on this plate. 651 652 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. 653 654 The distinct scores which met the plate requirement will be included in the result, the unfinished scores won't. 655 """ 656 results = {song.id: PlateObject(song=song, levels=[], scores=[]) for song in self.songs} 657 658 def insert(score: Score) -> None: 659 results[score.id].scores.append(score) 660 results[score.id].levels.append(score.level_index) 661 662 if self.kind == "者": 663 [insert(score) for score in self.scores if score.rate.value <= RateType.A.value] 664 elif self.kind == "将": 665 [insert(score) for score in self.scores if score.rate.value <= RateType.SSS.value] 666 elif self.kind == "极": 667 [insert(score) for score in self.scores if score.fc and score.fc.value <= FCType.FC.value] 668 elif self.kind == "舞舞": 669 [insert(score) for score in self.scores if score.fs and score.fs.value <= FSType.FSD.value] 670 elif self.kind == "神": 671 [insert(score) for score in self.scores if score.fc and score.fc.value <= FCType.AP.value] 672 673 return [plate for plate in results.values() if plate.levels != []] 674 675 @cached_property 676 def played(self) -> list[PlateObject]: 677 """Get the played songs and scores of the player on this plate. 678 679 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. 680 681 All distinct scores will be included in the result. 682 """ 683 results = {song.id: PlateObject(song=song, levels=[], scores=[]) for song in self.songs} 684 for score in self.scores: 685 results[score.id].scores.append(score) 686 results[score.id].levels.append(score.level_index) 687 return [plate for plate in results.values() if plate.levels != []] 688 689 @cached_property 690 def all(self) -> Iterator[PlateObject]: 691 """Get all songs on this plate, usually used for statistics of the plate. 692 693 All songs will be included in the result, with all levels, whether they met or not. 694 695 No scores will be included in the result, use played, cleared, remained to get the scores. 696 """ 697 698 return iter(PlateObject(song=song, levels=song._get_level_indexes(self.major_type, self.no_remaster), scores=[]) for song in self.songs) 699 700 @cached_property 701 def played_num(self) -> int: 702 """Get the number of played levels on this plate.""" 703 return len([level for plate in self.played for level in plate.levels]) 704 705 @cached_property 706 def cleared_num(self) -> int: 707 """Get the number of cleared levels on this plate.""" 708 return len([level for plate in self.cleared for level in plate.levels]) 709 710 @cached_property 711 def remained_num(self) -> int: 712 """Get the number of remained levels on this plate.""" 713 return len([level for plate in self.remained for level in plate.levels]) 714 715 @cached_property 716 def all_num(self) -> int: 717 """Get the number of all levels on this plate. 718 719 This is the total number of levels on the plate, should equal to `cleared_num + remained_num`. 720 """ 721 return len([level for plate in self.all for level in plate.levels])
598 @cached_property 599 def no_remaster(self) -> bool: 600 """Whether it is required to play ReMASTER levels in the plate. 601 602 Only 舞 and 霸 plates require ReMASTER levels, others don't. 603 """ 604 605 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.
607 @cached_property 608 def major_type(self) -> SongType: 609 """The major song type of the plate, usually for identifying the levels. 610 611 Only 舞 and 霸 plates require ReMASTER levels, others don't. 612 """ 613 return SongType.DX if any(ver.value > 20000 for ver in self._versions) else SongType.STANDARD
The major song type of the plate, usually for identifying the levels.
Only 舞 and 霸 plates require ReMASTER levels, others don't.
615 @cached_property 616 def remained(self) -> list[PlateObject]: 617 """Get the remained songs and scores of the player on this plate. 618 619 If player has ramained levels on one song, the song and ramained `level_index` will be included in the result, otherwise it won't. 620 621 The distinct scores which NOT met the plate requirement will be included in the result, the finished scores won't. 622 """ 623 scores_dict: dict[int, list[Score]] = {} 624 [scores_dict.setdefault(score.id, []).append(score) for score in self.scores] 625 results = { 626 song.id: PlateObject(song=song, levels=song._get_level_indexes(self.major_type, self.no_remaster), scores=scores_dict.get(song.id, [])) 627 for song in self.songs 628 } 629 630 def extract(score: Score) -> None: 631 results[score.id].scores.remove(score) 632 if score.level_index in results[score.id].levels: 633 results[score.id].levels.remove(score.level_index) 634 635 if self.kind == "者": 636 [extract(score) for score in self.scores if score.rate.value <= RateType.A.value] 637 elif self.kind == "将": 638 [extract(score) for score in self.scores if score.rate.value <= RateType.SSS.value] 639 elif self.kind == "极": 640 [extract(score) for score in self.scores if score.fc and score.fc.value <= FCType.FC.value] 641 elif self.kind == "舞舞": 642 [extract(score) for score in self.scores if score.fs and score.fs.value <= FSType.FSD.value] 643 elif self.kind == "神": 644 [extract(score) for score in self.scores if score.fc and score.fc.value <= FCType.AP.value] 645 646 return [plate for plate in results.values() if plate.levels != []]
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.
648 @cached_property 649 def cleared(self) -> list[PlateObject]: 650 """Get the cleared songs and scores of the player on this plate. 651 652 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. 653 654 The distinct scores which met the plate requirement will be included in the result, the unfinished scores won't. 655 """ 656 results = {song.id: PlateObject(song=song, levels=[], scores=[]) for song in self.songs} 657 658 def insert(score: Score) -> None: 659 results[score.id].scores.append(score) 660 results[score.id].levels.append(score.level_index) 661 662 if self.kind == "者": 663 [insert(score) for score in self.scores if score.rate.value <= RateType.A.value] 664 elif self.kind == "将": 665 [insert(score) for score in self.scores if score.rate.value <= RateType.SSS.value] 666 elif self.kind == "极": 667 [insert(score) for score in self.scores if score.fc and score.fc.value <= FCType.FC.value] 668 elif self.kind == "舞舞": 669 [insert(score) for score in self.scores if score.fs and score.fs.value <= FSType.FSD.value] 670 elif self.kind == "神": 671 [insert(score) for score in self.scores if score.fc and score.fc.value <= FCType.AP.value] 672 673 return [plate for plate in results.values() if plate.levels != []]
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.
675 @cached_property 676 def played(self) -> list[PlateObject]: 677 """Get the played songs and scores of the player on this plate. 678 679 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. 680 681 All distinct scores will be included in the result. 682 """ 683 results = {song.id: PlateObject(song=song, levels=[], scores=[]) for song in self.songs} 684 for score in self.scores: 685 results[score.id].scores.append(score) 686 results[score.id].levels.append(score.level_index) 687 return [plate for plate in results.values() if plate.levels != []]
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.
689 @cached_property 690 def all(self) -> Iterator[PlateObject]: 691 """Get all songs on this plate, usually used for statistics of the plate. 692 693 All songs will be included in the result, with all levels, whether they met or not. 694 695 No scores will be included in the result, use played, cleared, remained to get the scores. 696 """ 697 698 return iter(PlateObject(song=song, levels=song._get_level_indexes(self.major_type, self.no_remaster), scores=[]) for song in self.songs)
Get all songs on this plate, usually used for statistics of the plate.
All songs will be included in the result, with all levels, whether they met or not.
No scores will be included in the result, use played, cleared, remained to get the scores.
700 @cached_property 701 def played_num(self) -> int: 702 """Get the number of played levels on this plate.""" 703 return len([level for plate in self.played for level in plate.levels])
Get the number of played levels on this plate.
705 @cached_property 706 def cleared_num(self) -> int: 707 """Get the number of cleared levels on this plate.""" 708 return len([level for plate in self.cleared for level in plate.levels])
Get the number of cleared levels on this plate.
710 @cached_property 711 def remained_num(self) -> int: 712 """Get the number of remained levels on this plate.""" 713 return len([level for plate in self.remained for level in plate.levels])
Get the number of remained levels on this plate.
715 @cached_property 716 def all_num(self) -> int: 717 """Get the number of all levels on this plate. 718 719 This is the total number of levels on the plate, should equal to `cleared_num + remained_num`. 720 """ 721 return len([level for plate in self.all for level in plate.levels])
Get the number of all levels on this plate.
This is the total number of levels on the plate, should equal to cleared_num + remained_num
.
724class MaimaiScores: 725 scores: list[Score] 726 """All scores of the player when `ScoreKind.ALL`, otherwise only the b50 scores.""" 727 scores_b35: list[Score] 728 """The b35 scores of the player.""" 729 scores_b15: list[Score] 730 """The b15 scores of the player.""" 731 rating: int 732 """The total rating of the player.""" 733 rating_b35: int 734 """The b35 rating of the player.""" 735 rating_b15: int 736 """The b15 rating of the player.""" 737 738 @staticmethod 739 def _get_distinct_scores(scores: list[Score]) -> list[Score]: 740 scores_unique = {} 741 for score in scores: 742 score_key = f"{score.id} {score.type} {score.level_index}" 743 scores_unique[score_key] = score._compare(scores_unique.get(score_key, None)) 744 return list(scores_unique.values()) 745 746 def __init__( 747 self, b35: list[Score] | None = None, b15: list[Score] | None = None, all: list[Score] | None = None, songs: MaimaiSongs | None = None 748 ): 749 self.scores = all or (b35 + b15 if b35 and b15 else None) or [] 750 # if b35 and b15 are not provided, try to calculate them from all scores 751 if (not b35 or not b15) and all: 752 distinct_scores = MaimaiScores._get_distinct_scores(all) # scores have to be distinct to calculate the bests 753 scores_new: list[Score] = [] 754 scores_old: list[Score] = [] 755 for score in distinct_scores: 756 if songs and (diff := score.difficulty): 757 (scores_new if diff.version >= current_version.value else scores_old).append(score) 758 scores_old.sort(key=lambda score: (score.dx_rating, score.dx_score, score.achievements), reverse=True) 759 scores_new.sort(key=lambda score: (score.dx_rating, score.dx_score, score.achievements), reverse=True) 760 b35 = scores_old[:35] 761 b15 = scores_new[:15] 762 self.scores_b35 = b35 or [] 763 self.scores_b15 = b15 or [] 764 self.rating_b35 = int(sum((score.dx_rating or 0) for score in b35) if b35 else 0) 765 self.rating_b15 = int(sum((score.dx_rating or 0) for score in b15) if b15 else 0) 766 self.rating = self.rating_b35 + self.rating_b15 767 768 @property 769 def as_distinct(self) -> "MaimaiScores": 770 """Get the distinct scores. 771 772 Normally, player has more than one score for the same song and level, this method will return a new `MaimaiScores` object with the highest scores for each song and level. 773 774 This method won't modify the original scores object, it will return a new one. 775 776 If ScoreKind is BEST, this won't make any difference, because the scores are already the best ones. 777 """ 778 distinct_scores = MaimaiScores._get_distinct_scores(self.scores) 779 songs: MaimaiSongs = default_caches._caches["msongs"] 780 assert songs is not None and isinstance(songs, MaimaiSongs) 781 return MaimaiScores(b35=self.scores_b35, b15=self.scores_b15, all=distinct_scores, songs=songs) 782 783 def by_song( 784 self, song_id: int, song_type: SongType | _UnsetSentinel = UNSET, level_index: LevelIndex | _UnsetSentinel = UNSET 785 ) -> Iterator[Score]: 786 """Get scores of the song on that type and level_index. 787 788 If song_type or level_index is not provided, all scores of the song will be returned. 789 790 Args: 791 song_id: the ID of the song to get the scores by. 792 song_type: the type of the song to get the scores by, defaults to None. 793 level_index: the level index of the song to get the scores by, defaults to None. 794 Returns: 795 the list of scores of the song, return an empty list if no score is found. 796 """ 797 for score in self.scores: 798 if score.id != song_id: 799 continue 800 if song_type is not UNSET and score.type != song_type: 801 continue 802 if level_index is not UNSET and score.level_index != level_index: 803 continue 804 yield score 805 806 def filter(self, **kwargs) -> list[Score]: 807 """Filter scores by their attributes. 808 809 Make sure the attribute is of the score, and the value is of the same type. All conditions are connected by AND. 810 811 Args: 812 kwargs: the attributes to filter the scores by. 813 Returns: 814 the list of scores that match all the conditions, return an empty list if no score is found. 815 """ 816 return [score for score in self.scores if all(getattr(score, key) == value for key, value in kwargs.items())]
746 def __init__( 747 self, b35: list[Score] | None = None, b15: list[Score] | None = None, all: list[Score] | None = None, songs: MaimaiSongs | None = None 748 ): 749 self.scores = all or (b35 + b15 if b35 and b15 else None) or [] 750 # if b35 and b15 are not provided, try to calculate them from all scores 751 if (not b35 or not b15) and all: 752 distinct_scores = MaimaiScores._get_distinct_scores(all) # scores have to be distinct to calculate the bests 753 scores_new: list[Score] = [] 754 scores_old: list[Score] = [] 755 for score in distinct_scores: 756 if songs and (diff := score.difficulty): 757 (scores_new if diff.version >= current_version.value else scores_old).append(score) 758 scores_old.sort(key=lambda score: (score.dx_rating, score.dx_score, score.achievements), reverse=True) 759 scores_new.sort(key=lambda score: (score.dx_rating, score.dx_score, score.achievements), reverse=True) 760 b35 = scores_old[:35] 761 b15 = scores_new[:15] 762 self.scores_b35 = b35 or [] 763 self.scores_b15 = b15 or [] 764 self.rating_b35 = int(sum((score.dx_rating or 0) for score in b35) if b35 else 0) 765 self.rating_b15 = int(sum((score.dx_rating or 0) for score in b15) if b15 else 0) 766 self.rating = self.rating_b35 + self.rating_b15
768 @property 769 def as_distinct(self) -> "MaimaiScores": 770 """Get the distinct scores. 771 772 Normally, player has more than one score for the same song and level, this method will return a new `MaimaiScores` object with the highest scores for each song and level. 773 774 This method won't modify the original scores object, it will return a new one. 775 776 If ScoreKind is BEST, this won't make any difference, because the scores are already the best ones. 777 """ 778 distinct_scores = MaimaiScores._get_distinct_scores(self.scores) 779 songs: MaimaiSongs = default_caches._caches["msongs"] 780 assert songs is not None and isinstance(songs, MaimaiSongs) 781 return MaimaiScores(b35=self.scores_b35, b15=self.scores_b15, all=distinct_scores, songs=songs)
Get the distinct scores.
Normally, player has more than one score for the same song and level, this method will return a new MaimaiScores
object with the highest scores for each song and level.
This method won't modify the original scores object, it will return a new one.
If ScoreKind is BEST, this won't make any difference, because the scores are already the best ones.
783 def by_song( 784 self, song_id: int, song_type: SongType | _UnsetSentinel = UNSET, level_index: LevelIndex | _UnsetSentinel = UNSET 785 ) -> Iterator[Score]: 786 """Get scores of the song on that type and level_index. 787 788 If song_type or level_index is not provided, all scores of the song will be returned. 789 790 Args: 791 song_id: the ID of the song to get the scores by. 792 song_type: the type of the song to get the scores by, defaults to None. 793 level_index: the level index of the song to get the scores by, defaults to None. 794 Returns: 795 the list of scores of the song, return an empty list if no score is found. 796 """ 797 for score in self.scores: 798 if score.id != song_id: 799 continue 800 if song_type is not UNSET and score.type != song_type: 801 continue 802 if level_index is not UNSET and score.level_index != level_index: 803 continue 804 yield score
Get scores of the song on that type and level_index.
If song_type or level_index is not provided, all scores of the song will be returned.
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:
the list of scores of the song, return an empty list if no score is found.
806 def filter(self, **kwargs) -> list[Score]: 807 """Filter scores by their attributes. 808 809 Make sure the attribute is of the score, and the value is of the same type. All conditions are connected by AND. 810 811 Args: 812 kwargs: the attributes to filter the scores by. 813 Returns: 814 the list of scores that match all the conditions, return an empty list if no score is found. 815 """ 816 return [score for score in self.scores if all(getattr(score, key) == value for key, value in kwargs.items())]
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:
the list of scores that match all the conditions, return an empty list if no score is found.
819class MaimaiAreas: 820 lang: str 821 """The language of the areas.""" 822 823 _area_id_dict: dict[str, Area] # area_id: area 824 825 def __init__(self, lang: str, areas: dict[str, Area]) -> None: 826 """@private""" 827 self.lang = lang 828 self._area_id_dict = areas 829 830 @property 831 def values(self) -> Iterator[Area]: 832 """All areas as list.""" 833 return iter(self._area_id_dict.values()) 834 835 def by_id(self, id: str) -> Area | None: 836 """Get an area by its ID. 837 838 Args: 839 id: the ID of the area. 840 Returns: 841 the area if it exists, otherwise return None. 842 """ 843 return self._area_id_dict.get(id, None) 844 845 def by_name(self, name: str) -> Area | None: 846 """Get an area by its name, language-sensitive. 847 848 Args: 849 name: the name of the area. 850 Returns: 851 the area if it exists, otherwise return None. 852 """ 853 return next((area for area in self.values if area.name == name), None)
830 @property 831 def values(self) -> Iterator[Area]: 832 """All areas as list.""" 833 return iter(self._area_id_dict.values())
All areas as list.
835 def by_id(self, id: str) -> Area | None: 836 """Get an area by its ID. 837 838 Args: 839 id: the ID of the area. 840 Returns: 841 the area if it exists, otherwise return None. 842 """ 843 return self._area_id_dict.get(id, None)
Get an area by its ID.
Arguments:
- id: the ID of the area.
Returns:
the area if it exists, otherwise return None.
845 def by_name(self, name: str) -> Area | None: 846 """Get an area by its name, language-sensitive. 847 848 Args: 849 name: the name of the area. 850 Returns: 851 the area if it exists, otherwise return None. 852 """ 853 return next((area for area in self.values 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.