Рефактор

This commit is contained in:
2025-11-26 11:59:35 +03:00
parent c3b4602ed1
commit 5510f85d94
4 changed files with 279 additions and 759 deletions

View File

@ -1,6 +1,6 @@
[project] [project]
name = "osuclient" name = "osuclient"
version = "0.2.1" version = "0.3.0"
description = "Client for osu! API" description = "Client for osu! API"
readme = "README.md" readme = "README.md"
authors = [ authors = [

View File

@ -12,278 +12,34 @@ class osuAPIClient(AioHTTPXClient):
redis_url: str, redis_url: str,
client_id: str, client_id: str,
client_secret: str, client_secret: str,
callback_url: str, redirect_uri: str,
): ):
self.base_uri = 'https://osu.ppy.sh/api/v2' self.base_uri = 'https://osu.ppy.sh/api/v2'
self.client_id = client_id self.client_id = client_id
self.client_secret = client_secret self.client_secret = client_secret
self.callback_url = callback_url self.redirect_uri = redirect_uri
super().__init__( super().__init__(
base_url=self.base_uri, base_url=self.base_uri,
redis_url=redis_url, redis_url=redis_url,
key='osu', key='osu',
limit=10, limit=1200,
logger='osu! API', logger='osu! API',
) )
async def get_beatmap_favorites(
self, access_token: str, cache_time: int | None = None
):
req = await self.get(
'/me/beatmapset-favourites',
headers=self.clean_dict(
{
'Authorization': f'Bearer {access_token}',
'X-Cache-TTL': cache_time,
}
),
)
match req.status_code:
case st.OK:
return s.FavoriteBeatmaps.model_validate(req.json())
case st.UNAUTHORIZED:
raise s.Error(req.status_code, 'Unauthorized')
case _:
self.logger.error(req.text)
raise s.Error(500, 'Internal Server Error')
async def get_beatmap_packs(
self,
access_token: str,
packs_type: Literal[
'standard',
'featured',
'tournament',
'loved',
'chart',
'theme',
'artist',
] = 'standard',
cursor_string: str | None = None,
cache_time: int | None = None,
):
req = await self.get(
'/beatmaps/packs',
params=self.clean_dict(
{'type': packs_type, 'cursor_string': cursor_string}
),
headers=self.clean_dict(
{
'Authorization': f'Bearer {access_token}',
'X-Cache-TTL': cache_time,
}
),
)
match req.status_code:
case st.OK:
return s.BeatmapPacks.model_validate(req.json())
case st.UNAUTHORIZED:
raise s.Error(req.status_code, 'Unauthorized')
case _:
self.logger.error(req.text)
raise s.Error(500, 'Internal Server Error')
async def get_beatmap_pack(
self,
access_token: str,
pack: str,
legacy_only: int = 0,
cache_time: int | None = None,
):
req = await self.get(
f'/beatmaps/packs/{pack}',
params=self.clean_dict({'legacy_only': legacy_only}),
headers=self.clean_dict(
{
'Authorization': f'Bearer {access_token}',
'X-Cache-TTL': cache_time,
}
),
)
match req.status_code:
case st.OK:
return s.BeatmapPack.model_validate(req.json())
case st.UNAUTHORIZED:
raise s.Error(req.status_code, 'Unauthorized')
case st.NOT_FOUND:
raise s.Error(req.status_code, 'Not Found')
case _:
self.logger.error(req.text)
raise s.Error(500, 'Internal Server Error')
async def lookup_beatmap(
self,
access_token: str,
checksum: str | None = None,
filename: str | None = None,
beatmap_id: int | None = None,
cache_time: int | None = None,
):
req = await self.get(
'/beatmaps/lookup',
params=self.clean_dict(
{
'checksum': checksum,
'filename': filename,
'beatmap_id': beatmap_id,
}
),
headers=self.clean_dict(
{
'Authorization': f'Bearer {access_token}',
'X-Cache-TTL': cache_time,
}
),
)
match req.status_code:
case st.OK:
return s.BeatmapExtended.model_validate(req.json())
case st.UNAUTHORIZED:
raise s.Error(req.status_code, 'Unauthorized')
case st.NOT_FOUND:
raise s.Error(req.status_code, 'Not Found')
case _:
self.logger.error(req.text)
raise s.Error(500, 'Internal Server Error')
async def get_user_beatmap_score(
self,
access_token: str,
beatmap: int,
user: int,
cache_time: int | None = None,
):
req = await self.get(
f'/beatmaps/{beatmap}/scores/users/{user}',
headers=self.clean_dict(
{
'Authorization': f'Bearer {access_token}',
'X-Cache-TTL': cache_time,
}
),
)
match req.status_code:
case st.OK:
return s.BeatmapUserScore.model_validate(req.json())
case st.UNAUTHORIZED:
raise s.Error(req.status_code, 'Unauthorized')
case st.NOT_FOUND:
raise s.Error(req.status_code, 'Not Found')
case _:
self.logger.error(req.text)
raise s.Error(500, 'Internal Server Error')
async def get_user_beatmap_scores(
self,
access_token: str,
beatmap: int,
user: int,
legacy_only: int = 0,
ruleset: Literal['fruits', 'mania', 'osu', 'taiko'] | None = None,
cache_time: int | None = None,
):
req = await self.get(
f'/beatmaps/{beatmap}/scores/users/{user}/all',
params=self.clean_dict(
{'legacy_only': legacy_only, 'ruleset': ruleset}
),
headers=self.clean_dict(
{
'Authorization': f'Bearer {access_token}',
'X-Cache-TTL': cache_time,
}
),
)
match req.status_code:
case st.OK:
return s.UserBeatmapScores.model_validate(req.json())
case st.UNAUTHORIZED:
raise s.Error(req.status_code, 'Unauthorized')
case st.NOT_FOUND:
raise s.Error(req.status_code, 'Not Found')
case _:
self.logger.error(req.text)
raise s.Error(500, 'Internal Server Error')
async def get_beatmap_scores(
self,
access_token: str,
beatmap: int,
legacy_only: int = 0,
mode: Literal['fruits', 'mania', 'osu', 'taiko'] | None = None,
mods: str | None = None,
ranking_type: str | None = None,
cache_time: int | None = None,
):
req = await self.get(
f'/beatmaps/{beatmap}/scores',
params=self.clean_dict(
{
'legacy_only': legacy_only,
'mode': mode,
'mods': mods,
'ranking_type': ranking_type,
}
),
headers=self.clean_dict(
{
'Authorization': f'Bearer {access_token}',
'X-Cache-TTL': cache_time,
}
),
)
match req.status_code:
case st.OK:
return s.BeatmapScores.model_validate(req.json())
case st.UNAUTHORIZED:
raise s.Error(req.status_code, 'Unauthorized')
case _:
self.logger.error(req.text)
raise s.Error(500, 'Internal Server Error')
# TODO: implement endpoints
# https://osu.ppy.sh/docs/index.html#get-beatmaps
async def get_user_scores( async def get_user_scores(
self, self,
access_token: str, access_token: str,
user: int, user_id: int,
score_type: Literal['best', 'firsts', 'recent'], score_type: Literal['best', 'recent', 'firsts'],
legacy_only: int = 0, legacy_only: Literal[0, 1] = 0,
include_fails: int = 0, include_fails: Literal[0, 1] = 0,
mode: Literal['fruits', 'mania', 'osu', 'taiko'] | None = None, mode: Literal['fruits', 'mania', 'osu', 'taiko'] | None = None,
limit: int | None = None, limit: int | None = None,
offset: int | None = None, offset: str | None = None,
cache_time: int | None = None,
): ):
req = await self.get( req = await self.get(
f'/users/{user}/scores/{score_type}', f'/users/{user_id}/scores/{score_type}',
params=self.clean_dict( params=self.clean_dict(
{ {
'legacy_only': legacy_only, 'legacy_only': legacy_only,
@ -296,7 +52,6 @@ class osuAPIClient(AioHTTPXClient):
headers=self.clean_dict( headers=self.clean_dict(
{ {
'Authorization': f'Bearer {access_token}', 'Authorization': f'Bearer {access_token}',
'X-Cache-TTL': cache_time,
} }
), ),
) )
@ -315,9 +70,6 @@ class osuAPIClient(AioHTTPXClient):
self.logger.error(req.text) self.logger.error(req.text)
raise s.Error(500, 'Internal Server Error') raise s.Error(500, 'Internal Server Error')
# TODO: implement other endpoints
# https://osu.ppy.sh/docs/index.html#get-user-beatmaps
async def get_user( async def get_user(
self, self,
access_token: str, access_token: str,
@ -342,7 +94,7 @@ class osuAPIClient(AioHTTPXClient):
match req.status_code: match req.status_code:
case st.OK: case st.OK:
return s.GetUserSchema.model_validate(req.json()) return s.GetUser.model_validate(req.json())
case st.NOT_FOUND: case st.NOT_FOUND:
raise s.Error(req.status_code, 'Not Found') raise s.Error(req.status_code, 'Not Found')

View File

@ -114,7 +114,7 @@ class osuAuthClient(AioHTTPXClient):
return s.Token.model_validate(req.json()) return s.Token.model_validate(req.json())
case st.BAD_REQUEST: case st.BAD_REQUEST:
raise s.Error(req.status_code, req.json()['error']) raise s.Error(req.status_code, req.json()['message'])
case _: case _:
raise s.Error(req.status_code, 'Internal Server Error') raise s.Error(req.status_code, 'Internal Server Error')

View File

@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
class Error(Exception): class Error(Exception):
@ -25,432 +25,146 @@ class UserToken(Token):
class Country(BaseModel): class Country(BaseModel):
model_config = ConfigDict(extra='forbid')
code: str code: str
name: str name: str
class Beatmap(BaseModel): class Cover(BaseModel):
beatmapset_id: int model_config = ConfigDict(extra='forbid')
difficulty_rating: float
id: int custom_url: str
mode: Literal['fruits', 'mania', 'osu', 'taiko'] url: str
status: int id: int | None
total_length: int
user_id: int
version: str
class BeatmapDifficultyAttributes(BaseModel): class DailyChallengeUserStats(BaseModel):
star_rating: float model_config = ConfigDict(extra='forbid')
max_combo: int
daily_streak_best: int
class osuBeatmapDifficultyAttributes(BeatmapDifficultyAttributes): daily_streak_current: int
aim_difficulty: float last_update: str
aim_difficult_slider_count: float last_weekly_streak: str
speed_difficulty: float
speed_note_count: float
slider_factor: float
aim_difficult_strain_count: float
speed_difficult_strain_count: float
class taikoBeatmapDifficultyAttributes(BeatmapDifficultyAttributes):
mono_stamina_factor: float
class BeatmapExtended(Beatmap):
accuracy: float
ar: float
beatmapset_id: int
bpm: float | None = None
convert: bool
count_circles: int
count_sliders: int
count_spinners: int
cs: float
deleted_at: datetime | None = None
drain: float
hit_length: int
is_scoreable: bool
last_updated: datetime
mode_int: int
passcount: int
playcount: int playcount: int
ranked: int top_10p_placements: int
url: str top_50p_placements: int
user_id: int
weekly_streak_best: int
weekly_streak_current: int
class BeatmapOwner(BaseModel): class MonthlyPlaycount(BaseModel):
id: int model_config = ConfigDict(extra='forbid')
username: str
start_date: str
class UserCompletionData(BaseModel):
beatmapset_ids: list[int]
completed: bool
class BeatmapPack(BaseModel):
author: str
date: datetime
name: str
no_diff_reduction: bool
ruleset_id: int | None
tag: str
url: str
class BeatmapPlaycount(BaseModel):
beatmap_id: int
beatmap: 'Beatmap | None' = None
beatmapset: 'Beatmapset | None' = None
count: int count: int
class BeatmapScores(BaseModel): class Page(BaseModel):
scores: list['Score'] model_config = ConfigDict(extra='forbid')
userScore: 'BeatmapUserScore | None' = None
html: str
raw: str
class BeatmapUserScore(BaseModel): class RankHighest(BaseModel):
position: int model_config = ConfigDict(extra='forbid')
score: 'Score'
rank: int
updated_at: str
class Beatmapset(BaseModel): class Kudosu(BaseModel):
artist: str model_config = ConfigDict(extra='forbid')
artist_unicode: str
covers: 'Covers' available: int
creator: str total: int
favourite_count: int
id: int
nsfw: bool class ReplaysWatchedCount(BaseModel):
offset: int model_config = ConfigDict(extra='forbid')
start_date: str
count: int
class Level(BaseModel):
model_config = ConfigDict(extra='forbid')
current: int
progress: int
class GradeCounts(BaseModel):
model_config = ConfigDict(extra='forbid')
ss: int
ssh: int
s: int
sh: int
a: int
class Rank(BaseModel):
model_config = ConfigDict(extra='forbid')
country: int
class UserStatistics(BaseModel):
model_config = ConfigDict(extra='forbid')
count_100: int
count_300: int
count_50: int
count_miss: int
level: Level
global_rank: int
global_rank_percent: float
global_rank_exp: None
pp: float
pp_exp: int
ranked_score: int
hit_accuracy: float
play_count: int play_count: int
preview_url: str play_time: int
source: str total_score: int
status: str total_hits: int
spotlight: bool maximum_combo: int
title: str replays_watched_by_others: int
title_unicode: str is_ranked: bool
user_id: int grade_counts: GradeCounts
video: bool country_rank: int
rank: Rank
class Covers(BaseModel): class UserAchievement(BaseModel):
cover: str model_config = ConfigDict(extra='forbid')
cover_2x: str = Field(alias='cover@2x')
card: str
card_2x: str = Field(alias='card@2x')
list: str
list_2x: str = Field(alias='list@2x')
slimcover: str
slimcover_2x: str = Field(alias='slimcover@2x')
achieved_at: str
achievement_id: int
class BeatmapsetDiscussion(BaseModel):
beatmap: 'Beatmap | None' = None
beatmap_id: int | None = None
beatmapset: 'Beatmapset | None' = None
beatmapset_id: int
can_be_resolved: bool
can_grant_kudosu: bool
created_at: datetime
current_user_attributes: 'CurrentUserAttributes'
deleted_at: datetime | None = None
deleted_by_id: int | None = None
id: int
kudosu_denied: bool
last_post_at: datetime
message_type: Literal[
'hype', 'mapper_note', 'praise', 'problem', 'review', 'suggestion'
]
parent_id: int | None = None
posts: list['BeatmapsetDiscussionPost']
timestamp: int | None = None
updated_at: datetime | None = None
user_id: int
class RankHistory(BaseModel):
model_config = ConfigDict(extra='forbid')
class BeatmapsetDiscussionPost(BaseModel): mode: str
beatmapset_discussion_id: int data: list[int]
created_at: datetime
deleted_at: datetime | None = None
deleted_by_id: int | None = None
id: int
last_editor_id: int | None = None
message: str
system: bool
updated_at: datetime | None = None
user_id: int
class BeatmapsetDiscussionVote(BaseModel): class ActiveTournamentBanner(BaseModel):
beatmapset_discussion_id: int model_config = ConfigDict(extra='forbid')
created_at: datetime
id: int
score: int
updated_at: datetime
user_id: int
class BeatmapsetAvailability(BaseModel):
download_disabled: bool
more_information: str | None = None
class BeatmapsetHype(BaseModel):
current: int
required: int
class BeatmapsetNominationsSummary(BaseModel):
current: int
required: int
class BeatmapsetExtended(Beatmapset):
availability: BeatmapsetAvailability
bpm: float
can_be_hyped: float
deleted_at: datetime | None = None
discussion_enabled: bool
discussion_locked: bool
hype: BeatmapsetHype
is_scoreable: bool
last_updated: datetime
legacy_thread_url: str | None = None
nominations_summary: BeatmapsetNominationsSummary
ranked: int
ranked_date: datetime | None = None
rating: float
source: str
storyboard: bool
submitted_date: datetime | None = None
tags: str
class Build(BaseModel):
created_at: datetime
display_version: str
id: int
update_stream: 'UpdateStream | None' = None
users: int
version: str | None = None
youtube_id: str | None = None
class Versions(BaseModel):
next: Build | None = None
previous: Build | None = None
class ChangelogEntry(BaseModel):
category: str
created_at: datetime
github_pull_request_id: int | None = None
github_url: str | None = None
id: int | None = None
major: bool
repository_url: str | None = None
title: str | None = None
entry_type: str = Field(alias='type')
url: str | None = None
class ChatChannel(BaseModel):
channel_id: int
name: str
description: str | None = None
icon: str | None = None
channel_type: Literal[
'PUBLIC',
'PRIVATE',
'MULTIPLAYER',
'SPECTATOR',
'TEMPORARY',
'PM',
'GROUP',
'ANNOUNCE',
] = Field(alias='type')
message_length_limit: int
moderated: bool
uuid: str | None = None
class ChatMessage(BaseModel):
channel_id: int
content: str
is_action: bool
message_id: int
sender_id: int
timestamp: datetime
message_type: str = Field(alias='type')
uuid: str | None = None
class Comment(BaseModel):
pass
class CommentBundle(BaseModel):
pass
class CommentSort(BaseModel):
pass
class CommentableMeta(BaseModel):
pass
class CurrentUserAttributes(BaseModel):
pass
class Event(BaseModel):
pass
class Forum(BaseModel):
pass
class ForumPost(BaseModel):
pass
class ForumTopic(BaseModel):
pass
class GithubUser(BaseModel):
pass
class Group(BaseModel):
pass
class KudosuHistory(BaseModel):
pass
class Match(BaseModel):
pass
class MatchEvent(BaseModel):
pass
class MatchGame(BaseModel):
pass
class MultiplayerScores(BaseModel):
pass
class MultiplayerScoresAroundUser(BaseModel):
pass
class MultiplayerScoresCursor(BaseModel):
pass
class MultiplayerScoresSort(BaseModel):
pass
class NewsPost(BaseModel):
pass
class Nomination(BaseModel):
pass
class Notification(BaseModel):
pass
class RankingType(BaseModel):
pass
class Rankings(BaseModel):
pass
class Score(BaseModel):
accuracy: float
beatmap_id: int | None = None
best_id: int | None = None
build_id: int | None = None
classic_total_score: int | None = None
ended_at: datetime | None = None
has_replay: bool | None = None
id: int
is_perfect_combo: bool | None = None
legacy_perfect: bool | None = None
legacy_score_id: int | None = None
legacy_total_score: int | None = None
max_combo: int
maximum_statistics: 'ScoreStatistics | None' = None
mods: list[str]
passed: bool
playlist_item_id: int | None = None
pp: float | None = None
preserve: bool | None = None
processed: bool | None = None
rank: str
ranked: bool | None = None
room_id: int | None = None
ruleset_id: int | None = None
started_at: datetime | None = None
statistics: 'ScoreStatistics'
total_score: int | None = None
type: str
user_id: int
class ScoreStatistics(BaseModel):
pass
class Spotlight(BaseModel):
pass
class Spotlights(BaseModel):
spotlights: list[Spotlight]
class UpdateStream(BaseModel):
pass
class UserAccountHistory(BaseModel):
description: str | None = None
id: int
length: int
permanent: bool
timestamp: datetime
type: Literal['note', 'restriction', 'silence']
class ProfileBanner(BaseModel):
id: int id: int
tournament_id: int tournament_id: int
image: str | None = None image: str
image_2x: str | None = Field(alias='image@2x') image_2x: str = Field(alias='image@2x')
class UserBadge(BaseModel): class Badge(BaseModel):
model_config = ConfigDict(extra='forbid')
awarded_at: datetime awarded_at: datetime
description: str description: str
image_2x_url: str = Field(alias='image@2x_url') image_2x_url: str = Field(alias='image@2x_url')
@ -458,146 +172,200 @@ class UserBadge(BaseModel):
url: str url: str
class UserKudosu(BaseModel): class Team(BaseModel):
available: int model_config = ConfigDict(extra='forbid')
total: int
id: int
name: str
short_name: str
flag_url: str
class UserRankHighest(BaseModel): class ScoreStatistics(BaseModel):
rank: int count_100: int
updated_at: datetime count_300: int
count_50: int
count_geki: None
count_katu: None
count_miss: int
class UserMonthlyPlaycount(BaseModel): class CurrentUserAttributes(BaseModel):
start_date: datetime pin: None
count: int
class Covers(BaseModel):
cover: str
cover_2x: str = Field(..., alias='cover@2x')
card: str
card_2x: str = Field(..., alias='card@2x')
list: str
list_2x: str = Field(..., alias='list@2x')
slimcover: str
slimcover_2x: str = Field(..., alias='slimcover@2x')
class Beatmap(BaseModel):
beatmapset_id: int
difficulty_rating: float
id: int
mode: str
status: str
total_length: int
user_id: int
version: str
accuracy: float
ar: float
bpm: float
convert: bool
count_circles: int
count_sliders: int
count_spinners: int
cs: float
deleted_at: datetime | None
drain: float
hit_length: int
is_scoreable: bool
last_updated: str
mode_int: int
passcount: int
playcount: int
ranked: int
url: str
checksum: str
class Beatmapset(BaseModel):
anime_cover: bool
artist: str
artist_unicode: str
covers: Covers
creator: str
favourite_count: int
genre_id: int
hype: None
id: int
language_id: int
nsfw: bool
offset: int
play_count: int
preview_url: str
source: str
spotlight: bool
status: str
title: str
title_unicode: str
track_id: int | None
user_id: int
video: bool
class User(BaseModel): class User(BaseModel):
avatar_url: str | None = None model_config = ConfigDict(extra='forbid')
avatar_url: str
country_code: str country_code: str
default_group: str | None = None default_group: str
id: int id: int
is_active: bool is_active: bool
is_bot: bool is_bot: bool
is_deleted: bool is_deleted: bool
is_online: bool is_online: bool
is_supporter: bool is_supporter: bool
last_visit: datetime | None = None last_visit: datetime | None
pm_friends_only: bool pm_friends_only: bool
profile_colour: str | None = None profile_colour: None
username: str username: str
class UserExtended(User): class Weight(BaseModel):
discord: str | None = None percentage: float
pp: float
class Score(BaseModel):
model_config = ConfigDict(extra='forbid')
accuracy: float
best_id: int
created_at: str
id: int
max_combo: int
mode: str
mode_int: int
mods: list[str]
passed: bool
perfect: bool
pp: float | None
rank: str
replay: bool
score: int
statistics: ScoreStatistics
type: str
user_id: int
current_user_attributes: CurrentUserAttributes
beatmap: Beatmap
beatmapset: Beatmapset
user: User
weight: Weight | None = None
class GetUser(User):
model_config = ConfigDict(extra='forbid')
cover_url: str
discord: str | None
has_supported: bool has_supported: bool
interests: str | None = None interests: None
join_date: datetime join_date: str
location: str | None = None location: str | None
max_blocks: int max_blocks: int
max_friends: int max_friends: int
occupation: str | None = None occupation: None
playmode: Literal['osu', 'taiko', 'fruits', 'mania'] playmode: str
playstyle: list[str] playstyle: list[str] | None
post_count: int post_count: int
profile_hue: int | None = None profile_hue: int | None
profile_order: list[str] profile_order: list[str]
title: str | None = None title: str | None
title_url: str | None = None title_url: None
twitter: str | None = None twitter: str | None
website: str | None = None website: str | None
class UserGroup(BaseModel):
pass
class UserSilence(BaseModel):
pass
class UserStatisticsGradeCounts(BaseModel):
a: int
s: int
sh: int
ss: int
ssh: int
class UserStatisticsLevel(BaseModel):
current: int
progress: float
class UserStatistics(BaseModel):
count_100: int
count_300: int
count_50: int
count_miss: int
country_rank: int | None = None
grade_counts: UserStatisticsGradeCounts
hit_accuracy: float
is_ranked: bool
level: UserStatisticsLevel
maximum_combo: int
play_count: int
play_time: int
pp: float
global_rank: int | None = None
global_rank_exp: int | None = None
ranked_score: int
replays_watched_by_others: int
total_hits: int
total_score: int
class WikiPage(BaseModel):
pass
class FavoriteBeatmaps(BaseModel):
beatmapset_ids: list[int]
class BeatmapPacks(BaseModel):
beatmap_packs: list[BeatmapPack]
class UserBeatmapScores(BaseModel):
scores: list['Score']
class GetUserSchema(UserExtended):
account_history: list[UserAccountHistory]
active_tournament_banners: list[ProfileBanner]
badges: list[UserBadge]
beatmap_playcounts_count: int
country: Country country: Country
# cover cover: Cover
kudosu: Kudosu
account_history: list[str]
active_tournament_banner: ActiveTournamentBanner | None
active_tournament_banners: list[ActiveTournamentBanner]
badges: list[Badge]
beatmap_playcounts_count: int
comments_count: int
current_season_stats: None
daily_challenge_user_stats: DailyChallengeUserStats
favourite_beatmapset_count: int favourite_beatmapset_count: int
# follow_user_mapping: list[int]
follower_count: int follower_count: int
graveyard_beatmapset_count: int graveyard_beatmapset_count: int
groups: list[UserGroup] groups: list[str]
guest_beatmapset_count: int guest_beatmapset_count: int
is_restricted: bool | None = None
kudosu: UserKudosu
loved_beatmapset_count: int loved_beatmapset_count: int
mapping_follower_count: int mapping_follower_count: int
monthly_playcounts: list[UserMonthlyPlaycount] monthly_playcounts: list[MonthlyPlaycount]
nominated_beatmapset_count: int nominated_beatmapset_count: int
# page page: Page
pending_beatmapset_count: int pending_beatmapset_count: int
previous_usernames: list[str] previous_usernames: list[str]
rank_highest: UserRankHighest | None = None rank_highest: RankHighest
# rank_history
ranked_beatmapset_count: int ranked_beatmapset_count: int
# replays_watched_counts replays_watched_counts: list[ReplaysWatchedCount]
scores_best_count: int scores_best_count: int
scores_first_count: int scores_first_count: int
scores_pinned_count: int
scores_recent_count: int scores_recent_count: int
# session_verified: bool
statistics: UserStatistics statistics: UserStatistics
support_level: int support_level: int
# user_achievements team: Team | None
user_achievements: list[UserAchievement]
rank_history: RankHistory
rankHistory: RankHistory
ranked_and_approved_beatmapset_count: int
unranked_beatmapset_count: int