maimai_py
1from .maimai import MaimaiClient 2from .exceptions import MaimaiPyError 3from .models import MaimaiSongs, MaimaiPlates, MaimaiScores, MaimaiItems 4from .providers import DivingFishProvider, LXNSProvider, YuzuProvider, WechatProvider, ArcadeProvider, LocalProvider 5 6# extended models and enums 7from .enums import ScoreKind, LevelIndex, FCType, FSType, RateType, SongType 8from .models import DivingFishPlayer, LXNSPlayer, ArcadePlayer, Score, PlateObject 9from .models import Song, SongDifficulties, SongDifficulty, SongDifficultyUtage, CurveObject 10from .models import PlayerIdentifier, PlayerTrophy, PlayerIcon, PlayerNamePlate, PlayerFrame, PlayerPartner, PlayerChara, PlayerRegion 11 12 13__all__ = [ 14 "MaimaiClient", 15 "MaimaiScores", 16 "MaimaiPlates", 17 "MaimaiSongs", 18 "MaimaiItems", 19 "models", 20 "enums", 21 "exceptions", 22 "providers", 23]
15class MaimaiClient: 16 """The main client of maimai.py.""" 17 18 default_caches._caches_provider["songs"] = LXNSProvider() 19 default_caches._caches_provider["aliases"] = YuzuProvider() 20 default_caches._caches_provider["curves"] = DivingFishProvider() 21 default_caches._caches_provider["icons"] = LXNSProvider() 22 default_caches._caches_provider["nameplates"] = LXNSProvider() 23 default_caches._caches_provider["frames"] = LXNSProvider() 24 default_caches._caches_provider["trophies"] = LocalProvider() 25 default_caches._caches_provider["charas"] = LocalProvider() 26 default_caches._caches_provider["partners"] = LocalProvider() 27 28 _client: AsyncClient 29 30 def __init__(self, timeout: float = 20.0, **kwargs) -> None: 31 """Initialize the maimai.py client. 32 33 Args: 34 timeout: the timeout of the requests, defaults to 20.0. 35 """ 36 self._client = httpx.AsyncClient(timeout=timeout, **kwargs) 37 38 async def songs( 39 self, 40 flush=False, 41 provider: ISongProvider | _UnsetSentinel = UNSET, 42 alias_provider: IAliasProvider | _UnsetSentinel = UNSET, 43 curve_provider: ICurveProvider | _UnsetSentinel = UNSET, 44 ) -> MaimaiSongs: 45 """Fetch all maimai songs from the provider. 46 47 Available providers: `DivingFishProvider`, `LXNSProvider`. 48 49 Available alias providers: `YuzuProvider`, `LXNSProvider`. 50 51 Available curve providers: `DivingFishProvider`. 52 53 Args: 54 flush: whether to flush the cache, defaults to False. 55 provider: override the data source to fetch the player from, defaults to `LXNSProvider`. 56 alias_provider: override the data source to fetch the song aliases from, defaults to `YuzuProvider`. 57 curve_provider: override the data source to fetch the song curves from, defaults to `DivingFishProvider`. 58 Returns: 59 A wrapper of the song list, for easier access and filtering. 60 Raises: 61 httpx.HTTPError: Request failed due to network issues. 62 """ 63 if provider is not UNSET and default_caches._caches_provider["songs"] != provider: 64 default_caches._caches_provider["songs"] = provider 65 flush = True 66 if alias_provider is not UNSET and default_caches._caches_provider["aliases"] != alias_provider: 67 default_caches._caches_provider["aliases"] = alias_provider 68 flush = True 69 if curve_provider is not UNSET and default_caches._caches_provider["curves"] != curve_provider: 70 default_caches._caches_provider["curves"] = curve_provider 71 flush = True 72 return await MaimaiSongs._get_or_fetch(self._client, flush=flush) 73 74 async def players( 75 self, 76 identifier: PlayerIdentifier, 77 provider: IPlayerProvider = LXNSProvider(), 78 ) -> Player: 79 """Fetch player data from the provider. 80 81 Available providers: `DivingFishProvider`, `LXNSProvider`, `ArcadeProvider`. 82 83 Possible returns: `DivingFishPlayer`, `LXNSPlayer`, `ArcadePlayer`. 84 85 Args: 86 identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(username="turou")`. 87 provider: the data source to fetch the player from, defaults to `LXNSProvider`. 88 Returns: 89 The player object of the player, with all the data fetched. Depending on the provider, it may contain different objects that derived from `Player`. 90 Raises: 91 InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found. 92 InvalidDeveloperTokenError: Developer token is not provided or token is invalid. 93 PrivacyLimitationError: The user has not accepted the 3rd party to access the data. 94 httpx.HTTPError: Request failed due to network issues. 95 Raises: 96 TitleServerError: Only for ArcadeProvider, maimai title server related errors, possibly network problems. 97 ArcadeError: Only for ArcadeProvider, maimai response is invalid, or user id is invalid. 98 """ 99 return await provider.get_player(identifier, self._client) 100 101 async def scores( 102 self, 103 identifier: PlayerIdentifier, 104 kind: ScoreKind = ScoreKind.BEST, 105 provider: IScoreProvider = LXNSProvider(), 106 ) -> MaimaiScores: 107 """Fetch player's scores from the provider. 108 109 For WechatProvider, PlayerIdentifier must have the `credentials` attribute, we suggest you to use the `maimai.wechat()` method to get the identifier. 110 Also, PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time. 111 112 For ArcadeProvider, PlayerIdentifier must have the `credentials` attribute, which is the player's encrypted userId, can be detrived from `maimai.qrcode()`. 113 Credentials can be reused, since it won't expire, also, userId is encrypted, can't be used in any other cases outside the maimai.py 114 115 Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`, `ArcadeProvider`. 116 117 Args: 118 identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`. 119 kind: the kind of scores list to fetch, defaults to `ScoreKind.BEST`. 120 provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`. 121 Returns: 122 The scores object of the player, with all the data fetched. 123 Raises: 124 InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found. 125 InvalidDeveloperTokenError: Developer token is not provided or token is invalid. 126 PrivacyLimitationError: The user has not accepted the 3rd party to access the data. 127 httpx.HTTPError: Request failed due to network issues. 128 Raises: 129 TitleServerError: Only for ArcadeProvider, maimai title server related errors, possibly network problems. 130 ArcadeError: Only for ArcadeProvider, maimai response is invalid, or user id is invalid. 131 """ 132 # MaimaiScores should always cache b35 and b15 scores, in ScoreKind.ALL cases, we can calc the b50 scores from all scores. 133 # But there is one exception, LXNSProvider's ALL scores are incomplete, which doesn't contain dx_rating and achievements, leading to sorting difficulties. 134 # In this case, we should always fetch the b35 and b15 scores for LXNSProvider. 135 await MaimaiSongs._get_or_fetch(self._client) # Cache the songs first, as we need to use it for scores' property. 136 b35, b15, all, songs = None, None, None, None 137 if kind == ScoreKind.BEST or isinstance(provider, LXNSProvider): 138 b35, b15 = await provider.get_scores_best(identifier, self._client) 139 # For some cases, the provider doesn't support fetching b35 and b15 scores, we should fetch all scores instead. 140 if kind == ScoreKind.ALL or (b35 == None and b15 == None): 141 songs = await MaimaiSongs._get_or_fetch(self._client) 142 all = await provider.get_scores_all(identifier, self._client) 143 return MaimaiScores(b35, b15, all, songs) 144 145 async def regions(self, identifier: PlayerIdentifier, provider: IRegionProvider = ArcadeProvider()) -> list[PlayerRegion]: 146 """Get the player's regions that they have played. 147 148 Args: 149 identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(credentials="encrypted_user_id")`. 150 provider: the data source to fetch the player from, defaults to `ArcadeProvider`. 151 Returns: 152 The list of regions that the player has played. 153 Raises: 154 TitleServerError: Only for ArcadeProvider, maimai title server related errors, possibly network problems. 155 ArcadeError: Only for ArcadeProvider, maimai response is invalid, or user id is invalid. 156 """ 157 return await provider.get_regions(identifier, self._client) 158 159 async def updates( 160 self, 161 identifier: PlayerIdentifier, 162 scores: list[Score], 163 provider: IScoreProvider = LXNSProvider(), 164 ) -> None: 165 """Update player's scores to the provider. 166 167 For Diving Fish, the player identifier should be the player's username and password, or import token, e.g.: 168 169 `PlayerIdentifier(username="turou", credentials="password")` or `PlayerIdentifier(credentials="my_diving_fish_import_token")`. 170 171 Available providers: `DivingFishProvider`, `LXNSProvider`. 172 173 Args: 174 identifier: the identifier of the player to update, e.g. `PlayerIdentifier(friend_code=664994421382429)`. 175 scores: the scores to update, usually the scores fetched from other providers. 176 provider: the data source to update the player scores to, defaults to `LXNSProvider`. 177 Returns: 178 Nothing, failures will raise exceptions. 179 Raises: 180 InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found, or the import token / password is invalid. 181 InvalidDeveloperTokenError: Developer token is not provided or token is invalid. 182 PrivacyLimitationError: The user has not accepted the 3rd party to access the data. 183 httpx.HTTPError: Request failed due to network issues. 184 """ 185 await provider.update_scores(identifier, scores, self._client) 186 187 async def plates( 188 self, 189 identifier: PlayerIdentifier, 190 plate: str, 191 provider: IScoreProvider = LXNSProvider(), 192 ) -> MaimaiPlates: 193 """Get the plate achievement of the given player and plate. 194 195 Available providers: `DivingFishProvider`, `LXNSProvider`, `ArcadeProvider`. 196 197 Args: 198 identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`. 199 plate: the name of the plate, e.g. "樱将", "真舞舞". 200 provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`. 201 Returns: 202 A wrapper of the plate achievement, with plate information, and matched player scores. 203 Raises: 204 InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found. 205 InvalidPlateError: Provided version or plate is invalid. 206 InvalidDeveloperTokenError: Developer token is not provided or token is invalid. 207 PrivacyLimitationError: The user has not accepted the 3rd party to access the data. 208 httpx.HTTPError: Request failed due to network issues. 209 """ 210 songs = await MaimaiSongs._get_or_fetch(self._client) 211 scores = await provider.get_scores_all(identifier, self._client) 212 return MaimaiPlates(scores, plate[0], plate[1:], songs) 213 214 async def wechat(self, r=None, t=None, code=None, state=None) -> PlayerIdentifier | str: 215 """Get the player identifier from the Wahlap Wechat OffiAccount. 216 217 Call the method with no parameters to get the URL, then redirect the user to the URL with your mitmproxy enabled. 218 219 Your mitmproxy should intercept the response from tgk-wcaime.wahlap.com, then call the method with the parameters from the intercepted response. 220 221 With the parameters from specific user's response, the method will return the user's player identifier. 222 223 Never cache or store the player identifier, as the cookies may expire at any time. 224 225 Args: 226 r: the r parameter from the request, defaults to None. 227 t: the t parameter from the request, defaults to None. 228 code: the code parameter from the request, defaults to None. 229 state: the state parameter from the request, defaults to None. 230 Returns: 231 The player identifier if all parameters are provided, otherwise return the URL to get the identifier. 232 Raises: 233 WechatTokenExpiredError: Wechat token is expired, please re-authorize. 234 httpx.HTTPError: Request failed due to network issues. 235 """ 236 if not all([r, t, code, state]): 237 resp = await self._client.get("https://tgk-wcaime.wahlap.com/wc_auth/oauth/authorize/maimai-dx") 238 return resp.headers["location"].replace("redirect_uri=https", "redirect_uri=http") 239 params = {"r": r, "t": t, "code": code, "state": state} 240 headers = { 241 "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36 NetType/WIFI MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x6307001e)", 242 "Host": "tgk-wcaime.wahlap.com", 243 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", 244 } 245 resp = await self._client.get("https://tgk-wcaime.wahlap.com/wc_auth/oauth/callback/maimai-dx", params=params, headers=headers, timeout=5) 246 if resp.status_code == 302 and resp.next_request: 247 resp_next = await self._client.get(resp.next_request.url, headers=headers) 248 return PlayerIdentifier(credentials=resp_next.cookies) 249 else: 250 raise WechatTokenExpiredError("Wechat token is expired") 251 252 async def qrcode(self, qrcode: str, http_proxy: str | None = None) -> PlayerIdentifier: 253 """Get the player identifier from the Wahlap QR code. 254 255 Player identifier is the encrypted userId, can't be used in any other cases outside the maimai.py. 256 257 Args: 258 qrcode: the QR code of the player, should begin with SGWCMAID. 259 http_proxy: the http proxy to use for the request, defaults to None. 260 Returns: 261 The player identifier of the player. 262 Raises: 263 AimeServerError: Maimai Aime server error, may be invalid QR code or QR code has expired. 264 TitleServerError: Maimai title server related errors, possibly network problems. 265 """ 266 resp: ArcadeResponse = await arcade.get_uid_encrypted(qrcode, http_proxy=http_proxy) 267 ArcadeResponse._raise_for_error(resp) 268 if resp.data and isinstance(resp.data, bytes): 269 return PlayerIdentifier(credentials=resp.data.decode()) 270 else: 271 raise ArcadeError("Invalid QR code or QR code has expired") 272 273 async def items(self, item: Type[CachedType], flush=False, provider: IItemListProvider | _UnsetSentinel = UNSET) -> MaimaiItems[CachedType]: 274 """Fetch maimai player items from the cache default provider. 275 276 Available items: `PlayerIcon`, `PlayerNamePlate`, `PlayerFrame`, `PlayerTrophy`, `PlayerChara`, `PlayerPartner`. 277 278 Args: 279 item: the item type to fetch, e.g. `PlayerIcon`. 280 flush: whether to flush the cache, defaults to False. 281 provider: override the default item list provider, defaults to `LXNSProvider` and `LocalProvider`. 282 Returns: 283 A wrapper of the item list, for easier access and filtering. 284 Raises: 285 FileNotFoundError: The item file is not found. 286 httpx.HTTPError: Request failed due to network issues. 287 """ 288 if provider and provider is not UNSET: 289 default_caches._caches_provider[item._cache_key()] = provider 290 items = await default_caches.get_or_fetch(item._cache_key(), self._client, flush=flush) 291 return MaimaiItems[CachedType](items) 292 293 async def areas(self, lang: Literal["ja", "zh"] = "ja", provider: IAreaProvider = LocalProvider()) -> MaimaiAreas: 294 """Fetch maimai areas from the provider. 295 296 Available providers: `LocalProvider`. 297 298 Args: 299 lang: the language of the area to fetch, available languages: `ja`, `zh`. 300 provider: override the default area provider, defaults to `ArcadeProvider`. 301 Returns: 302 A wrapper of the area list, for easier access and filtering. 303 Raises: 304 FileNotFoundError: The area file is not found. 305 """ 306 307 return MaimaiAreas(lang, await provider.get_areas(lang, self._client)) 308 309 async def flush(self) -> None: 310 """Flush the caches of the client, this will perform a full re-fetch of all the data. 311 312 Notice that only items ("songs", "aliases", "curves", "icons", "plates", "frames", "trophy", "chara", "partner") will be cached, this will only affect those items. 313 """ 314 await default_caches.flush(self._client)
The main client of maimai.py.
30 def __init__(self, timeout: float = 20.0, **kwargs) -> None: 31 """Initialize the maimai.py client. 32 33 Args: 34 timeout: the timeout of the requests, defaults to 20.0. 35 """ 36 self._client = httpx.AsyncClient(timeout=timeout, **kwargs)
Initialize the maimai.py client.
Arguments:
- timeout: the timeout of the requests, defaults to 20.0.
38 async def songs( 39 self, 40 flush=False, 41 provider: ISongProvider | _UnsetSentinel = UNSET, 42 alias_provider: IAliasProvider | _UnsetSentinel = UNSET, 43 curve_provider: ICurveProvider | _UnsetSentinel = UNSET, 44 ) -> MaimaiSongs: 45 """Fetch all maimai songs from the provider. 46 47 Available providers: `DivingFishProvider`, `LXNSProvider`. 48 49 Available alias providers: `YuzuProvider`, `LXNSProvider`. 50 51 Available curve providers: `DivingFishProvider`. 52 53 Args: 54 flush: whether to flush the cache, defaults to False. 55 provider: override the data source to fetch the player from, defaults to `LXNSProvider`. 56 alias_provider: override the data source to fetch the song aliases from, defaults to `YuzuProvider`. 57 curve_provider: override the data source to fetch the song curves from, defaults to `DivingFishProvider`. 58 Returns: 59 A wrapper of the song list, for easier access and filtering. 60 Raises: 61 httpx.HTTPError: Request failed due to network issues. 62 """ 63 if provider is not UNSET and default_caches._caches_provider["songs"] != provider: 64 default_caches._caches_provider["songs"] = provider 65 flush = True 66 if alias_provider is not UNSET and default_caches._caches_provider["aliases"] != alias_provider: 67 default_caches._caches_provider["aliases"] = alias_provider 68 flush = True 69 if curve_provider is not UNSET and default_caches._caches_provider["curves"] != curve_provider: 70 default_caches._caches_provider["curves"] = curve_provider 71 flush = True 72 return await MaimaiSongs._get_or_fetch(self._client, flush=flush)
Fetch all maimai songs from the provider.
Available providers: DivingFishProvider
, LXNSProvider
.
Available alias providers: YuzuProvider
, LXNSProvider
.
Available curve providers: DivingFishProvider
.
Arguments:
- flush: whether to flush the cache, defaults to False.
- provider: override the data source to fetch the player from, defaults to
LXNSProvider
. - alias_provider: override the data source to fetch the song aliases from, defaults to
YuzuProvider
. - curve_provider: override the data source to fetch the song curves from, defaults to
DivingFishProvider
.
Returns:
A wrapper of the song list, for easier access and filtering.
Raises:
- httpx.HTTPError: Request failed due to network issues.
74 async def players( 75 self, 76 identifier: PlayerIdentifier, 77 provider: IPlayerProvider = LXNSProvider(), 78 ) -> Player: 79 """Fetch player data from the provider. 80 81 Available providers: `DivingFishProvider`, `LXNSProvider`, `ArcadeProvider`. 82 83 Possible returns: `DivingFishPlayer`, `LXNSPlayer`, `ArcadePlayer`. 84 85 Args: 86 identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(username="turou")`. 87 provider: the data source to fetch the player from, defaults to `LXNSProvider`. 88 Returns: 89 The player object of the player, with all the data fetched. Depending on the provider, it may contain different objects that derived from `Player`. 90 Raises: 91 InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found. 92 InvalidDeveloperTokenError: Developer token is not provided or token is invalid. 93 PrivacyLimitationError: The user has not accepted the 3rd party to access the data. 94 httpx.HTTPError: Request failed due to network issues. 95 Raises: 96 TitleServerError: Only for ArcadeProvider, maimai title server related errors, possibly network problems. 97 ArcadeError: Only for ArcadeProvider, maimai response is invalid, or user id is invalid. 98 """ 99 return await provider.get_player(identifier, self._client)
Fetch player data from the provider.
Available providers: DivingFishProvider
, LXNSProvider
, ArcadeProvider
.
Possible returns: DivingFishPlayer
, LXNSPlayer
, ArcadePlayer
.
Arguments:
- identifier: the identifier of the player to fetch, e.g.
PlayerIdentifier(username="turou")
. - provider: the data source to fetch the player from, defaults to
LXNSProvider
.
Returns:
The player object of the player, with all the data fetched. Depending on the provider, it may contain different objects that derived from
Player
.
Raises:
- InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
- InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
- PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
- httpx.HTTPError: Request failed due to network issues.
Raises:
- TitleServerError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
- ArcadeError: Only for ArcadeProvider, maimai response is invalid, or user id is invalid.
101 async def scores( 102 self, 103 identifier: PlayerIdentifier, 104 kind: ScoreKind = ScoreKind.BEST, 105 provider: IScoreProvider = LXNSProvider(), 106 ) -> MaimaiScores: 107 """Fetch player's scores from the provider. 108 109 For WechatProvider, PlayerIdentifier must have the `credentials` attribute, we suggest you to use the `maimai.wechat()` method to get the identifier. 110 Also, PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time. 111 112 For ArcadeProvider, PlayerIdentifier must have the `credentials` attribute, which is the player's encrypted userId, can be detrived from `maimai.qrcode()`. 113 Credentials can be reused, since it won't expire, also, userId is encrypted, can't be used in any other cases outside the maimai.py 114 115 Available providers: `DivingFishProvider`, `LXNSProvider`, `WechatProvider`, `ArcadeProvider`. 116 117 Args: 118 identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`. 119 kind: the kind of scores list to fetch, defaults to `ScoreKind.BEST`. 120 provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`. 121 Returns: 122 The scores object of the player, with all the data fetched. 123 Raises: 124 InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found. 125 InvalidDeveloperTokenError: Developer token is not provided or token is invalid. 126 PrivacyLimitationError: The user has not accepted the 3rd party to access the data. 127 httpx.HTTPError: Request failed due to network issues. 128 Raises: 129 TitleServerError: Only for ArcadeProvider, maimai title server related errors, possibly network problems. 130 ArcadeError: Only for ArcadeProvider, maimai response is invalid, or user id is invalid. 131 """ 132 # MaimaiScores should always cache b35 and b15 scores, in ScoreKind.ALL cases, we can calc the b50 scores from all scores. 133 # But there is one exception, LXNSProvider's ALL scores are incomplete, which doesn't contain dx_rating and achievements, leading to sorting difficulties. 134 # In this case, we should always fetch the b35 and b15 scores for LXNSProvider. 135 await MaimaiSongs._get_or_fetch(self._client) # Cache the songs first, as we need to use it for scores' property. 136 b35, b15, all, songs = None, None, None, None 137 if kind == ScoreKind.BEST or isinstance(provider, LXNSProvider): 138 b35, b15 = await provider.get_scores_best(identifier, self._client) 139 # For some cases, the provider doesn't support fetching b35 and b15 scores, we should fetch all scores instead. 140 if kind == ScoreKind.ALL or (b35 == None and b15 == None): 141 songs = await MaimaiSongs._get_or_fetch(self._client) 142 all = await provider.get_scores_all(identifier, self._client) 143 return MaimaiScores(b35, b15, all, songs)
Fetch player's scores from the provider.
For WechatProvider, PlayerIdentifier must have the credentials
attribute, we suggest you to use the maimai.wechat()
method to get the identifier.
Also, PlayerIdentifier should not be cached or stored in the database, as the cookies may expire at any time.
For ArcadeProvider, PlayerIdentifier must have the credentials
attribute, which is the player's encrypted userId, can be detrived from maimai.qrcode()
.
Credentials can be reused, since it won't expire, also, userId is encrypted, can't be used in any other cases outside the maimai.py
Available providers: DivingFishProvider
, LXNSProvider
, WechatProvider
, ArcadeProvider
.
Arguments:
- identifier: the identifier of the player to fetch, e.g.
PlayerIdentifier(friend_code=664994421382429)
. - kind: the kind of scores list to fetch, defaults to
ScoreKind.BEST
. - provider: the data source to fetch the player and scores from, defaults to
LXNSProvider
.
Returns:
The scores object of the player, with all the data fetched.
Raises:
- InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
- InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
- PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
- httpx.HTTPError: Request failed due to network issues.
Raises:
- TitleServerError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
- ArcadeError: Only for ArcadeProvider, maimai response is invalid, or user id is invalid.
145 async def regions(self, identifier: PlayerIdentifier, provider: IRegionProvider = ArcadeProvider()) -> list[PlayerRegion]: 146 """Get the player's regions that they have played. 147 148 Args: 149 identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(credentials="encrypted_user_id")`. 150 provider: the data source to fetch the player from, defaults to `ArcadeProvider`. 151 Returns: 152 The list of regions that the player has played. 153 Raises: 154 TitleServerError: Only for ArcadeProvider, maimai title server related errors, possibly network problems. 155 ArcadeError: Only for ArcadeProvider, maimai response is invalid, or user id is invalid. 156 """ 157 return await provider.get_regions(identifier, self._client)
Get the player's regions that they have played.
Arguments:
- identifier: the identifier of the player to fetch, e.g.
PlayerIdentifier(credentials="encrypted_user_id")
. - provider: the data source to fetch the player from, defaults to
ArcadeProvider
.
Returns:
The list of regions that the player has played.
Raises:
- TitleServerError: Only for ArcadeProvider, maimai title server related errors, possibly network problems.
- ArcadeError: Only for ArcadeProvider, maimai response is invalid, or user id is invalid.
159 async def updates( 160 self, 161 identifier: PlayerIdentifier, 162 scores: list[Score], 163 provider: IScoreProvider = LXNSProvider(), 164 ) -> None: 165 """Update player's scores to the provider. 166 167 For Diving Fish, the player identifier should be the player's username and password, or import token, e.g.: 168 169 `PlayerIdentifier(username="turou", credentials="password")` or `PlayerIdentifier(credentials="my_diving_fish_import_token")`. 170 171 Available providers: `DivingFishProvider`, `LXNSProvider`. 172 173 Args: 174 identifier: the identifier of the player to update, e.g. `PlayerIdentifier(friend_code=664994421382429)`. 175 scores: the scores to update, usually the scores fetched from other providers. 176 provider: the data source to update the player scores to, defaults to `LXNSProvider`. 177 Returns: 178 Nothing, failures will raise exceptions. 179 Raises: 180 InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found, or the import token / password is invalid. 181 InvalidDeveloperTokenError: Developer token is not provided or token is invalid. 182 PrivacyLimitationError: The user has not accepted the 3rd party to access the data. 183 httpx.HTTPError: Request failed due to network issues. 184 """ 185 await provider.update_scores(identifier, scores, self._client)
Update player's scores to the provider.
For Diving Fish, the player identifier should be the player's username and password, or import token, e.g.:
PlayerIdentifier(username="turou", credentials="password")
or PlayerIdentifier(credentials="my_diving_fish_import_token")
.
Available providers: DivingFishProvider
, LXNSProvider
.
Arguments:
- identifier: the identifier of the player to update, e.g.
PlayerIdentifier(friend_code=664994421382429)
. - scores: the scores to update, usually the scores fetched from other providers.
- provider: the data source to update the player scores to, defaults to
LXNSProvider
.
Returns:
Nothing, failures will raise exceptions.
Raises:
- InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found, or the import token / password is invalid.
- InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
- PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
- httpx.HTTPError: Request failed due to network issues.
187 async def plates( 188 self, 189 identifier: PlayerIdentifier, 190 plate: str, 191 provider: IScoreProvider = LXNSProvider(), 192 ) -> MaimaiPlates: 193 """Get the plate achievement of the given player and plate. 194 195 Available providers: `DivingFishProvider`, `LXNSProvider`, `ArcadeProvider`. 196 197 Args: 198 identifier: the identifier of the player to fetch, e.g. `PlayerIdentifier(friend_code=664994421382429)`. 199 plate: the name of the plate, e.g. "樱将", "真舞舞". 200 provider: the data source to fetch the player and scores from, defaults to `LXNSProvider`. 201 Returns: 202 A wrapper of the plate achievement, with plate information, and matched player scores. 203 Raises: 204 InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found. 205 InvalidPlateError: Provided version or plate is invalid. 206 InvalidDeveloperTokenError: Developer token is not provided or token is invalid. 207 PrivacyLimitationError: The user has not accepted the 3rd party to access the data. 208 httpx.HTTPError: Request failed due to network issues. 209 """ 210 songs = await MaimaiSongs._get_or_fetch(self._client) 211 scores = await provider.get_scores_all(identifier, self._client) 212 return MaimaiPlates(scores, plate[0], plate[1:], songs)
Get the plate achievement of the given player and plate.
Available providers: DivingFishProvider
, LXNSProvider
, ArcadeProvider
.
Arguments:
- identifier: the identifier of the player to fetch, e.g.
PlayerIdentifier(friend_code=664994421382429)
. - plate: the name of the plate, e.g. "樱将", "真舞舞".
- provider: the data source to fetch the player and scores from, defaults to
LXNSProvider
.
Returns:
A wrapper of the plate achievement, with plate information, and matched player scores.
Raises:
- InvalidPlayerIdentifierError: Player identifier is invalid for the provider, or player is not found.
- InvalidPlateError: Provided version or plate is invalid.
- InvalidDeveloperTokenError: Developer token is not provided or token is invalid.
- PrivacyLimitationError: The user has not accepted the 3rd party to access the data.
- httpx.HTTPError: Request failed due to network issues.
214 async def wechat(self, r=None, t=None, code=None, state=None) -> PlayerIdentifier | str: 215 """Get the player identifier from the Wahlap Wechat OffiAccount. 216 217 Call the method with no parameters to get the URL, then redirect the user to the URL with your mitmproxy enabled. 218 219 Your mitmproxy should intercept the response from tgk-wcaime.wahlap.com, then call the method with the parameters from the intercepted response. 220 221 With the parameters from specific user's response, the method will return the user's player identifier. 222 223 Never cache or store the player identifier, as the cookies may expire at any time. 224 225 Args: 226 r: the r parameter from the request, defaults to None. 227 t: the t parameter from the request, defaults to None. 228 code: the code parameter from the request, defaults to None. 229 state: the state parameter from the request, defaults to None. 230 Returns: 231 The player identifier if all parameters are provided, otherwise return the URL to get the identifier. 232 Raises: 233 WechatTokenExpiredError: Wechat token is expired, please re-authorize. 234 httpx.HTTPError: Request failed due to network issues. 235 """ 236 if not all([r, t, code, state]): 237 resp = await self._client.get("https://tgk-wcaime.wahlap.com/wc_auth/oauth/authorize/maimai-dx") 238 return resp.headers["location"].replace("redirect_uri=https", "redirect_uri=http") 239 params = {"r": r, "t": t, "code": code, "state": state} 240 headers = { 241 "User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36 NetType/WIFI MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x6307001e)", 242 "Host": "tgk-wcaime.wahlap.com", 243 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", 244 } 245 resp = await self._client.get("https://tgk-wcaime.wahlap.com/wc_auth/oauth/callback/maimai-dx", params=params, headers=headers, timeout=5) 246 if resp.status_code == 302 and resp.next_request: 247 resp_next = await self._client.get(resp.next_request.url, headers=headers) 248 return PlayerIdentifier(credentials=resp_next.cookies) 249 else: 250 raise WechatTokenExpiredError("Wechat token is expired")
Get the player identifier from the Wahlap Wechat OffiAccount.
Call the method with no parameters to get the URL, then redirect the user to the URL with your mitmproxy enabled.
Your mitmproxy should intercept the response from tgk-wcaime.wahlap.com, then call the method with the parameters from the intercepted response.
With the parameters from specific user's response, the method will return the user's player identifier.
Never cache or store the player identifier, as the cookies may expire at any time.
Arguments:
- r: the r parameter from the request, defaults to None.
- t: the t parameter from the request, defaults to None.
- code: the code parameter from the request, defaults to None.
- state: the state parameter from the request, defaults to None.
Returns:
The player identifier if all parameters are provided, otherwise return the URL to get the identifier.
Raises:
- WechatTokenExpiredError: Wechat token is expired, please re-authorize.
- httpx.HTTPError: Request failed due to network issues.
252 async def qrcode(self, qrcode: str, http_proxy: str | None = None) -> PlayerIdentifier: 253 """Get the player identifier from the Wahlap QR code. 254 255 Player identifier is the encrypted userId, can't be used in any other cases outside the maimai.py. 256 257 Args: 258 qrcode: the QR code of the player, should begin with SGWCMAID. 259 http_proxy: the http proxy to use for the request, defaults to None. 260 Returns: 261 The player identifier of the player. 262 Raises: 263 AimeServerError: Maimai Aime server error, may be invalid QR code or QR code has expired. 264 TitleServerError: Maimai title server related errors, possibly network problems. 265 """ 266 resp: ArcadeResponse = await arcade.get_uid_encrypted(qrcode, http_proxy=http_proxy) 267 ArcadeResponse._raise_for_error(resp) 268 if resp.data and isinstance(resp.data, bytes): 269 return PlayerIdentifier(credentials=resp.data.decode()) 270 else: 271 raise ArcadeError("Invalid QR code or QR code has expired")
Get the player identifier from the Wahlap QR code.
Player identifier is the encrypted userId, can't be used in any other cases outside the maimai.py.
Arguments:
- qrcode: the QR code of the player, should begin with SGWCMAID.
- http_proxy: the http proxy to use for the request, defaults to None.
Returns:
The player identifier of the player.
Raises:
- AimeServerError: Maimai Aime server error, may be invalid QR code or QR code has expired.
- TitleServerError: Maimai title server related errors, possibly network problems.
273 async def items(self, item: Type[CachedType], flush=False, provider: IItemListProvider | _UnsetSentinel = UNSET) -> MaimaiItems[CachedType]: 274 """Fetch maimai player items from the cache default provider. 275 276 Available items: `PlayerIcon`, `PlayerNamePlate`, `PlayerFrame`, `PlayerTrophy`, `PlayerChara`, `PlayerPartner`. 277 278 Args: 279 item: the item type to fetch, e.g. `PlayerIcon`. 280 flush: whether to flush the cache, defaults to False. 281 provider: override the default item list provider, defaults to `LXNSProvider` and `LocalProvider`. 282 Returns: 283 A wrapper of the item list, for easier access and filtering. 284 Raises: 285 FileNotFoundError: The item file is not found. 286 httpx.HTTPError: Request failed due to network issues. 287 """ 288 if provider and provider is not UNSET: 289 default_caches._caches_provider[item._cache_key()] = provider 290 items = await default_caches.get_or_fetch(item._cache_key(), self._client, flush=flush) 291 return MaimaiItems[CachedType](items)
Fetch maimai player items from the cache default provider.
Available items: PlayerIcon
, PlayerNamePlate
, PlayerFrame
, PlayerTrophy
, PlayerChara
, PlayerPartner
.
Arguments:
- item: the item type to fetch, e.g.
PlayerIcon
. - flush: whether to flush the cache, defaults to False.
- provider: override the default item list provider, defaults to
LXNSProvider
andLocalProvider
.
Returns:
A wrapper of the item list, for easier access and filtering.
Raises:
- FileNotFoundError: The item file is not found.
- httpx.HTTPError: Request failed due to network issues.
293 async def areas(self, lang: Literal["ja", "zh"] = "ja", provider: IAreaProvider = LocalProvider()) -> MaimaiAreas: 294 """Fetch maimai areas from the provider. 295 296 Available providers: `LocalProvider`. 297 298 Args: 299 lang: the language of the area to fetch, available languages: `ja`, `zh`. 300 provider: override the default area provider, defaults to `ArcadeProvider`. 301 Returns: 302 A wrapper of the area list, for easier access and filtering. 303 Raises: 304 FileNotFoundError: The area file is not found. 305 """ 306 307 return MaimaiAreas(lang, await provider.get_areas(lang, self._client))
Fetch maimai areas from the provider.
Available providers: LocalProvider
.
Arguments:
- lang: the language of the area to fetch, available languages:
ja
,zh
. - provider: override the default area provider, defaults to
ArcadeProvider
.
Returns:
A wrapper of the area list, for easier access and filtering.
Raises:
- FileNotFoundError: The area file is not found.
309 async def flush(self) -> None: 310 """Flush the caches of the client, this will perform a full re-fetch of all the data. 311 312 Notice that only items ("songs", "aliases", "curves", "icons", "plates", "frames", "trophy", "chara", "partner") will be cached, this will only affect those items. 313 """ 314 await default_caches.flush(self._client)
Flush the caches of the client, this will perform a full re-fetch of all the data.
Notice that only items ("songs", "aliases", "curves", "icons", "plates", "frames", "trophy", "chara", "partner") will be cached, this will only affect those items.
724class MaimaiScores: 725 scores: list[Score] 726 """All scores of the player when `ScoreKind.ALL`, otherwise only the b50 scores.""" 727 scores_b35: list[Score] 728 """The b35 scores of the player.""" 729 scores_b15: list[Score] 730 """The b15 scores of the player.""" 731 rating: int 732 """The total rating of the player.""" 733 rating_b35: int 734 """The b35 rating of the player.""" 735 rating_b15: int 736 """The b15 rating of the player.""" 737 738 @staticmethod 739 def _get_distinct_scores(scores: list[Score]) -> list[Score]: 740 scores_unique = {} 741 for score in scores: 742 score_key = f"{score.id} {score.type} {score.level_index}" 743 scores_unique[score_key] = score._compare(scores_unique.get(score_key, None)) 744 return list(scores_unique.values()) 745 746 def __init__( 747 self, b35: list[Score] | None = None, b15: list[Score] | None = None, all: list[Score] | None = None, songs: MaimaiSongs | None = None 748 ): 749 self.scores = all or (b35 + b15 if b35 and b15 else None) or [] 750 # if b35 and b15 are not provided, try to calculate them from all scores 751 if (not b35 or not b15) and all: 752 distinct_scores = MaimaiScores._get_distinct_scores(all) # scores have to be distinct to calculate the bests 753 scores_new: list[Score] = [] 754 scores_old: list[Score] = [] 755 for score in distinct_scores: 756 if songs and (diff := score.difficulty): 757 (scores_new if diff.version >= current_version.value else scores_old).append(score) 758 scores_old.sort(key=lambda score: (score.dx_rating, score.dx_score, score.achievements), reverse=True) 759 scores_new.sort(key=lambda score: (score.dx_rating, score.dx_score, score.achievements), reverse=True) 760 b35 = scores_old[:35] 761 b15 = scores_new[:15] 762 self.scores_b35 = b35 or [] 763 self.scores_b15 = b15 or [] 764 self.rating_b35 = int(sum((score.dx_rating or 0) for score in b35) if b35 else 0) 765 self.rating_b15 = int(sum((score.dx_rating or 0) for score in b15) if b15 else 0) 766 self.rating = self.rating_b35 + self.rating_b15 767 768 @property 769 def as_distinct(self) -> "MaimaiScores": 770 """Get the distinct scores. 771 772 Normally, player has more than one score for the same song and level, this method will return a new `MaimaiScores` object with the highest scores for each song and level. 773 774 This method won't modify the original scores object, it will return a new one. 775 776 If ScoreKind is BEST, this won't make any difference, because the scores are already the best ones. 777 """ 778 distinct_scores = MaimaiScores._get_distinct_scores(self.scores) 779 songs: MaimaiSongs = default_caches._caches["msongs"] 780 assert songs is not None and isinstance(songs, MaimaiSongs) 781 return MaimaiScores(b35=self.scores_b35, b15=self.scores_b15, all=distinct_scores, songs=songs) 782 783 def by_song( 784 self, song_id: int, song_type: SongType | _UnsetSentinel = UNSET, level_index: LevelIndex | _UnsetSentinel = UNSET 785 ) -> Iterator[Score]: 786 """Get scores of the song on that type and level_index. 787 788 If song_type or level_index is not provided, all scores of the song will be returned. 789 790 Args: 791 song_id: the ID of the song to get the scores by. 792 song_type: the type of the song to get the scores by, defaults to None. 793 level_index: the level index of the song to get the scores by, defaults to None. 794 Returns: 795 the list of scores of the song, return an empty list if no score is found. 796 """ 797 for score in self.scores: 798 if score.id != song_id: 799 continue 800 if song_type is not UNSET and score.type != song_type: 801 continue 802 if level_index is not UNSET and score.level_index != level_index: 803 continue 804 yield score 805 806 def filter(self, **kwargs) -> list[Score]: 807 """Filter scores by their attributes. 808 809 Make sure the attribute is of the score, and the value is of the same type. All conditions are connected by AND. 810 811 Args: 812 kwargs: the attributes to filter the scores by. 813 Returns: 814 the list of scores that match all the conditions, return an empty list if no score is found. 815 """ 816 return [score for score in self.scores if all(getattr(score, key) == value for key, value in kwargs.items())]
746 def __init__( 747 self, b35: list[Score] | None = None, b15: list[Score] | None = None, all: list[Score] | None = None, songs: MaimaiSongs | None = None 748 ): 749 self.scores = all or (b35 + b15 if b35 and b15 else None) or [] 750 # if b35 and b15 are not provided, try to calculate them from all scores 751 if (not b35 or not b15) and all: 752 distinct_scores = MaimaiScores._get_distinct_scores(all) # scores have to be distinct to calculate the bests 753 scores_new: list[Score] = [] 754 scores_old: list[Score] = [] 755 for score in distinct_scores: 756 if songs and (diff := score.difficulty): 757 (scores_new if diff.version >= current_version.value else scores_old).append(score) 758 scores_old.sort(key=lambda score: (score.dx_rating, score.dx_score, score.achievements), reverse=True) 759 scores_new.sort(key=lambda score: (score.dx_rating, score.dx_score, score.achievements), reverse=True) 760 b35 = scores_old[:35] 761 b15 = scores_new[:15] 762 self.scores_b35 = b35 or [] 763 self.scores_b15 = b15 or [] 764 self.rating_b35 = int(sum((score.dx_rating or 0) for score in b35) if b35 else 0) 765 self.rating_b15 = int(sum((score.dx_rating or 0) for score in b15) if b15 else 0) 766 self.rating = self.rating_b35 + self.rating_b15
All scores of the player when ScoreKind.ALL
, otherwise only the b50 scores.
768 @property 769 def as_distinct(self) -> "MaimaiScores": 770 """Get the distinct scores. 771 772 Normally, player has more than one score for the same song and level, this method will return a new `MaimaiScores` object with the highest scores for each song and level. 773 774 This method won't modify the original scores object, it will return a new one. 775 776 If ScoreKind is BEST, this won't make any difference, because the scores are already the best ones. 777 """ 778 distinct_scores = MaimaiScores._get_distinct_scores(self.scores) 779 songs: MaimaiSongs = default_caches._caches["msongs"] 780 assert songs is not None and isinstance(songs, MaimaiSongs) 781 return MaimaiScores(b35=self.scores_b35, b15=self.scores_b15, all=distinct_scores, songs=songs)
Get the distinct scores.
Normally, player has more than one score for the same song and level, this method will return a new MaimaiScores
object with the highest scores for each song and level.
This method won't modify the original scores object, it will return a new one.
If ScoreKind is BEST, this won't make any difference, because the scores are already the best ones.
783 def by_song( 784 self, song_id: int, song_type: SongType | _UnsetSentinel = UNSET, level_index: LevelIndex | _UnsetSentinel = UNSET 785 ) -> Iterator[Score]: 786 """Get scores of the song on that type and level_index. 787 788 If song_type or level_index is not provided, all scores of the song will be returned. 789 790 Args: 791 song_id: the ID of the song to get the scores by. 792 song_type: the type of the song to get the scores by, defaults to None. 793 level_index: the level index of the song to get the scores by, defaults to None. 794 Returns: 795 the list of scores of the song, return an empty list if no score is found. 796 """ 797 for score in self.scores: 798 if score.id != song_id: 799 continue 800 if song_type is not UNSET and score.type != song_type: 801 continue 802 if level_index is not UNSET and score.level_index != level_index: 803 continue 804 yield score
Get scores of the song on that type and level_index.
If song_type or level_index is not provided, all scores of the song will be returned.
Arguments:
- song_id: the ID of the song to get the scores by.
- song_type: the type of the song to get the scores by, defaults to None.
- level_index: the level index of the song to get the scores by, defaults to None.
Returns:
the list of scores of the song, return an empty list if no score is found.
806 def filter(self, **kwargs) -> list[Score]: 807 """Filter scores by their attributes. 808 809 Make sure the attribute is of the score, and the value is of the same type. All conditions are connected by AND. 810 811 Args: 812 kwargs: the attributes to filter the scores by. 813 Returns: 814 the list of scores that match all the conditions, return an empty list if no score is found. 815 """ 816 return [score for score in self.scores if all(getattr(score, key) == value for key, value in kwargs.items())]
Filter scores by their attributes.
Make sure the attribute is of the score, and the value is of the same type. All conditions are connected by AND.
Arguments:
- kwargs: the attributes to filter the scores by.
Returns:
the list of scores that match all the conditions, return an empty list if no score is found.
550class MaimaiPlates: 551 scores: list[Score] 552 """The scores that match the plate version and kind.""" 553 songs: list[Song] 554 """The songs that match the plate version and kind.""" 555 version: str 556 """The version of the plate, e.g. "真", "舞".""" 557 kind: str 558 """The kind of the plate, e.g. "将", "神".""" 559 560 _versions: list[Version] = [] 561 562 def __init__(self, scores: list[Score], version_str: str, kind: str, songs: MaimaiSongs) -> None: 563 """@private""" 564 self.scores = [] 565 self.songs = [] 566 self.version = plate_aliases.get(version_str, version_str) 567 self.kind = plate_aliases.get(kind, kind) 568 versions = [] # in case of invalid plate, we will raise an error 569 if self.version == "真": 570 versions = [plate_to_version["初"], plate_to_version["真"]] 571 if self.version in ["霸", "舞"]: 572 versions = [ver for ver in plate_to_version.values() if ver.value < 20000] 573 if plate_to_version.get(self.version): 574 versions = [plate_to_version[self.version]] 575 if not versions or self.kind not in ["将", "者", "极", "舞舞", "神"]: 576 raise InvalidPlateError(f"Invalid plate: {self.version}{self.kind}") 577 versions.append([ver for ver in plate_to_version.values() if ver.value > versions[-1].value][0]) 578 self._versions = versions 579 580 scores_unique = {} 581 for score in scores: 582 if song := songs.by_id(score.id): 583 score_key = f"{score.id} {score.type} {score.level_index}" 584 if difficulty := song.get_difficulty(score.type, score.level_index): 585 score_version = difficulty.version 586 if score.level_index == LevelIndex.ReMASTER and self.no_remaster: 587 continue # skip ReMASTER levels if not required, e.g. in 霸 and 舞 plates 588 if any(score_version >= o.value and score_version < versions[i + 1].value for i, o in enumerate(versions[:-1])): 589 scores_unique[score_key] = score._compare(scores_unique.get(score_key, None)) 590 591 for song in songs.songs: 592 diffs = song.difficulties._get_children() 593 if any(diff.version >= o.value and diff.version < versions[i + 1].value for i, o in enumerate(versions[:-1]) for diff in diffs): 594 self.songs.append(song) 595 596 self.scores = list(scores_unique.values()) 597 598 @cached_property 599 def no_remaster(self) -> bool: 600 """Whether it is required to play ReMASTER levels in the plate. 601 602 Only 舞 and 霸 plates require ReMASTER levels, others don't. 603 """ 604 605 return self.version not in ["舞", "霸"] 606 607 @cached_property 608 def major_type(self) -> SongType: 609 """The major song type of the plate, usually for identifying the levels. 610 611 Only 舞 and 霸 plates require ReMASTER levels, others don't. 612 """ 613 return SongType.DX if any(ver.value > 20000 for ver in self._versions) else SongType.STANDARD 614 615 @cached_property 616 def remained(self) -> list[PlateObject]: 617 """Get the remained songs and scores of the player on this plate. 618 619 If player has ramained levels on one song, the song and ramained `level_index` will be included in the result, otherwise it won't. 620 621 The distinct scores which NOT met the plate requirement will be included in the result, the finished scores won't. 622 """ 623 scores_dict: dict[int, list[Score]] = {} 624 [scores_dict.setdefault(score.id, []).append(score) for score in self.scores] 625 results = { 626 song.id: PlateObject(song=song, levels=song._get_level_indexes(self.major_type, self.no_remaster), scores=scores_dict.get(song.id, [])) 627 for song in self.songs 628 } 629 630 def extract(score: Score) -> None: 631 results[score.id].scores.remove(score) 632 if score.level_index in results[score.id].levels: 633 results[score.id].levels.remove(score.level_index) 634 635 if self.kind == "者": 636 [extract(score) for score in self.scores if score.rate.value <= RateType.A.value] 637 elif self.kind == "将": 638 [extract(score) for score in self.scores if score.rate.value <= RateType.SSS.value] 639 elif self.kind == "极": 640 [extract(score) for score in self.scores if score.fc and score.fc.value <= FCType.FC.value] 641 elif self.kind == "舞舞": 642 [extract(score) for score in self.scores if score.fs and score.fs.value <= FSType.FSD.value] 643 elif self.kind == "神": 644 [extract(score) for score in self.scores if score.fc and score.fc.value <= FCType.AP.value] 645 646 return [plate for plate in results.values() if plate.levels != []] 647 648 @cached_property 649 def cleared(self) -> list[PlateObject]: 650 """Get the cleared songs and scores of the player on this plate. 651 652 If player has levels (one or more) that met the requirement on the song, the song and cleared `level_index` will be included in the result, otherwise it won't. 653 654 The distinct scores which met the plate requirement will be included in the result, the unfinished scores won't. 655 """ 656 results = {song.id: PlateObject(song=song, levels=[], scores=[]) for song in self.songs} 657 658 def insert(score: Score) -> None: 659 results[score.id].scores.append(score) 660 results[score.id].levels.append(score.level_index) 661 662 if self.kind == "者": 663 [insert(score) for score in self.scores if score.rate.value <= RateType.A.value] 664 elif self.kind == "将": 665 [insert(score) for score in self.scores if score.rate.value <= RateType.SSS.value] 666 elif self.kind == "极": 667 [insert(score) for score in self.scores if score.fc and score.fc.value <= FCType.FC.value] 668 elif self.kind == "舞舞": 669 [insert(score) for score in self.scores if score.fs and score.fs.value <= FSType.FSD.value] 670 elif self.kind == "神": 671 [insert(score) for score in self.scores if score.fc and score.fc.value <= FCType.AP.value] 672 673 return [plate for plate in results.values() if plate.levels != []] 674 675 @cached_property 676 def played(self) -> list[PlateObject]: 677 """Get the played songs and scores of the player on this plate. 678 679 If player has ever played levels on the song, whether they met or not, the song and played `level_index` will be included in the result. 680 681 All distinct scores will be included in the result. 682 """ 683 results = {song.id: PlateObject(song=song, levels=[], scores=[]) for song in self.songs} 684 for score in self.scores: 685 results[score.id].scores.append(score) 686 results[score.id].levels.append(score.level_index) 687 return [plate for plate in results.values() if plate.levels != []] 688 689 @cached_property 690 def all(self) -> Iterator[PlateObject]: 691 """Get all songs on this plate, usually used for statistics of the plate. 692 693 All songs will be included in the result, with all levels, whether they met or not. 694 695 No scores will be included in the result, use played, cleared, remained to get the scores. 696 """ 697 698 return iter(PlateObject(song=song, levels=song._get_level_indexes(self.major_type, self.no_remaster), scores=[]) for song in self.songs) 699 700 @cached_property 701 def played_num(self) -> int: 702 """Get the number of played levels on this plate.""" 703 return len([level for plate in self.played for level in plate.levels]) 704 705 @cached_property 706 def cleared_num(self) -> int: 707 """Get the number of cleared levels on this plate.""" 708 return len([level for plate in self.cleared for level in plate.levels]) 709 710 @cached_property 711 def remained_num(self) -> int: 712 """Get the number of remained levels on this plate.""" 713 return len([level for plate in self.remained for level in plate.levels]) 714 715 @cached_property 716 def all_num(self) -> int: 717 """Get the number of all levels on this plate. 718 719 This is the total number of levels on the plate, should equal to `cleared_num + remained_num`. 720 """ 721 return len([level for plate in self.all for level in plate.levels])
598 @cached_property 599 def no_remaster(self) -> bool: 600 """Whether it is required to play ReMASTER levels in the plate. 601 602 Only 舞 and 霸 plates require ReMASTER levels, others don't. 603 """ 604 605 return self.version not in ["舞", "霸"]
Whether it is required to play ReMASTER levels in the plate.
Only 舞 and 霸 plates require ReMASTER levels, others don't.
607 @cached_property 608 def major_type(self) -> SongType: 609 """The major song type of the plate, usually for identifying the levels. 610 611 Only 舞 and 霸 plates require ReMASTER levels, others don't. 612 """ 613 return SongType.DX if any(ver.value > 20000 for ver in self._versions) else SongType.STANDARD
The major song type of the plate, usually for identifying the levels.
Only 舞 and 霸 plates require ReMASTER levels, others don't.
615 @cached_property 616 def remained(self) -> list[PlateObject]: 617 """Get the remained songs and scores of the player on this plate. 618 619 If player has ramained levels on one song, the song and ramained `level_index` will be included in the result, otherwise it won't. 620 621 The distinct scores which NOT met the plate requirement will be included in the result, the finished scores won't. 622 """ 623 scores_dict: dict[int, list[Score]] = {} 624 [scores_dict.setdefault(score.id, []).append(score) for score in self.scores] 625 results = { 626 song.id: PlateObject(song=song, levels=song._get_level_indexes(self.major_type, self.no_remaster), scores=scores_dict.get(song.id, [])) 627 for song in self.songs 628 } 629 630 def extract(score: Score) -> None: 631 results[score.id].scores.remove(score) 632 if score.level_index in results[score.id].levels: 633 results[score.id].levels.remove(score.level_index) 634 635 if self.kind == "者": 636 [extract(score) for score in self.scores if score.rate.value <= RateType.A.value] 637 elif self.kind == "将": 638 [extract(score) for score in self.scores if score.rate.value <= RateType.SSS.value] 639 elif self.kind == "极": 640 [extract(score) for score in self.scores if score.fc and score.fc.value <= FCType.FC.value] 641 elif self.kind == "舞舞": 642 [extract(score) for score in self.scores if score.fs and score.fs.value <= FSType.FSD.value] 643 elif self.kind == "神": 644 [extract(score) for score in self.scores if score.fc and score.fc.value <= FCType.AP.value] 645 646 return [plate for plate in results.values() if plate.levels != []]
Get the remained songs and scores of the player on this plate.
If player has ramained levels on one song, the song and ramained level_index
will be included in the result, otherwise it won't.
The distinct scores which NOT met the plate requirement will be included in the result, the finished scores won't.
648 @cached_property 649 def cleared(self) -> list[PlateObject]: 650 """Get the cleared songs and scores of the player on this plate. 651 652 If player has levels (one or more) that met the requirement on the song, the song and cleared `level_index` will be included in the result, otherwise it won't. 653 654 The distinct scores which met the plate requirement will be included in the result, the unfinished scores won't. 655 """ 656 results = {song.id: PlateObject(song=song, levels=[], scores=[]) for song in self.songs} 657 658 def insert(score: Score) -> None: 659 results[score.id].scores.append(score) 660 results[score.id].levels.append(score.level_index) 661 662 if self.kind == "者": 663 [insert(score) for score in self.scores if score.rate.value <= RateType.A.value] 664 elif self.kind == "将": 665 [insert(score) for score in self.scores if score.rate.value <= RateType.SSS.value] 666 elif self.kind == "极": 667 [insert(score) for score in self.scores if score.fc and score.fc.value <= FCType.FC.value] 668 elif self.kind == "舞舞": 669 [insert(score) for score in self.scores if score.fs and score.fs.value <= FSType.FSD.value] 670 elif self.kind == "神": 671 [insert(score) for score in self.scores if score.fc and score.fc.value <= FCType.AP.value] 672 673 return [plate for plate in results.values() if plate.levels != []]
Get the cleared songs and scores of the player on this plate.
If player has levels (one or more) that met the requirement on the song, the song and cleared level_index
will be included in the result, otherwise it won't.
The distinct scores which met the plate requirement will be included in the result, the unfinished scores won't.
675 @cached_property 676 def played(self) -> list[PlateObject]: 677 """Get the played songs and scores of the player on this plate. 678 679 If player has ever played levels on the song, whether they met or not, the song and played `level_index` will be included in the result. 680 681 All distinct scores will be included in the result. 682 """ 683 results = {song.id: PlateObject(song=song, levels=[], scores=[]) for song in self.songs} 684 for score in self.scores: 685 results[score.id].scores.append(score) 686 results[score.id].levels.append(score.level_index) 687 return [plate for plate in results.values() if plate.levels != []]
Get the played songs and scores of the player on this plate.
If player has ever played levels on the song, whether they met or not, the song and played level_index
will be included in the result.
All distinct scores will be included in the result.
689 @cached_property 690 def all(self) -> Iterator[PlateObject]: 691 """Get all songs on this plate, usually used for statistics of the plate. 692 693 All songs will be included in the result, with all levels, whether they met or not. 694 695 No scores will be included in the result, use played, cleared, remained to get the scores. 696 """ 697 698 return iter(PlateObject(song=song, levels=song._get_level_indexes(self.major_type, self.no_remaster), scores=[]) for song in self.songs)
Get all songs on this plate, usually used for statistics of the plate.
All songs will be included in the result, with all levels, whether they met or not.
No scores will be included in the result, use played, cleared, remained to get the scores.
700 @cached_property 701 def played_num(self) -> int: 702 """Get the number of played levels on this plate.""" 703 return len([level for plate in self.played for level in plate.levels])
Get the number of played levels on this plate.
705 @cached_property 706 def cleared_num(self) -> int: 707 """Get the number of cleared levels on this plate.""" 708 return len([level for plate in self.cleared for level in plate.levels])
Get the number of cleared levels on this plate.
710 @cached_property 711 def remained_num(self) -> int: 712 """Get the number of remained levels on this plate.""" 713 return len([level for plate in self.remained for level in plate.levels])
Get the number of remained levels on this plate.
715 @cached_property 716 def all_num(self) -> int: 717 """Get the number of all levels on this plate. 718 719 This is the total number of levels on the plate, should equal to `cleared_num + remained_num`. 720 """ 721 return len([level for plate in self.all for level in plate.levels])
Get the number of all levels on this plate.
This is the total number of levels on the plate, should equal to cleared_num + remained_num
.
390class MaimaiSongs: 391 _cached_songs: list[Song] 392 _cached_aliases: list[SongAlias] 393 _cached_curves: dict[str, list[CurveObject | None]] 394 395 _song_id_dict: dict[int, Song] # song_id: song 396 _alias_entry_dict: dict[str, Song] # alias_entry: song 397 _keywords_dict: dict[str, Song] # keywords: song 398 399 def __init__(self, songs: list[Song], aliases: list[SongAlias] | None, curves: dict[str, list[CurveObject | None]] | None) -> None: 400 """@private""" 401 self._cached_songs = songs 402 self._cached_aliases = aliases or [] 403 self._cached_curves = curves or {} 404 self._song_id_dict = {} 405 self._alias_entry_dict = {} 406 self._keywords_dict = {} 407 self._flush() 408 409 def _flush(self) -> None: 410 self._song_id_dict = {song.id: song for song in self._cached_songs} 411 self._keywords_dict = {} 412 default_caches._caches["lxns_detailed_songs"] = {} 413 for alias in self._cached_aliases or []: 414 if song := self._song_id_dict.get(alias.song_id): 415 song.aliases = alias.aliases 416 for alias_entry in alias.aliases: 417 self._alias_entry_dict[alias_entry] = song 418 for idx, curve_list in (self._cached_curves or {}).items(): 419 song_type: SongType = SongType._from_id(int(idx)) 420 song_id = int(idx) % 10000 421 if song := self._song_id_dict.get(song_id): 422 diffs = song.difficulties._get_children(song_type) 423 if len(diffs) < len(curve_list): 424 # ignore the extra curves, diving fish may return more curves than the song has, which is a bug 425 curve_list = curve_list[: len(diffs)] 426 [diffs[i].__setattr__("curve", curve) for i, curve in enumerate(curve_list)] 427 for song in self._cached_songs: 428 keywords = song.title.lower() + song.artist.lower() + "".join(alias.lower() for alias in (song.aliases or [])) 429 self._keywords_dict[keywords] = song 430 431 @staticmethod 432 async def _get_or_fetch(client: AsyncClient, flush=False) -> "MaimaiSongs": 433 if "msongs" not in default_caches._caches or flush: 434 tasks = [ 435 default_caches.get_or_fetch("songs", client, flush=flush), 436 default_caches.get_or_fetch("aliases", client, flush=flush), 437 default_caches.get_or_fetch("curves", client, flush=flush), 438 ] 439 songs, aliases, curves = await asyncio.gather(*tasks) 440 default_caches._caches["msongs"] = MaimaiSongs(songs, aliases, curves) 441 return default_caches._caches["msongs"] 442 443 @property 444 def songs(self) -> Iterator[Song]: 445 """All songs as list.""" 446 return iter(self._song_id_dict.values()) 447 448 def by_id(self, id: int) -> Song | None: 449 """Get a song by its ID. 450 451 Args: 452 id: the ID of the song, always smaller than `10000`, should (`% 10000`) if necessary. 453 Returns: 454 the song if it exists, otherwise return None. 455 """ 456 return self._song_id_dict.get(id, None) 457 458 def by_title(self, title: str) -> Song | None: 459 """Get a song by its title. 460 461 Args: 462 title: the title of the song. 463 Returns: 464 the song if it exists, otherwise return None. 465 """ 466 if title == "Link(CoF)": 467 return self.by_id(383) 468 return next((song for song in self.songs if song.title == title), None) 469 470 def by_alias(self, alias: str) -> Song | None: 471 """Get song by one possible alias. 472 473 Args: 474 alias: one possible alias of the song. 475 Returns: 476 the song if it exists, otherwise return None. 477 """ 478 return self._alias_entry_dict.get(alias, None) 479 480 def by_artist(self, artist: str) -> list[Song]: 481 """Get songs by their artist, case-sensitive. 482 483 Args: 484 artist: the artist of the songs. 485 Returns: 486 the list of songs that match the artist, return an empty list if no song is found. 487 """ 488 return [song for song in self.songs if song.artist == artist] 489 490 def by_genre(self, genre: Genre) -> list[Song]: 491 """Get songs by their genre, case-sensitive. 492 493 Args: 494 genre: the genre of the songs. 495 Returns: 496 the list of songs that match the genre, return an empty list if no song is found. 497 """ 498 499 return [song for song in self.songs if song.genre == genre] 500 501 def by_bpm(self, minimum: int, maximum: int) -> list[Song]: 502 """Get songs by their BPM. 503 504 Args: 505 minimum: the minimum (inclusive) BPM of the songs. 506 maximum: the maximum (inclusive) BPM of the songs. 507 Returns: 508 the list of songs that match the BPM range, return an empty list if no song is found. 509 """ 510 return [song for song in self.songs if minimum <= song.bpm <= maximum] 511 512 def by_versions(self, versions: Version) -> list[Song]: 513 """Get songs by their versions, versions are fuzzy matched version of major maimai version. 514 515 Args: 516 versions: the versions of the songs. 517 Returns: 518 the list of songs that match the versions, return an empty list if no song is found. 519 """ 520 521 versions_func: Callable[[Song], bool] = lambda song: versions.value <= song.version < all_versions[all_versions.index(versions) + 1].value 522 return list(filter(versions_func, self.songs)) 523 524 def by_keywords(self, keywords: str) -> list[Song]: 525 """Get songs by their keywords, keywords are matched with song title, artist and aliases. 526 527 Args: 528 keywords: the keywords to match the songs. 529 Returns: 530 the list of songs that match the keywords, return an empty list if no song is found. 531 """ 532 return [v for k, v in self._keywords_dict.items() if keywords.lower() in k] 533 534 def filter(self, **kwargs) -> list[Song]: 535 """Filter songs by their attributes. 536 537 Ensure that the attribute is of the song, and the value is of the same type. All conditions are connected by AND. 538 539 Args: 540 kwargs: the attributes to filter the songs by. 541 Returns: 542 the list of songs that match all the conditions, return an empty list if no song is found. 543 """ 544 if "id" in kwargs and kwargs["id"] is not None: 545 # if id is provided, ignore other attributes, as id is unique 546 return [item] if (item := self.by_id(kwargs["id"])) else [] 547 return [song for song in self.songs if all(getattr(song, key) == value for key, value in kwargs.items() if value is not None)]
443 @property 444 def songs(self) -> Iterator[Song]: 445 """All songs as list.""" 446 return iter(self._song_id_dict.values())
All songs as list.
448 def by_id(self, id: int) -> Song | None: 449 """Get a song by its ID. 450 451 Args: 452 id: the ID of the song, always smaller than `10000`, should (`% 10000`) if necessary. 453 Returns: 454 the song if it exists, otherwise return None. 455 """ 456 return self._song_id_dict.get(id, None)
Get a song by its ID.
Arguments:
- id: the ID of the song, always smaller than
10000
, should (% 10000
) if necessary.
Returns:
the song if it exists, otherwise return None.
458 def by_title(self, title: str) -> Song | None: 459 """Get a song by its title. 460 461 Args: 462 title: the title of the song. 463 Returns: 464 the song if it exists, otherwise return None. 465 """ 466 if title == "Link(CoF)": 467 return self.by_id(383) 468 return next((song for song in self.songs if song.title == title), None)
Get a song by its title.
Arguments:
- title: the title of the song.
Returns:
the song if it exists, otherwise return None.
470 def by_alias(self, alias: str) -> Song | None: 471 """Get song by one possible alias. 472 473 Args: 474 alias: one possible alias of the song. 475 Returns: 476 the song if it exists, otherwise return None. 477 """ 478 return self._alias_entry_dict.get(alias, None)
Get song by one possible alias.
Arguments:
- alias: one possible alias of the song.
Returns:
the song if it exists, otherwise return None.
480 def by_artist(self, artist: str) -> list[Song]: 481 """Get songs by their artist, case-sensitive. 482 483 Args: 484 artist: the artist of the songs. 485 Returns: 486 the list of songs that match the artist, return an empty list if no song is found. 487 """ 488 return [song for song in self.songs if song.artist == artist]
Get songs by their artist, case-sensitive.
Arguments:
- artist: the artist of the songs.
Returns:
the list of songs that match the artist, return an empty list if no song is found.
490 def by_genre(self, genre: Genre) -> list[Song]: 491 """Get songs by their genre, case-sensitive. 492 493 Args: 494 genre: the genre of the songs. 495 Returns: 496 the list of songs that match the genre, return an empty list if no song is found. 497 """ 498 499 return [song for song in self.songs if song.genre == genre]
Get songs by their genre, case-sensitive.
Arguments:
- genre: the genre of the songs.
Returns:
the list of songs that match the genre, return an empty list if no song is found.
501 def by_bpm(self, minimum: int, maximum: int) -> list[Song]: 502 """Get songs by their BPM. 503 504 Args: 505 minimum: the minimum (inclusive) BPM of the songs. 506 maximum: the maximum (inclusive) BPM of the songs. 507 Returns: 508 the list of songs that match the BPM range, return an empty list if no song is found. 509 """ 510 return [song for song in self.songs if minimum <= song.bpm <= maximum]
Get songs by their BPM.
Arguments:
- minimum: the minimum (inclusive) BPM of the songs.
- maximum: the maximum (inclusive) BPM of the songs.
Returns:
the list of songs that match the BPM range, return an empty list if no song is found.
512 def by_versions(self, versions: Version) -> list[Song]: 513 """Get songs by their versions, versions are fuzzy matched version of major maimai version. 514 515 Args: 516 versions: the versions of the songs. 517 Returns: 518 the list of songs that match the versions, return an empty list if no song is found. 519 """ 520 521 versions_func: Callable[[Song], bool] = lambda song: versions.value <= song.version < all_versions[all_versions.index(versions) + 1].value 522 return list(filter(versions_func, self.songs))
Get songs by their versions, versions are fuzzy matched version of major maimai version.
Arguments:
- versions: the versions of the songs.
Returns:
the list of songs that match the versions, return an empty list if no song is found.
524 def by_keywords(self, keywords: str) -> list[Song]: 525 """Get songs by their keywords, keywords are matched with song title, artist and aliases. 526 527 Args: 528 keywords: the keywords to match the songs. 529 Returns: 530 the list of songs that match the keywords, return an empty list if no song is found. 531 """ 532 return [v for k, v in self._keywords_dict.items() if keywords.lower() in k]
Get songs by their keywords, keywords are matched with song title, artist and aliases.
Arguments:
- keywords: the keywords to match the songs.
Returns:
the list of songs that match the keywords, return an empty list if no song is found.
534 def filter(self, **kwargs) -> list[Song]: 535 """Filter songs by their attributes. 536 537 Ensure that the attribute is of the song, and the value is of the same type. All conditions are connected by AND. 538 539 Args: 540 kwargs: the attributes to filter the songs by. 541 Returns: 542 the list of songs that match all the conditions, return an empty list if no song is found. 543 """ 544 if "id" in kwargs and kwargs["id"] is not None: 545 # if id is provided, ignore other attributes, as id is unique 546 return [item] if (item := self.by_id(kwargs["id"])) else [] 547 return [song for song in self.songs if all(getattr(song, key) == value for key, value in kwargs.items() if value is not None)]
Filter songs by their attributes.
Ensure that the attribute is of the song, and the value is of the same type. All conditions are connected by AND.
Arguments:
- kwargs: the attributes to filter the songs by.
Returns:
the list of songs that match all the conditions, return an empty list if no song is found.
355class MaimaiItems(Generic[CachedType]): 356 _cached_items: dict[int, CachedType] 357 358 def __init__(self, items: dict[int, CachedType]) -> None: 359 """@private""" 360 self._cached_items = items 361 362 @property 363 def values(self) -> Iterator[CachedType]: 364 """All items as list.""" 365 return iter(self._cached_items.values()) 366 367 def by_id(self, id: int) -> CachedType | None: 368 """Get an item by its ID. 369 370 Args: 371 id: the ID of the item. 372 Returns: 373 the item if it exists, otherwise return None. 374 """ 375 return self._cached_items.get(id, None) 376 377 def filter(self, **kwargs) -> list[CachedType]: 378 """Filter items by their attributes. 379 380 Ensure that the attribute is of the item, and the value is of the same type. All conditions are connected by AND. 381 382 Args: 383 kwargs: the attributes to filter the items by. 384 Returns: 385 the list of items that match all the conditions, return an empty list if no item is found. 386 """ 387 return [item for item in self.values if all(getattr(item, key) == value for key, value in kwargs.items() if value is not None)]
Abstract base class for generic types.
On Python 3.12 and newer, generic classes implicitly inherit from Generic when they declare a parameter list after the class's name::
class Mapping[KT, VT]:
def __getitem__(self, key: KT) -> VT:
...
# Etc.
On older versions of Python, however, generic classes have to explicitly inherit from Generic.
After a class has been declared to be generic, it can then be used as follows::
def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT:
try:
return mapping[key]
except KeyError:
return default
362 @property 363 def values(self) -> Iterator[CachedType]: 364 """All items as list.""" 365 return iter(self._cached_items.values())
All items as list.
367 def by_id(self, id: int) -> CachedType | None: 368 """Get an item by its ID. 369 370 Args: 371 id: the ID of the item. 372 Returns: 373 the item if it exists, otherwise return None. 374 """ 375 return self._cached_items.get(id, None)
Get an item by its ID.
Arguments:
- id: the ID of the item.
Returns:
the item if it exists, otherwise return None.
377 def filter(self, **kwargs) -> list[CachedType]: 378 """Filter items by their attributes. 379 380 Ensure that the attribute is of the item, and the value is of the same type. All conditions are connected by AND. 381 382 Args: 383 kwargs: the attributes to filter the items by. 384 Returns: 385 the list of items that match all the conditions, return an empty list if no item is found. 386 """ 387 return [item for item in self.values if all(getattr(item, key) == value for key, value in kwargs.items() if value is not None)]
Filter items by their attributes.
Ensure that the attribute is of the item, and the value is of the same type. All conditions are connected by AND.
Arguments:
- kwargs: the attributes to filter the items by.
Returns:
the list of items that match all the conditions, return an empty list if no item is found.