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)
@dataclass
class Song:
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)
Song( id: int, title: str, artist: str, genre: maimai_py.enums.Genre, bpm: int, map: str | None, version: int, rights: str | None, aliases: list[str] | None, disabled: bool, difficulties: SongDifficulties)
id: int
title: str
artist: str
bpm: int
map: str | None
version: int
rights: str | None
aliases: list[str] | None
disabled: bool
difficulties: SongDifficulties
def get_difficulty( self, type: maimai_py.enums.SongType, level_index: maimai_py.enums.LevelIndex | None) -> SongDifficulty | 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)
@dataclass
class SongDifficulties:
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
SongDifficulties( standard: list[SongDifficulty], dx: list[SongDifficulty], utage: list[SongDifficultyUtage])
standard: list[SongDifficulty]
dx: list[SongDifficulty]
utage: list[SongDifficultyUtage]
@dataclass
class CurveObject:
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]
CurveObject( sample_size: int, fit_level_value: float, avg_achievements: float, stdev_achievements: float, avg_dx_score: float, rate_sample_size: dict[maimai_py.enums.RateType, int], fc_sample_size: dict[maimai_py.enums.FCType, int])
sample_size: int
fit_level_value: float
avg_achievements: float
stdev_achievements: float
avg_dx_score: float
rate_sample_size: dict[maimai_py.enums.RateType, int]
fc_sample_size: dict[maimai_py.enums.FCType, int]
@dataclass
class SongDifficulty:
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
SongDifficulty( type: maimai_py.enums.SongType, level: str, level_value: float, level_index: maimai_py.enums.LevelIndex, note_designer: str, version: int, tap_num: int, hold_num: int, slide_num: int, touch_num: int, break_num: int, curve: CurveObject | None)
level: str
level_value: float
note_designer: str
version: int
tap_num: int
hold_num: int
slide_num: int
touch_num: int
break_num: int
curve: CurveObject | None
@dataclass
class SongDifficultyUtage(SongDifficulty):
85@dataclass
86class SongDifficultyUtage(SongDifficulty):
87    kanji: str
88    description: str
89    is_buddy: bool
SongDifficultyUtage( type: maimai_py.enums.SongType, level: str, level_value: float, level_index: maimai_py.enums.LevelIndex, note_designer: str, version: int, tap_num: int, hold_num: int, slide_num: int, touch_num: int, break_num: int, curve: CurveObject | None, kanji: str, description: str, is_buddy: bool)
kanji: str
description: str
is_buddy: bool
@dataclass
class PlayerIdentifier:
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")
PlayerIdentifier( qq: int | None = None, username: str | None = None, friend_code: int | None = None, credentials: str | httpx.Cookies | None = None)
qq: int | None = None
username: str | None = None
friend_code: int | None = None
credentials: str | httpx.Cookies | None = None
@dataclass
class CachedModel:
151@dataclass
152class CachedModel:
153    @staticmethod
154    def _cache_key() -> str:
155        raise NotImplementedError
@dataclass
class PlayerTrophy(CachedModel):
158@dataclass
159class PlayerTrophy(CachedModel):
160    id: int
161    name: str
162    color: str
163
164    @staticmethod
165    def _cache_key():
166        return "trophies"
PlayerTrophy(id: int, name: str, color: str)
id: int
name: str
color: str
@dataclass
class PlayerIcon(CachedModel):
169@dataclass
170class PlayerIcon(CachedModel):
171    id: int
172    name: str
173    description: str | None = None
174    genre: str | None = None
175
176    @staticmethod
177    def _cache_key():
178        return "icons"
PlayerIcon( id: int, name: str, description: str | None = None, genre: str | None = None)
id: int
name: str
description: str | None = None
genre: str | None = None
@dataclass
class PlayerNamePlate(CachedModel):
181@dataclass
182class PlayerNamePlate(CachedModel):
183    id: int
184    name: str
185    description: str | None = None
186    genre: str | None = None
187
188    @staticmethod
189    def _cache_key():
190        return "nameplates"
PlayerNamePlate( id: int, name: str, description: str | None = None, genre: str | None = None)
id: int
name: str
description: str | None = None
genre: str | None = None
@dataclass
class PlayerFrame(CachedModel):
193@dataclass
194class PlayerFrame(CachedModel):
195    id: int
196    name: str
197    description: str | None = None
198    genre: str | None = None
199
200    @staticmethod
201    def _cache_key():
202        return "frames"
PlayerFrame( id: int, name: str, description: str | None = None, genre: str | None = None)
id: int
name: str
description: str | None = None
genre: str | None = None
@dataclass
class PlayerPartner(CachedModel):
205@dataclass
206class PlayerPartner(CachedModel):
207    id: int
208    name: str
209
210    @staticmethod
211    def _cache_key():
212        return "partners"
PlayerPartner(id: int, name: str)
id: int
name: str
@dataclass
class PlayerChara(CachedModel):
215@dataclass
216class PlayerChara(CachedModel):
217    id: int
218    name: str
219
220    @staticmethod
221    def _cache_key():
222        return "charas"
PlayerChara(id: int, name: str)
id: int
name: str
@dataclass
class PlayerRegion:
225@dataclass
226class PlayerRegion:
227    region_id: int
228    region_name: str
229    play_count: int
230    created_at: datetime
PlayerRegion( region_id: int, region_name: str, play_count: int, created_at: datetime.datetime)
region_id: int
region_name: str
play_count: int
created_at: datetime.datetime
@dataclass
class Player:
233@dataclass
234class Player:
235    name: str
236    rating: int
Player(name: str, rating: int)
name: str
rating: int
@dataclass
class DivingFishPlayer(Player):
239@dataclass
240class DivingFishPlayer(Player):
241    nickname: str
242    plate: str
243    additional_rating: int
DivingFishPlayer( name: str, rating: int, nickname: str, plate: str, additional_rating: int)
nickname: str
plate: str
additional_rating: int
Inherited Members
Player
name
rating
@dataclass
class LXNSPlayer(Player):
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
LXNSPlayer( name: str, rating: int, friend_code: int, trophy: PlayerTrophy, course_rank: int, class_rank: int, star: int, icon: PlayerIcon | None, name_plate: PlayerNamePlate | None, frame: PlayerFrame | None, upload_time: str)
friend_code: int
trophy: PlayerTrophy
course_rank: int
class_rank: int
star: int
icon: PlayerIcon | None
name_plate: PlayerNamePlate | None
frame: PlayerFrame | None
upload_time: str
Inherited Members
Player
name
rating
@dataclass
class ArcadePlayer(Player):
259@dataclass
260class ArcadePlayer(Player):
261    is_login: bool
262    name_plate: PlayerNamePlate | None
263    icon: PlayerIcon | None
264    trophy: PlayerFrame | None
ArcadePlayer( name: str, rating: int, is_login: bool, name_plate: PlayerNamePlate | None, icon: PlayerIcon | None, trophy: PlayerFrame | None)
is_login: bool
name_plate: PlayerNamePlate | None
icon: PlayerIcon | None
trophy: PlayerFrame | None
Inherited Members
Player
name
rating
@dataclass
class AreaCharacter:
267@dataclass
268class AreaCharacter:
269    name: str
270    illustrator: str
271    description1: str
272    description2: str
273    team: str
274    props: dict[str, str]
AreaCharacter( name: str, illustrator: str, description1: str, description2: str, team: str, props: dict[str, str])
name: str
illustrator: str
description1: str
description2: str
team: str
props: dict[str, str]
@dataclass
class AreaSong:
277@dataclass
278class AreaSong:
279    id: int
280    title: str
281    artist: str
282    description: str
283    illustrator: str | None
284    movie: str | None
AreaSong( id: int, title: str, artist: str, description: str, illustrator: str | None, movie: str | None)
id: int
title: str
artist: str
description: str
illustrator: str | None
movie: str | None
@dataclass
class Area:
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]
Area( id: str, name: str, comment: str, description: str, video_id: str, characters: list[AreaCharacter], songs: list[AreaSong])
id: str
name: str
comment: str
description: str
video_id: str
characters: list[AreaCharacter]
songs: list[AreaSong]
@dataclass
class Score:
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)
Score( id: int, song_name: str, level: str, level_index: maimai_py.enums.LevelIndex, achievements: float | None, fc: maimai_py.enums.FCType | None, fs: maimai_py.enums.FSType | None, dx_score: int | None, dx_rating: float | None, rate: maimai_py.enums.RateType, type: maimai_py.enums.SongType)
id: int
song_name: str
level: str
achievements: float | None
dx_score: int | None
dx_rating: float | None
song: Song | None
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)
difficulty: SongDifficulty | None
339    @property
340    def difficulty(self) -> SongDifficulty | None:
341        if self.song:
342            return self.song.get_difficulty(self.type, self.level_index)
@dataclass
class PlateObject:
345@dataclass
346class PlateObject:
347    song: Song
348    levels: list[LevelIndex]
349    scores: list[Score]
PlateObject( song: Song, levels: list[maimai_py.enums.LevelIndex], scores: list[Score])
song: Song
scores: list[Score]
class MaimaiItems(typing.Generic[~CachedType]):
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
values: Iterator[~CachedType]
362    @property
363    def values(self) -> Iterator[CachedType]:
364        """All items as list."""
365        return iter(self._cached_items.values())

All items as list.

def by_id(self, id: int) -> Optional[~CachedType]:
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.

def filter(self, **kwargs) -> list[~CachedType]:
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.

class MaimaiSongs:
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)]
songs: Iterator[Song]
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.

def by_id(self, id: int) -> Song | None:
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.

def by_title(self, title: str) -> Song | 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.

def by_alias(self, alias: str) -> Song | 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.

def by_artist(self, artist: str) -> list[Song]:
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.

def by_genre(self, genre: maimai_py.enums.Genre) -> list[Song]:
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.

def by_bpm(self, minimum: int, maximum: int) -> list[Song]:
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.

def by_versions(self, versions: maimai_py.enums.Version) -> list[Song]:
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.

def by_keywords(self, keywords: str) -> list[Song]:
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.

def filter(self, **kwargs) -> list[Song]:
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.

class MaimaiPlates:
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])
scores: list[Score]

The scores that match the plate version and kind.

songs: list[Song]

The songs that match the plate version and kind.

version: str

The version of the plate, e.g. "真", "舞".

kind: str

The kind of the plate, e.g. "将", "神".

no_remaster: bool
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.

major_type: maimai_py.enums.SongType
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.

remained: list[PlateObject]
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.

cleared: list[PlateObject]
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.

played: list[PlateObject]
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.

all: Iterator[PlateObject]
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.

played_num: int
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.

cleared_num: int
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.

remained_num: int
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.

all_num: int
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.

class MaimaiScores:
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())]
MaimaiScores( b35: list[Score] | None = None, b15: list[Score] | None = None, all: list[Score] | None = None, songs: MaimaiSongs | None = None)
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
scores: list[Score]

All scores of the player when ScoreKind.ALL, otherwise only the b50 scores.

scores_b35: list[Score]

The b35 scores of the player.

scores_b15: list[Score]

The b15 scores of the player.

rating: int

The total rating of the player.

rating_b35: int

The b35 rating of the player.

rating_b15: int

The b15 rating of the player.

as_distinct: MaimaiScores
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.

def by_song( self, song_id: int, song_type: maimai_py.enums.SongType | maimai_py.utils.sentinel._UnsetSentinel = Unset, level_index: maimai_py.enums.LevelIndex | maimai_py.utils.sentinel._UnsetSentinel = Unset) -> Iterator[Score]:
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.

def filter(self, **kwargs) -> list[Score]:
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.

class MaimaiAreas:
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)
lang: str

The language of the areas.

values: Iterator[Area]
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.

def by_id(self, id: str) -> Area | None:
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.

def by_name(self, name: str) -> Area | 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.