From d16a2ac1b506007c4240ce3a66a8ea444f81a9bc Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Sun, 27 Jul 2025 15:04:30 +0000 Subject: [PATCH 001/104] =?UTF-8?q?feat(multiplayer):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=88=BF=E9=97=B4=E7=94=A8=E6=88=B7=E6=B7=BB=E5=8A=A0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96=E6=88=BF=E9=97=B4=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 APIUser 模型用于表示房间内的用户 - 扩展 MultiplayerRoom 模型以支持更多房间相关功能 - 添加用户加入房间的路由和相关逻辑 - 优化 Room 模型,增加从 MultiplayerRoom 转换的方法 --- app/models/room.py | 140 +++++++++++++++++++++++++++++++++++++++++++-- app/models/user.py | 4 ++ app/router/room.py | 36 +++++++++++- 3 files changed, 174 insertions(+), 6 deletions(-) diff --git a/app/models/room.py b/app/models/room.py index 9ca24d2..793635e 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -1,11 +1,12 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from enum import Enum +from typing import Any from app.database.beatmap import Beatmap -from app.database.user import User from app.models.mods import APIMod +from app.models.user import APIUser from pydantic import BaseModel @@ -40,6 +41,75 @@ class RoomStatus(str, Enum): PLAYING = "playing" +class MultiplayerRoomState(str, Enum): + OPEN = "open" + WAITING_FOR_LOAD = "waiting_for_load" + PLAYING = "playing" + CLOSED = "closed" + + +class MultiplayerUserState(str, Enum): + IDLE = "idle" + READY = "ready" + WAITING_FOR_LOAD = "waiting_for_load" + LOADED = "loaded" + READY_FOR_GAMEPLAY = "ready_for_gameplay" + PLAYING = "playing" + FINISHED_PLAY = "finished_play" + RESULTS = "results" + SPECTATING = "spectating" + + +class DownloadState(str, Enum): + UNKONWN = "unkown" + NOT_DOWNLOADED = "not_downloaded" + DOWNLOADING = "downloading" + IMPORTING = "importing" + LOCALLY_AVAILABLE = "locally_available" + + +class BeatmapAvailability(BaseModel): + state: DownloadState + downloadProgress: float | None + + +class MultiplayerPlaylistItem(BaseModel): + id: int = 0 + ownerId: int = 0 + beatmapId: int = 0 + beatmapChecksum: str = "" + rulesetId: int = 0 + requiredMods: list[APIMod] = [] + allowedMods: list[APIMod] = [] + expired: bool = False + playlistOrder: int = 0 + playedAt: datetime | None + starRating: float = 0.0 + freestyle: bool = False + + +class MultiplayerRoomSettings(BaseModel): + name: str = "Unnamed room" + playlistItemId: int = 0 + password: str = "" + matchType: MatchType = MatchType.HEAD_TO_HEAD + queueMode: QueueMode = QueueMode.HOST_ONLY + autoStartDuration: timedelta = timedelta(0) + autoSkip: bool = False + + +class MultiplayerRoomUser(BaseModel): + id: int + state: MultiplayerUserState = MultiplayerUserState.IDLE + beatmapAvailability: BeatmapAvailability = BeatmapAvailability( + state=DownloadState.UNKONWN, downloadProgress=None + ) + mods: list[APIMod] = [] + matchState: dict[str, Any] | None + rulesetId: int | None + beatmapId: int | None + + class PlaylistItem(BaseModel): id: int | None owner_id: int @@ -75,18 +145,23 @@ class PlaylistAggregateScore(BaseModel): playlist_item_attempts: list[ItemAttemptsCount] +class MultiplayerCountdown(BaseModel): + id: int + timeRemaining: timedelta + + class Room(BaseModel): id: int | None name: str = "" password: str | None has_password: bool = False - host: User | None + host: APIUser category: RoomCategory = RoomCategory.NORMAL duration: int | None starts_at: datetime | None ends_at: datetime | None participant_count: int = 0 - recent_participants: list[User] = [] + recent_participants: list[APIUser] = [] max_attempts: int | None playlist: list[PlaylistItem] = [] playlist_item_stats: RoomPlaylistItemStats | None @@ -101,3 +176,60 @@ class Room(BaseModel): status: RoomStatus = RoomStatus.IDLE # availability 字段在当前序列化中未包含,但可能在某些场景下需要 availability: RoomAvailability | None + + @classmethod + def from_MultiplayerRoom(cls, room: MultiplayerRoom): + r = cls.model_validate(room.model_dump()) + r.id = room.roomId + r.name = room.settings.name + r.password = room.settings.password + r.has_password = bool(room.settings.password) + if room.host: + r.host.id = room.host.id + r.type = room.settings.matchType + r.queue_mode = room.settings.queueMode + r.auto_start_duration = room.settings.autoStartDuration.seconds + r.auto_skip = room.settings.autoSkip + r.channel_id = room.channelId + if room.state == MultiplayerRoomState.OPEN: + r.status = RoomStatus.IDLE + elif ( + room.state == MultiplayerRoomState.WAITING_FOR_LOAD + or room.state == MultiplayerRoomState.PLAYING + ): + r.status = RoomStatus.PLAYING + elif room.state == MultiplayerRoomState.CLOSED: + r.status = RoomStatus.IDLE + r.ends_at = datetime.utcnow() + playlist_items = [] + for multiplayer_item in room.playlist: + playlist_item = PlaylistItem( + id=multiplayer_item.id, + owner_id=multiplayer_item.ownerId, + ruleset_id=multiplayer_item.rulesetId, + expired=multiplayer_item.expired, + playlist_order=multiplayer_item.playlistOrder, + played_at=multiplayer_item.playedAt, + freestyle=multiplayer_item.freestyle, + beatmap_id=multiplayer_item.beatmapId, + beatmap=None, + ) + playlist_items.append(playlist_item) + r.playlist = playlist_items + r.participant_count = len(playlist_items) + return r + + +class MultiplayerRoom(BaseModel): + roomId: int + state: MultiplayerRoomState = MultiplayerRoomState.OPEN + settings: MultiplayerRoomSettings = MultiplayerRoomSettings() + users: list[MultiplayerRoomUser] = [] + host: MultiplayerRoomUser | None + matchState: dict[str, Any] | None + playlist: list[MultiplayerPlaylistItem] = [] + activeCountdowns: list[MultiplayerCountdown] = [] + channelId: int = 0 + + def __init__(self, roomId: int, **data): + super().__init__(roomId=roomId, **data) diff --git a/app/models/user.py b/app/models/user.py index 42251e9..0f5dab9 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -209,3 +209,7 @@ class User(BaseModel): replays_watched_counts: list[dict] = [] team: Team | None = None user_achievements: list[UserAchievement] = [] + + +class APIUser(BaseModel): + id: int diff --git a/app/router/room.py b/app/router/room.py index ed540fc..ebd8326 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -2,7 +2,11 @@ from __future__ import annotations from app.database.room import RoomIndex from app.dependencies.database import get_db, get_redis -from app.models.room import Room +from app.models.room import ( + MultiplayerRoom, + MultiplayerRoomUser, + Room, +) from .api_router import router @@ -27,9 +31,37 @@ async def get_all_rooms( for room_index in all_room_ids: dumped_room = redis.get(str(room_index.id)) if dumped_room: - actual_room = Room.model_validate_json(str(dumped_room)) + actual_room = MultiplayerRoom.model_validate_json(str(dumped_room)) + actual_room = Room.from_MultiplayerRoom(actual_room) if actual_room.status == status and actual_room.category == category: roomsList.append(actual_room) return roomsList else: raise HTTPException(status_code=500, detail="Redis Error") + + +@router.put("/rooms/{room}/users/{user}", tags=["rooms"], response_model=Room) +async def add_user_to_room( + room: int, user: int, password: str, db: AsyncSession = Depends(dependency=get_db) +): + redis = get_redis() + if redis: + dumped_room = redis.get(str(room)) + if not dumped_room: + raise HTTPException(status_code=404, detail="房间不存在") + actual_room = MultiplayerRoom.model_validate_json(str(dumped_room)) + + # 验证密码 + if password != actual_room.settings.password: + raise HTTPException(status_code=403, detail="Invalid password") + + # 继续处理加入房间的逻辑 + actual_room.users.append( + MultiplayerRoomUser( + id=user, matchState=None, rulesetId=None, beatmapId=None + ) + ) + actual_room = Room.from_MultiplayerRoom(actual_room) + return actual_room + else: + raise HTTPException(status_code=500, detail="Redis Error") From 605ad934ccc623843478b42086073c8ed0fffc35 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Tue, 29 Jul 2025 06:17:56 +0000 Subject: [PATCH 002/104] =?UTF-8?q?refactor(multiplayer):=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=88=BF=E9=97=B4=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/room.py | 297 ++++++++++++++++++++++++++------------------- 1 file changed, 169 insertions(+), 128 deletions(-) diff --git a/app/models/room.py b/app/models/room.py index 793635e..f8a4a73 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -2,13 +2,16 @@ from __future__ import annotations from datetime import datetime, timedelta from enum import Enum -from typing import Any -from app.database.beatmap import Beatmap +from app.database.beatmap import Beatmap, BeatmapResp +from app.database.user import User as DBUser +from app.fetcher import Fetcher from app.models.mods import APIMod -from app.models.user import APIUser +from app.models.user import User +from app.utils import convert_db_user_to_api_user from pydantic import BaseModel +from sqlmodel.ext.asyncio.session import AsyncSession class RoomCategory(str, Enum): @@ -61,57 +64,15 @@ class MultiplayerUserState(str, Enum): class DownloadState(str, Enum): - UNKONWN = "unkown" + UNKOWN = "unkown" NOT_DOWNLOADED = "not_downloaded" DOWNLOADING = "downloading" IMPORTING = "importing" LOCALLY_AVAILABLE = "locally_available" -class BeatmapAvailability(BaseModel): - state: DownloadState - downloadProgress: float | None - - -class MultiplayerPlaylistItem(BaseModel): - id: int = 0 - ownerId: int = 0 - beatmapId: int = 0 - beatmapChecksum: str = "" - rulesetId: int = 0 - requiredMods: list[APIMod] = [] - allowedMods: list[APIMod] = [] - expired: bool = False - playlistOrder: int = 0 - playedAt: datetime | None - starRating: float = 0.0 - freestyle: bool = False - - -class MultiplayerRoomSettings(BaseModel): - name: str = "Unnamed room" - playlistItemId: int = 0 - password: str = "" - matchType: MatchType = MatchType.HEAD_TO_HEAD - queueMode: QueueMode = QueueMode.HOST_ONLY - autoStartDuration: timedelta = timedelta(0) - autoSkip: bool = False - - -class MultiplayerRoomUser(BaseModel): - id: int - state: MultiplayerUserState = MultiplayerUserState.IDLE - beatmapAvailability: BeatmapAvailability = BeatmapAvailability( - state=DownloadState.UNKONWN, downloadProgress=None - ) - mods: list[APIMod] = [] - matchState: dict[str, Any] | None - rulesetId: int | None - beatmapId: int | None - - class PlaylistItem(BaseModel): - id: int | None + id: int owner_id: int ruleset_id: int expired: bool @@ -120,9 +81,12 @@ class PlaylistItem(BaseModel): allowed_mods: list[APIMod] = [] required_mods: list[APIMod] = [] beatmap_id: int - beatmap: Beatmap | None + beatmap: BeatmapResp | None freestyle: bool + class Config: + exclude_none = True + class RoomPlaylistItemStats(BaseModel): count_active: int @@ -145,91 +109,168 @@ class PlaylistAggregateScore(BaseModel): playlist_item_attempts: list[ItemAttemptsCount] -class MultiplayerCountdown(BaseModel): - id: int - timeRemaining: timedelta +class MultiplayerRoomSettings(BaseModel): + Name: str = "Unnamed Room" + PlaylistItemId: int + Password: str = "" + MatchType: MatchType + QueueMode: QueueMode + AutoStartDuration: timedelta + AutoSkip: bool -class Room(BaseModel): - id: int | None - name: str = "" - password: str | None - has_password: bool = False - host: APIUser - category: RoomCategory = RoomCategory.NORMAL - duration: int | None - starts_at: datetime | None - ends_at: datetime | None - participant_count: int = 0 - recent_participants: list[APIUser] = [] - max_attempts: int | None - playlist: list[PlaylistItem] = [] - playlist_item_stats: RoomPlaylistItemStats | None - difficulty_range: RoomDifficultyRange | None - type: MatchType = MatchType.PLAYLISTS - queue_mode: QueueMode = QueueMode.HOST_ONLY - auto_skip: bool = False - auto_start_duration: int = 0 - current_user_score: PlaylistAggregateScore | None - current_playlist_item: PlaylistItem | None - channel_id: int = 0 - status: RoomStatus = RoomStatus.IDLE - # availability 字段在当前序列化中未包含,但可能在某些场景下需要 - availability: RoomAvailability | None +class BeatmapAvailability(BaseModel): + State: DownloadState + DownloadProgress: float | None + + +class MatchUserState(BaseModel): + class Config: + extra = "allow" + + +class TeamVersusState(MatchUserState): + TeamId: int + + +MatchUserStateType = TeamVersusState | MatchUserState + + +class MultiplayerRoomUser(BaseModel): + UserID: int + State: MultiplayerUserState = MultiplayerUserState.IDLE + BeatmapAvailability: BeatmapAvailability + Mods: list[APIMod] = [] + MatchUserState: MatchUserStateType | None + RulesetId: int | None + BeatmapId: int | None + User: User | None @classmethod - def from_MultiplayerRoom(cls, room: MultiplayerRoom): - r = cls.model_validate(room.model_dump()) - r.id = room.roomId - r.name = room.settings.name - r.password = room.settings.password - r.has_password = bool(room.settings.password) - if room.host: - r.host.id = room.host.id - r.type = room.settings.matchType - r.queue_mode = room.settings.queueMode - r.auto_start_duration = room.settings.autoStartDuration.seconds - r.auto_skip = room.settings.autoSkip - r.channel_id = room.channelId - if room.state == MultiplayerRoomState.OPEN: - r.status = RoomStatus.IDLE - elif ( - room.state == MultiplayerRoomState.WAITING_FOR_LOAD - or room.state == MultiplayerRoomState.PLAYING - ): - r.status = RoomStatus.PLAYING - elif room.state == MultiplayerRoomState.CLOSED: - r.status = RoomStatus.IDLE - r.ends_at = datetime.utcnow() - playlist_items = [] - for multiplayer_item in room.playlist: - playlist_item = PlaylistItem( - id=multiplayer_item.id, - owner_id=multiplayer_item.ownerId, - ruleset_id=multiplayer_item.rulesetId, - expired=multiplayer_item.expired, - playlist_order=multiplayer_item.playlistOrder, - played_at=multiplayer_item.playedAt, - freestyle=multiplayer_item.freestyle, - beatmap_id=multiplayer_item.beatmapId, - beatmap=None, + async def from_id(cls, id: int, db: AsyncSession): + actualUser = ( + await db.exec( + DBUser.all_select_clause().where( + DBUser.id == id, + ) ) - playlist_items.append(playlist_item) - r.playlist = playlist_items - r.participant_count = len(playlist_items) - return r + ).first() + user = ( + await convert_db_user_to_api_user(actualUser) + if actualUser is not None + else None + ) + return MultiplayerRoomUser( + UserID=id, + MatchUserState=None, + BeatmapAvailability=BeatmapAvailability( + State=DownloadState.UNKOWN, DownloadProgress=None + ), + RulesetId=None, + BeatmapId=None, + User=user, + ) + + +class MatchRoomState(BaseModel): + class Config: + extra = "allow" + + +class MultiPlayerTeam(BaseModel): + id: int = 0 + name: str = "" + + +class TeamVersusRoomState(BaseModel): + teams: list[MultiPlayerTeam] = [] + + class Config: + pass + + @classmethod + def create_default(cls): + return cls( + teams=[ + MultiPlayerTeam(id=0, name="Team Red"), + MultiPlayerTeam(id=1, name="Team Blue"), + ] + ) + + +MatchRoomStateType = TeamVersusRoomState | MatchRoomState + + +class MultiPlayerListItem(BaseModel): + id: int + OwnerID: int + BeatmapID: int + BeatmapChecksum: str = "" + RulesetID: int + RequierdMods: list[APIMod] + AllowedMods: list[APIMod] + Expired: bool + PlaylistOrder: int | None + PlayedAt: datetime | None + StarRating: float + Freestyle: bool + + @classmethod + async def from_apiItem(cls, item: PlaylistItem, db: AsyncSession, fetcher: Fetcher): + s = cls.model_validate(item.model_dump()) + s.id = item.id + s.OwnerID = item.owner_id + if item.beatmap is None: # 从客户端接受的一定没有这字段 + cur_beatmap = await Beatmap.get_or_fetch( + db, fetcher=fetcher, bid=item.beatmap_id + ) + s.BeatmapID = cur_beatmap.id if cur_beatmap.id is not None else 0 + s.BeatmapChecksum = cur_beatmap.checksum + s.StarRating = cur_beatmap.difficulty_rating + s.RulesetID = item.ruleset_id + s.RequierdMods = item.required_mods + s.AllowedMods = item.allowed_mods + s.Expired = item.expired + s.PlaylistOrder = item.playlist_order if item.playlist_order is not None else 0 + s.PlayedAt = item.played_at + s.Freestyle = item.freestyle + + +class MultiplayerCountdown(BaseModel): + id: int = 0 + time_remaining: timedelta = timedelta(seconds=0) + is_exclusive: bool = True + + class Config: + extra = "allow" + + +class MatchStartCountdown(MultiplayerCountdown): + pass + + +class ForceGameplayStartCountdown(MultiplayerCountdown): + pass + + +class ServerShuttingCountdown(MultiplayerCountdown): + pass + + +MultiplayerCountdownType = ( + MatchStartCountdown + | ForceGameplayStartCountdown + | ServerShuttingCountdown + | MultiplayerCountdown +) class MultiplayerRoom(BaseModel): - roomId: int - state: MultiplayerRoomState = MultiplayerRoomState.OPEN - settings: MultiplayerRoomSettings = MultiplayerRoomSettings() - users: list[MultiplayerRoomUser] = [] - host: MultiplayerRoomUser | None - matchState: dict[str, Any] | None - playlist: list[MultiplayerPlaylistItem] = [] - activeCountdowns: list[MultiplayerCountdown] = [] - channelId: int = 0 - - def __init__(self, roomId: int, **data): - super().__init__(roomId=roomId, **data) + RoomId: int + State: MultiplayerRoomState + Users: list[MultiplayerRoomUser] + Host: MultiplayerRoomUser + MatchState: MatchRoomState | None + Playlist: list[MultiPlayerListItem] + ActivecCountDowns: list[MultiplayerCountdownType] + ChannelID: int From 6d736528e3fc8903fd65fc70d29874b7a87a8c1a Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Tue, 29 Jul 2025 07:05:44 +0000 Subject: [PATCH 003/104] =?UTF-8?q?refactor(multiplayer):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E6=88=BF=E9=97=B4=E6=A8=A1=E5=9E=8B=E5=B9=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 Room 模型,增加多个新字段和方法 - 新增 PlaylistItem 和 MultiplayerRoom 类 - 优化 MultiPlayerListItem 类,添加 from_mpListItem 方法 - 调整 Beatmap 类,将 id 字段标记为非可选 --- app/database/beatmap.py | 2 +- app/models/room.py | 90 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/app/database/beatmap.py b/app/database/beatmap.py index 48e7fa0..b28473e 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -62,7 +62,7 @@ class BeatmapBase(SQLModel): class Beatmap(BeatmapBase, table=True): __tablename__ = "beatmaps" # pyright: ignore[reportAssignmentType] - id: int | None = Field(default=None, primary_key=True, index=True) + id: int = Field(primary_key=True, index=True) beatmapset_id: int = Field(foreign_key="beatmapsets.id", index=True) beatmap_status: BeatmapRankStatus # optional diff --git a/app/models/room.py b/app/models/room.py index f8a4a73..8b07878 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -10,7 +10,7 @@ from app.models.mods import APIMod from app.models.user import User from app.utils import convert_db_user_to_api_user -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlmodel.ext.asyncio.session import AsyncSession @@ -87,6 +87,27 @@ class PlaylistItem(BaseModel): class Config: exclude_none = True + @classmethod + async def from_mpListItem( + cls, item: MultiPlayerListItem, db: AsyncSession, fetcher: Fetcher + ): + s = cls.model_validate(item.model_dump()) + s.id = item.id + s.owner_id = item.OwnerID + s.ruleset_id = item.RulesetID + s.expired = item.Expired + s.playlist_order = item.PlaylistOrder + s.played_at = item.PlayedAt + s.required_mods = item.RequierdMods + s.allowed_mods = item.AllowedMods + s.freestyle = item.Freestyle + cur_beatmap = await Beatmap.get_or_fetch( + db, fetcher=fetcher, bid=item.BeatmapID + ) + s.beatmap = BeatmapResp.from_db(cur_beatmap) + s.beatmap_id = item.BeatmapID + return s + class RoomPlaylistItemStats(BaseModel): count_active: int @@ -234,6 +255,7 @@ class MultiPlayerListItem(BaseModel): s.PlaylistOrder = item.playlist_order if item.playlist_order is not None else 0 s.PlayedAt = item.played_at s.Freestyle = item.freestyle + return s class MultiplayerCountdown(BaseModel): @@ -265,12 +287,78 @@ MultiplayerCountdownType = ( ) +class PlaylistStatus(BaseModel): + count_active: int + count_total: int + ruleset_ids: list[int] + + class MultiplayerRoom(BaseModel): RoomId: int State: MultiplayerRoomState + Settings: MultiplayerRoomSettings = MultiplayerRoomSettings( + PlaylistItemId=0, + MatchType=MatchType.HEAD_TO_HEAD, + QueueMode=QueueMode.HOST_ONLY, + AutoStartDuration=timedelta(0), + AutoSkip=False, + ) Users: list[MultiplayerRoomUser] Host: MultiplayerRoomUser MatchState: MatchRoomState | None Playlist: list[MultiPlayerListItem] ActivecCountDowns: list[MultiplayerCountdownType] ChannelID: int + + @classmethod + def CanAddPlayistItem(cls, user: MultiplayerRoomUser) -> bool: + return user == cls.Host or cls.Settings.QueueMode != QueueMode.HOST_ONLY + + +class Room(BaseModel): + room_id: int + name: str + password: str | None + has_password: bool = Field(exclude=True) + host: User | None + category: RoomCategory + duration: int | None + starts_at: datetime | None + ends_at: datetime | None + max_particapants: int | None = Field(exclude=True) + particapant_count: int + recent_particapants: list[User] + type: MatchType + max_attempts: int | None + playlist: list[PlaylistItem] + playlist_item_status: list[RoomPlaylistItemStats] + difficulity_range: RoomDifficultyRange + queue_mode: QueueMode + auto_skip: bool + auto_start_duration: int + current_user_score: PlaylistAggregateScore | None + current_playlist_item: PlaylistItem | None + channel_id: int + status: RoomStatus + availability: RoomAvailability = Field(exclude=True) + + class Config: + exclude_none = True + + @classmethod + async def from_mpRoom( + cls, room: MultiplayerRoom, db: AsyncSession, fetcher: Fetcher + ): + s = cls.model_validate(room.model_dump()) + s.room_id = room.RoomId + s.name = room.Settings.Name + s.password = room.Settings.Password + s.type = room.Settings.MatchType + s.queue_mode = room.Settings.QueueMode + s.auto_skip = room.Settings.AutoSkip + s.host = room.Host.User + s.playlist = [ + await PlaylistItem.from_mpListItem(item, db, fetcher) + for item in room.Playlist + ] + return s From 9402eaece6e246468fa0375110717d80016306ef Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Tue, 29 Jul 2025 07:53:34 +0000 Subject: [PATCH 004/104] =?UTF-8?q?refactor(room):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=88=BF=E9=97=B4=E7=9B=B8=E5=85=B3=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化了房间列表获取逻辑,增加了对房间状态的筛选 - 重构了单个房间获取路由,提高了代码可读性和性能 - 移除了未使用的导入和冗余代码,提高了代码整洁度 - 增加了对 Redis 的错误处理,提高了系统稳定性 --- app/router/room.py | 82 ++++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index ebd8326..b19b6f9 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -2,14 +2,11 @@ from __future__ import annotations from app.database.room import RoomIndex from app.dependencies.database import get_db, get_redis -from app.models.room import ( - MultiplayerRoom, - MultiplayerRoomUser, - Room, -) - -from .api_router import router +from app.dependencies.fetcher import get_fetcher +from app.fetcher import Fetcher +from app.models.room import MultiplayerRoom, MultiplayerRoomState, Room +from api_router import router from fastapi import Depends, HTTPException, Query from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -17,51 +14,52 @@ from sqlmodel.ext.asyncio.session import AsyncSession @router.get("/rooms", tags=["rooms"], response_model=list[Room]) async def get_all_rooms( - mode: str = Query( - None - ), # TODO: lazer源码显示房间不会是除了open以外的其他状态,先放在这里 + mode: str = Query(None), # TODO: 对房间根据状态进行筛选 status: str = Query(None), - category: str = Query(None), + category: str = Query(None), # TODO: 对房间根据分类进行筛选(真的有人用这功能吗) db: AsyncSession = Depends(get_db), + fetcher: Fetcher = Depends(get_fetcher), ): - all_room_ids = (await db.exec(select(RoomIndex).where(True))).all() + all_roomID = (await db.exec(select(RoomIndex))).all() redis = get_redis() - roomsList: list[Room] = [] - if redis: - for room_index in all_room_ids: - dumped_room = redis.get(str(room_index.id)) - if dumped_room: - actual_room = MultiplayerRoom.model_validate_json(str(dumped_room)) - actual_room = Room.from_MultiplayerRoom(actual_room) - if actual_room.status == status and actual_room.category == category: - roomsList.append(actual_room) - return roomsList + if redis is not None: + resp: list[Room] = [] + for id in all_roomID: + dumped_room = redis.get(str(id)) + validated_room = MultiplayerRoom.model_validate_json(str(dumped_room)) + flag: bool = False + if validated_room.State == MultiplayerRoomState.OPEN and status == "idle": + flag = True + elif validated_room != MultiplayerRoomState.CLOSED: + flag = True + if flag: + resp.append( + await Room.from_mpRoom( + MultiplayerRoom.model_validate_json(str(dumped_room)), + db, + fetcher, + ) + ) + return resp else: raise HTTPException(status_code=500, detail="Redis Error") -@router.put("/rooms/{room}/users/{user}", tags=["rooms"], response_model=Room) -async def add_user_to_room( - room: int, user: int, password: str, db: AsyncSession = Depends(dependency=get_db) +@router.get("/rooms/{room}", tags=["room"], response_model=Room) +async def get_room( + room: int, + db: AsyncSession = Depends(get_db), + fetcher: Fetcher = Depends(get_fetcher), ): redis = get_redis() if redis: - dumped_room = redis.get(str(room)) - if not dumped_room: - raise HTTPException(status_code=404, detail="房间不存在") - actual_room = MultiplayerRoom.model_validate_json(str(dumped_room)) - - # 验证密码 - if password != actual_room.settings.password: - raise HTTPException(status_code=403, detail="Invalid password") - - # 继续处理加入房间的逻辑 - actual_room.users.append( - MultiplayerRoomUser( - id=user, matchState=None, rulesetId=None, beatmapId=None + dumped_room = str(redis.get(str(room))) + if dumped_room is not None: + resp = await Room.from_mpRoom( + MultiplayerRoom.model_validate_json(str(dumped_room)), db, fetcher ) - ) - actual_room = Room.from_MultiplayerRoom(actual_room) - return actual_room + return resp + else: + raise HTTPException(status_code=404, detail="Room Not Found") else: - raise HTTPException(status_code=500, detail="Redis Error") + raise HTTPException(status_code=500, detail="Redis error") From 804700d5022778899a073f858436e3eca84fa273 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Tue, 29 Jul 2025 14:57:30 +0000 Subject: [PATCH 005/104] =?UTF-8?q?feat(room):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E6=88=BF=E9=97=B4=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=88=BF=E9=97=B4=E8=8E=B7=E5=8F=96=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 room 路由中添加 POST 请求处理,用于创建新房间 - 实现 MultiplayerRoom 和 MultiplayerRoomSettings 的 from_apiRoom 方法 - 优化 get_all_rooms 接口,增加对 status 参数的处理 - 调整 RoomIndex 表结构,将 id 字段类型改为 int --- app/database/room.py | 2 +- app/models/room.py | 24 ++++++++++++++++++ app/router/room.py | 58 +++++++++++++++++++++++++++++++++----------- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/app/database/room.py b/app/database/room.py index 7a1aff8..0b79ee6 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -3,4 +3,4 @@ from sqlmodel import Field, SQLModel class RoomIndex(SQLModel, table=True): __tablename__ = "mp_room_index" # pyright: ignore[reportAssignmentType] - id: int | None = Field(default=None, primary_key=True, index=True) # pyright: ignore[reportCallIssue] + id: int = Field(default=None, primary_key=True, index=True) # pyright: ignore[reportCallIssue] diff --git a/app/models/room.py b/app/models/room.py index 8b07878..2d01a26 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -139,6 +139,17 @@ class MultiplayerRoomSettings(BaseModel): AutoStartDuration: timedelta AutoSkip: bool + @classmethod + def from_apiRoom(cls, room: Room): + s = cls.model_validate(room.model_dump()) + s.Name = room.name + s.Password = room.password if room.password is not None else "" + s.MatchType = room.type + s.QueueMode = room.queue_mode + s.AutoStartDuration = timedelta(seconds=room.auto_start_duration) + s.AutoSkip = room.auto_skip + return s + class BeatmapAvailability(BaseModel): State: DownloadState @@ -314,6 +325,19 @@ class MultiplayerRoom(BaseModel): def CanAddPlayistItem(cls, user: MultiplayerRoomUser) -> bool: return user == cls.Host or cls.Settings.QueueMode != QueueMode.HOST_ONLY + @classmethod + async def from_apiRoom(cls, room: Room, db: AsyncSession, fetcher: Fetcher): + s = cls.model_validate(room.model_dump()) + s.RoomId = room.room_id if room.room_id is not None else 0 + s.ChannelID = room.channel_id + s.Settings = MultiplayerRoomSettings.from_apiRoom(room) + s.Host = await MultiplayerRoomUser.from_id(room.host.id if room.host else 0, db) + s.Playlist = [ + await MultiPlayerListItem.from_apiItem(item, db, fetcher) + for item in room.playlist + ] + return s + class Room(BaseModel): room_id: int diff --git a/app/router/room.py b/app/router/room.py index b19b6f9..385e992 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -14,9 +14,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession @router.get("/rooms", tags=["rooms"], response_model=list[Room]) async def get_all_rooms( - mode: str = Query(None), # TODO: 对房间根据状态进行筛选 - status: str = Query(None), - category: str = Query(None), # TODO: 对房间根据分类进行筛选(真的有人用这功能吗) + mode: str | None = Query(None), # TODO: 对房间根据状态进行筛选 + status: str | None = Query(None), + category: str | None = Query( + None + ), # TODO: 对房间根据分类进行筛选(真的有人用这功能吗) db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), ): @@ -28,18 +30,22 @@ async def get_all_rooms( dumped_room = redis.get(str(id)) validated_room = MultiplayerRoom.model_validate_json(str(dumped_room)) flag: bool = False - if validated_room.State == MultiplayerRoomState.OPEN and status == "idle": - flag = True - elif validated_room != MultiplayerRoomState.CLOSED: - flag = True - if flag: - resp.append( - await Room.from_mpRoom( - MultiplayerRoom.model_validate_json(str(dumped_room)), - db, - fetcher, + if status is not None: + if ( + validated_room.State == MultiplayerRoomState.OPEN + and status == "idle" + ): + flag = True + elif validated_room != MultiplayerRoomState.CLOSED: + flag = True + if flag: + resp.append( + await Room.from_mpRoom( + MultiplayerRoom.model_validate_json(str(dumped_room)), + db, + fetcher, + ) ) - ) return resp else: raise HTTPException(status_code=500, detail="Redis Error") @@ -63,3 +69,27 @@ async def get_room( raise HTTPException(status_code=404, detail="Room Not Found") else: raise HTTPException(status_code=500, detail="Redis error") + + +class APICreatedRoom(Room): + error: str | None + + +@router.post("/rooms", tags=["beatmap"], response_model=APICreatedRoom) +async def create_room( + room: Room, + db: AsyncSession = Depends(get_db), + fetcher: Fetcher = Depends(get_fetcher), +): + redis = get_redis() + if redis: + room_index = RoomIndex() + db.add(room_index) + await db.commit() + await db.refresh(room_index) + server_room = await MultiplayerRoom.from_apiRoom(room, db, fetcher) + redis.set(str(room_index.id), server_room.model_dump_json()) + room.room_id = room_index.id + return APICreatedRoom(**room.model_dump(), error=None) + else: + raise HTTPException(status_code=500, detail="redis error") From 1f8211ec3070f4ec47f14c6b7acedcb998bc6bb0 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Tue, 29 Jul 2025 15:05:44 +0000 Subject: [PATCH 006/104] =?UTF-8?q?feat(room):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=88=BF=E9=97=B4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了删除房间的 API 接口 - 删除房间时,同时从 Redis 和数据库中移除相关数据 --- app/router/room.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/router/room.py b/app/router/room.py index 385e992..6e6f4a1 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -93,3 +93,14 @@ async def create_room( return APICreatedRoom(**room.model_dump(), error=None) else: raise HTTPException(status_code=500, detail="redis error") + + +@router.delete("/rooms/{room}", tags=["room"]) +async def remove_room(room: int, db: AsyncSession = Depends(get_db)): + redis = get_redis() + if redis: + redis.delete(str(room)) + room_index = await db.get(RoomIndex, room) + if room_index: + await db.delete(room_index) + await db.commit() From d399cb52e261571fb946bb0b6e809023f932bd6d Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 1 Aug 2025 11:00:57 +0000 Subject: [PATCH 007/104] fix(signarl): wrong msgpack encode --- app/models/signalr.py | 46 +++++++++++++++++-- app/signalr/packet.py | 2 +- .../msgpack_lazer_api/msgpack_lazer_api.pyi | 2 +- packages/msgpack_lazer_api/src/decode.rs | 4 +- packages/msgpack_lazer_api/src/encode.rs | 10 ++-- 5 files changed, 50 insertions(+), 14 deletions(-) diff --git a/app/models/signalr.py b/app/models/signalr.py index 37b2741..202da4f 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -1,10 +1,12 @@ from __future__ import annotations import datetime -from typing import Any, get_origin +from enum import Enum +from typing import Any from pydantic import ( BaseModel, + BeforeValidator, ConfigDict, Field, TypeAdapter, @@ -17,22 +19,56 @@ def serialize_to_list(value: BaseModel) -> list[Any]: data = [] for field, info in value.__class__.model_fields.items(): v = getattr(value, field) - anno = get_origin(info.annotation) - if anno and issubclass(anno, BaseModel): + typ = v.__class__ + if issubclass(typ, BaseModel): data.append(serialize_to_list(v)) - elif anno and issubclass(anno, list): + elif issubclass(typ, list): data.append( TypeAdapter( info.annotation, config=ConfigDict(arbitrary_types_allowed=True) ).dump_python(v) ) - elif isinstance(v, datetime.datetime): + elif issubclass(typ, datetime.datetime): data.append([v, 0]) + elif issubclass(typ, Enum): + list_ = list(typ) + data.append(list_.index(v) if v in list_ else v.value) else: data.append(v) return data +def _by_index(v: Any, class_: type[Enum]): + enum_list = list(class_) + if not isinstance(v, int): + return v + if 0 <= v < len(enum_list): + return enum_list[v] + raise ValueError( + f"Value {v} is out of range for enum " + f"{class_.__name__} with {len(enum_list)} items" + ) + + +def EnumByIndex(enum_class: type[Enum]) -> BeforeValidator: + return BeforeValidator(lambda v: _by_index(v, enum_class)) + + +def msgpack_union(v): + data = v[1] + data.append(v[0]) + return data + + +def msgpack_union_dump(v: BaseModel) -> list[Any]: + _type = getattr(v, "type", None) + if _type is None: + raise ValueError( + f"Model {v.__class__.__name__} does not have a '_type' attribute" + ) + return [_type, serialize_to_list(v)] + + class MessagePackArrayModel(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index e361ef8..387231c 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -158,7 +158,7 @@ class MsgpackProtocol: result_kind = 2 if packet.error: result_kind = 1 - elif packet.result is None: + elif packet.result is not None: result_kind = 3 payload.extend( [ diff --git a/packages/msgpack_lazer_api/msgpack_lazer_api.pyi b/packages/msgpack_lazer_api/msgpack_lazer_api.pyi index 88b79c5..b8653f0 100644 --- a/packages/msgpack_lazer_api/msgpack_lazer_api.pyi +++ b/packages/msgpack_lazer_api/msgpack_lazer_api.pyi @@ -5,7 +5,7 @@ class APIMod: @property def acronym(self) -> str: ... @property - def settings(self) -> str: ... + def settings(self) -> dict[str, Any]: ... def encode(obj: Any) -> bytes: ... def decode(data: bytes) -> Any: ... diff --git a/packages/msgpack_lazer_api/src/decode.rs b/packages/msgpack_lazer_api/src/decode.rs index 15156ca..b8e239b 100644 --- a/packages/msgpack_lazer_api/src/decode.rs +++ b/packages/msgpack_lazer_api/src/decode.rs @@ -13,6 +13,8 @@ pub fn read_object( match rmp::decode::read_marker(cursor) { Ok(marker) => match marker { rmp::Marker::Null => Ok(py.None()), + rmp::Marker::True => Ok(true.into_py_any(py)?), + rmp::Marker::False => Ok(false.into_py_any(py)?), rmp::Marker::FixPos(val) => Ok(val.into_pyobject(py)?.into_any().unbind()), rmp::Marker::FixNeg(val) => Ok(val.into_pyobject(py)?.into_any().unbind()), rmp::Marker::U8 => { @@ -86,8 +88,6 @@ pub fn read_object( cursor.read_exact(&mut data).map_err(to_py_err)?; Ok(data.into_pyobject(py)?.into_any().unbind()) } - rmp::Marker::True => Ok(true.into_py_any(py)?), - rmp::Marker::False => Ok(false.into_py_any(py)?), rmp::Marker::FixStr(len) => read_string(py, cursor, len as u32), rmp::Marker::Str8 => { let mut buf = [0u8; 1]; diff --git a/packages/msgpack_lazer_api/src/encode.rs b/packages/msgpack_lazer_api/src/encode.rs index 88a732b..0e0907c 100644 --- a/packages/msgpack_lazer_api/src/encode.rs +++ b/packages/msgpack_lazer_api/src/encode.rs @@ -110,12 +110,12 @@ pub fn write_object(buf: &mut Vec, obj: &Bound<'_, PyAny>) { write_list(buf, list); } else if let Ok(string) = obj.downcast::() { write_string(buf, string); - } else if let Ok(integer) = obj.downcast::() { - write_integer(buf, integer); - } else if let Ok(float) = obj.downcast::() { - write_float(buf, float); } else if let Ok(boolean) = obj.downcast::() { - write_bool(buf, boolean); + write_bool(buf, boolean); + } else if let Ok(float) = obj.downcast::() { + write_float(buf, float); + } else if let Ok(integer) = obj.downcast::() { + write_integer(buf, integer); } else if let Ok(bytes) = obj.downcast::() { write_bin(buf, bytes); } else if let Ok(dict) = obj.downcast::() { From a25cb852d9cd0a39b7b4beb44ab2edd70538d09e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 1 Aug 2025 11:08:59 +0000 Subject: [PATCH 008/104] feat(multiplay): support `CreateRoom` hub method --- app/database/__init__.py | 8 + app/database/playlist_attempts.py | 9 + app/database/playlists.py | 85 ++++++++ app/database/room.py | 137 ++++++++++++- app/models/mods.py | 14 +- app/models/multiplayer_hub.py | 168 ++++++++++++++++ app/models/room.py | 313 +----------------------------- app/router/__init__.py | 8 +- app/router/room.py | 143 ++++++-------- app/signalr/hub/multiplayer.py | 101 +++++++++- main.py | 7 +- 11 files changed, 590 insertions(+), 403 deletions(-) create mode 100644 app/database/playlist_attempts.py create mode 100644 app/database/playlists.py create mode 100644 app/models/multiplayer_hub.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 6e2e8c5..2c01f7a 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -15,8 +15,11 @@ from .lazer_user import ( User, UserResp, ) +from .playlist_attempts import ItemAttemptsCount +from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore from .relationship import Relationship, RelationshipResp, RelationshipType +from .room import Room, RoomResp from .score import ( Score, ScoreBase, @@ -43,11 +46,16 @@ __all__ = [ "DailyChallengeStats", "DailyChallengeStatsResp", "FavouriteBeatmapset", + "ItemAttemptsCount", "OAuthToken", "PPBestScore", + "Playlist", + "PlaylistResp", "Relationship", "RelationshipResp", "RelationshipType", + "Room", + "RoomResp", "Score", "ScoreBase", "ScoreResp", diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py new file mode 100644 index 0000000..5b4710a --- /dev/null +++ b/app/database/playlist_attempts.py @@ -0,0 +1,9 @@ +from sqlmodel import Field, SQLModel + + +class ItemAttemptsCount(SQLModel, table=True): + __tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType] + id: int = Field(foreign_key="room_playlists.db_id", primary_key=True, index=True) + room_id: int = Field(foreign_key="rooms.id", index=True) + attempts: int = Field(default=0) + passed: int = Field(default=0) diff --git a/app/database/playlists.py b/app/database/playlists.py new file mode 100644 index 0000000..42567b6 --- /dev/null +++ b/app/database/playlists.py @@ -0,0 +1,85 @@ +from datetime import datetime +from typing import TYPE_CHECKING + +from app.models.model import UTCBaseModel +from app.models.mods import APIMod, msgpack_to_apimod +from app.models.multiplayer_hub import PlaylistItem + +from .beatmap import Beatmap, BeatmapResp + +from sqlmodel import ( + JSON, + BigInteger, + Column, + DateTime, + Field, + ForeignKey, + Relationship, + SQLModel, +) + +if TYPE_CHECKING: + from .room import Room + + +class PlaylistBase(SQLModel, UTCBaseModel): + id: int = 0 + owner_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"))) + ruleset_id: int = Field(ge=0, le=3) + expired: bool = Field(default=False) + playlist_order: int = Field(default=0) + played_at: datetime | None = Field( + sa_column=Column(DateTime(timezone=True)), + default=None, + ) + allowed_mods: list[APIMod] = Field( + default_factory=list, + sa_column=Column(JSON), + ) + required_mods: list[APIMod] = Field( + default_factory=list, + sa_column=Column(JSON), + ) + beatmap_id: int = Field( + foreign_key="beatmaps.id", + ) + freestyle: bool = Field(default=False) + + +class Playlist(PlaylistBase, table=True): + __tablename__ = "room_playlists" # pyright: ignore[reportAssignmentType] + db_id: int = Field(default=None, primary_key=True, index=True, exclude=True) + room_id: int = Field(foreign_key="rooms.id", exclude=True) + + beatmap: Beatmap = Relationship( + sa_relationship_kwargs={ + "lazy": "joined", + } + ) + room: "Room" = Relationship() + + @classmethod + async def from_hub(cls, playlist: PlaylistItem, room_id: int) -> "Playlist": + return cls( + id=playlist.id, + owner_id=playlist.owner_id, + ruleset_id=playlist.ruleset_id, + beatmap_id=playlist.beatmap_id, + required_mods=[msgpack_to_apimod(mod) for mod in playlist.required_mods], + allowed_mods=[msgpack_to_apimod(mod) for mod in playlist.allowed_mods], + expired=playlist.expired, + playlist_order=playlist.order, + played_at=playlist.played_at, + freestyle=playlist.freestyle, + room_id=room_id, + ) + + +class PlaylistResp(PlaylistBase): + beatmap: BeatmapResp | None = None + + @classmethod + async def from_db(cls, playlist: Playlist) -> "PlaylistResp": + resp = cls.model_validate(playlist) + resp.beatmap = await BeatmapResp.from_db(playlist.beatmap) + return resp diff --git a/app/database/room.py b/app/database/room.py index 0b79ee6..8eb882d 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -1,6 +1,135 @@ -from sqlmodel import Field, SQLModel +from datetime import UTC, datetime + +from app.models.multiplayer_hub import ServerMultiplayerRoom +from app.models.room import ( + MatchType, + QueueMode, + RoomCategory, + RoomDifficultyRange, + RoomPlaylistItemStats, + RoomStatus, +) + +from .lazer_user import User, UserResp +from .playlist_attempts import ItemAttemptsCount +from .playlists import Playlist, PlaylistResp + +from sqlmodel import ( + BigInteger, + Column, + DateTime, + Field, + ForeignKey, + Relationship, + SQLModel, +) -class RoomIndex(SQLModel, table=True): - __tablename__ = "mp_room_index" # pyright: ignore[reportAssignmentType] - id: int = Field(default=None, primary_key=True, index=True) # pyright: ignore[reportCallIssue] +class RoomBase(SQLModel): + name: str = Field(index=True) + category: RoomCategory = Field(default=RoomCategory.NORMAL, index=True) + duration: int | None = Field(default=None) # minutes + starts_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), + ), + default=datetime.now(UTC), + ) + ended_at: datetime | None = Field( + sa_column=Column( + DateTime(timezone=True), + ), + default=None, + ) + participant_count: int = Field(default=0) + max_attempts: int | None = Field(default=None) # playlists + type: MatchType + queue_mode: QueueMode + auto_skip: bool + auto_start_duration: int + status: RoomStatus + # TODO: channel_id + # recent_participants: list[User] + + +class Room(RoomBase, table=True): + __tablename__ = "rooms" # pyright: ignore[reportAssignmentType] + id: int = Field(default=None, primary_key=True, index=True) + host_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) + ) + + host: User = Relationship() + playlist: list[Playlist] = Relationship( + sa_relationship_kwargs={ + "lazy": "joined", + "cascade": "all, delete-orphan", + "overlaps": "room", + } + ) + # playlist_item_attempts: list["ItemAttemptsCount"] = Relationship( + # sa_relationship_kwargs={ + # "lazy": "joined", + # "cascade": "all, delete-orphan", + # "primaryjoin": "ItemAttemptsCount.room_id == Room.id", + # } + # ) + + +class RoomResp(RoomBase): + id: int + password: str | None = None + host: UserResp | None = None + playlist: list[PlaylistResp] = [] + playlist_item_stats: RoomPlaylistItemStats | None = None + difficulty_range: RoomDifficultyRange | None = None + current_playlist_item: PlaylistResp | None = None + playlist_item_attempts: list[ItemAttemptsCount] = [] + + @classmethod + async def from_db(cls, room: Room) -> "RoomResp": + resp = cls.model_validate(room.model_dump()) + + stats = RoomPlaylistItemStats(count_active=0, count_total=0) + difficulty_range = RoomDifficultyRange( + min=0, + max=0, + ) + rulesets = set() + for playlist in room.playlist: + stats.count_total += 1 + if not playlist.expired: + stats.count_active += 1 + rulesets.add(playlist.ruleset_id) + difficulty_range.min = min( + difficulty_range.min, playlist.beatmap.difficulty_rating + ) + difficulty_range.max = max( + difficulty_range.max, playlist.beatmap.difficulty_rating + ) + resp.playlist.append(await PlaylistResp.from_db(playlist)) + stats.ruleset_ids = list(rulesets) + resp.playlist_item_stats = stats + resp.difficulty_range = difficulty_range + resp.current_playlist_item = resp.playlist[-1] if resp.playlist else None + # resp.playlist_item_attempts = room.playlist_item_attempts + + return resp + + @classmethod + async def from_hub(cls, server_room: ServerMultiplayerRoom) -> "RoomResp": + room = server_room.room + resp = cls( + id=room.room_id, + name=room.settings.name, + type=room.settings.match_type, + queue_mode=room.settings.queue_mode, + auto_skip=room.settings.auto_skip, + auto_start_duration=room.settings.auto_start_duration, + status=server_room.status, + category=server_room.category, + # duration = room.settings.duration, + starts_at=server_room.start_at, + participant_count=len(room.users), + ) + return resp diff --git a/app/models/mods.py b/app/models/mods.py index abcd2cd..4b20138 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -5,10 +5,12 @@ from typing import Literal, NotRequired, TypedDict from app.path import STATIC_DIR +from msgpack_lazer_api import APIMod as MsgpackAPIMod + class APIMod(TypedDict): acronym: str - settings: NotRequired[dict[str, bool | float | str]] + settings: NotRequired[dict[str, bool | float | str | int]] # https://github.com/ppy/osu-api/wiki#mods @@ -167,3 +169,13 @@ def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool: if expected_value != NO_CHECK and value != expected_value: return False return True + + +def msgpack_to_apimod(mod: MsgpackAPIMod) -> APIMod: + """ + Convert a MsgpackAPIMod to an APIMod. + """ + return APIMod( + acronym=mod.acronym, + settings=mod.settings, + ) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py new file mode 100644 index 0000000..fa5e935 --- /dev/null +++ b/app/models/multiplayer_hub.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import datetime +from typing import Annotated, Any, Literal + +from .room import ( + DownloadState, + MatchType, + MultiplayerRoomState, + MultiplayerUserState, + QueueMode, + RoomCategory, + RoomStatus, +) +from .signalr import ( + EnumByIndex, + MessagePackArrayModel, + UserState, + msgpack_union, + msgpack_union_dump, +) + +from msgpack_lazer_api import APIMod +from pydantic import BaseModel, Field, field_serializer, field_validator + + +class MultiplayerClientState(UserState): + room_id: int = 0 + + +class MultiplayerRoomSettings(MessagePackArrayModel): + name: str = "Unnamed Room" + playlist_item_id: int = 0 + password: str = "" + match_type: Annotated[MatchType, EnumByIndex(MatchType)] = MatchType.HEAD_TO_HEAD + queue_mode: Annotated[QueueMode, EnumByIndex(QueueMode)] = QueueMode.HOST_ONLY + auto_start_duration: int = 0 + auto_skip: bool = False + + +class BeatmapAvailability(MessagePackArrayModel): + state: Annotated[DownloadState, EnumByIndex(DownloadState)] = DownloadState.UNKNOWN + progress: float | None = None + + +class _MatchUserState(MessagePackArrayModel): ... + + +class TeamVersusUserState(_MatchUserState): + team_id: int + + type: Literal[0] = Field(0, exclude=True) + + +MatchUserState = TeamVersusUserState + + +class _MatchRoomState(MessagePackArrayModel): ... + + +class MultiplayerTeam(MessagePackArrayModel): + id: int + name: str + + +class TeamVersusRoomState(_MatchRoomState): + teams: list[MultiplayerTeam] = Field( + default_factory=lambda: [ + MultiplayerTeam(id=0, name="Team Red"), + MultiplayerTeam(id=1, name="Team Blue"), + ] + ) + + type: Literal[0] = Field(0, exclude=True) + + +MatchRoomState = TeamVersusRoomState + + +class PlaylistItem(MessagePackArrayModel): + id: int + owner_id: int + beatmap_id: int + checksum: str + ruleset_id: int + required_mods: list[APIMod] = Field(default_factory=list) + allowed_mods: list[APIMod] = Field(default_factory=list) + expired: bool + order: int + played_at: datetime.datetime | None = None + star: float + freestyle: bool + + +class _MultiplayerCountdown(MessagePackArrayModel): + id: int + remaining: int + is_exclusive: bool + + +class MatchStartCountdown(_MultiplayerCountdown): + type: Literal[0] = Field(0, exclude=True) + + +class ForceGameplayStartCountdown(_MultiplayerCountdown): + type: Literal[1] = Field(1, exclude=True) + + +class ServerShuttingDownCountdown(_MultiplayerCountdown): + type: Literal[2] = Field(2, exclude=True) + + +MultiplayerCountdown = ( + MatchStartCountdown | ForceGameplayStartCountdown | ServerShuttingDownCountdown +) + + +class MultiplayerRoomUser(MessagePackArrayModel): + user_id: int + state: Annotated[MultiplayerUserState, EnumByIndex(MultiplayerUserState)] = ( + MultiplayerUserState.IDLE + ) + availability: BeatmapAvailability = BeatmapAvailability( + state=DownloadState.UNKNOWN, progress=None + ) + mods: list[APIMod] = Field(default_factory=list) + match_state: MatchUserState | None = None + ruleset_id: int | None = None # freestyle + beatmap_id: int | None = None # freestyle + + @field_validator("match_state", mode="before") + def union_validate(v: Any): + if isinstance(v, list): + return msgpack_union(v) + return v + + @field_serializer("match_state") + def union_serialize(v: Any): + return msgpack_union_dump(v) + + +class MultiplayerRoom(MessagePackArrayModel): + room_id: int + state: Annotated[MultiplayerRoomState, EnumByIndex(MultiplayerRoomState)] + settings: MultiplayerRoomSettings + users: list[MultiplayerRoomUser] = Field(default_factory=list) + host: MultiplayerRoomUser | None = None + match_state: MatchRoomState | None = None + playlist: list[PlaylistItem] = Field(default_factory=list) + active_cooldowns: list[MultiplayerCountdown] = Field(default_factory=list) + channel_id: int + + @field_validator("match_state", mode="before") + def union_validate(v: Any): + if isinstance(v, list): + return msgpack_union(v) + return v + + @field_serializer("match_state") + def union_serialize(v: Any): + return msgpack_union_dump(v) + + +class ServerMultiplayerRoom(BaseModel): + room: MultiplayerRoom + category: RoomCategory + status: RoomStatus + start_at: datetime.datetime diff --git a/app/models/room.py b/app/models/room.py index 2d01a26..42f897c 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -1,17 +1,8 @@ from __future__ import annotations -from datetime import datetime, timedelta from enum import Enum -from app.database.beatmap import Beatmap, BeatmapResp -from app.database.user import User as DBUser -from app.fetcher import Fetcher -from app.models.mods import APIMod -from app.models.user import User -from app.utils import convert_db_user_to_api_user - -from pydantic import BaseModel, Field -from sqlmodel.ext.asyncio.session import AsyncSession +from pydantic import BaseModel class RoomCategory(str, Enum): @@ -64,51 +55,13 @@ class MultiplayerUserState(str, Enum): class DownloadState(str, Enum): - UNKOWN = "unkown" + UNKNOWN = "unknown" NOT_DOWNLOADED = "not_downloaded" DOWNLOADING = "downloading" IMPORTING = "importing" LOCALLY_AVAILABLE = "locally_available" -class PlaylistItem(BaseModel): - id: int - owner_id: int - ruleset_id: int - expired: bool - playlist_order: int | None - played_at: datetime | None - allowed_mods: list[APIMod] = [] - required_mods: list[APIMod] = [] - beatmap_id: int - beatmap: BeatmapResp | None - freestyle: bool - - class Config: - exclude_none = True - - @classmethod - async def from_mpListItem( - cls, item: MultiPlayerListItem, db: AsyncSession, fetcher: Fetcher - ): - s = cls.model_validate(item.model_dump()) - s.id = item.id - s.owner_id = item.OwnerID - s.ruleset_id = item.RulesetID - s.expired = item.Expired - s.playlist_order = item.PlaylistOrder - s.played_at = item.PlayedAt - s.required_mods = item.RequierdMods - s.allowed_mods = item.AllowedMods - s.freestyle = item.Freestyle - cur_beatmap = await Beatmap.get_or_fetch( - db, fetcher=fetcher, bid=item.BeatmapID - ) - s.beatmap = BeatmapResp.from_db(cur_beatmap) - s.beatmap_id = item.BeatmapID - return s - - class RoomPlaylistItemStats(BaseModel): count_active: int count_total: int @@ -120,269 +73,7 @@ class RoomDifficultyRange(BaseModel): max: float -class ItemAttemptsCount(BaseModel): - id: int - attempts: int - passed: bool - - -class PlaylistAggregateScore(BaseModel): - playlist_item_attempts: list[ItemAttemptsCount] - - -class MultiplayerRoomSettings(BaseModel): - Name: str = "Unnamed Room" - PlaylistItemId: int - Password: str = "" - MatchType: MatchType - QueueMode: QueueMode - AutoStartDuration: timedelta - AutoSkip: bool - - @classmethod - def from_apiRoom(cls, room: Room): - s = cls.model_validate(room.model_dump()) - s.Name = room.name - s.Password = room.password if room.password is not None else "" - s.MatchType = room.type - s.QueueMode = room.queue_mode - s.AutoStartDuration = timedelta(seconds=room.auto_start_duration) - s.AutoSkip = room.auto_skip - return s - - -class BeatmapAvailability(BaseModel): - State: DownloadState - DownloadProgress: float | None - - -class MatchUserState(BaseModel): - class Config: - extra = "allow" - - -class TeamVersusState(MatchUserState): - TeamId: int - - -MatchUserStateType = TeamVersusState | MatchUserState - - -class MultiplayerRoomUser(BaseModel): - UserID: int - State: MultiplayerUserState = MultiplayerUserState.IDLE - BeatmapAvailability: BeatmapAvailability - Mods: list[APIMod] = [] - MatchUserState: MatchUserStateType | None - RulesetId: int | None - BeatmapId: int | None - User: User | None - - @classmethod - async def from_id(cls, id: int, db: AsyncSession): - actualUser = ( - await db.exec( - DBUser.all_select_clause().where( - DBUser.id == id, - ) - ) - ).first() - user = ( - await convert_db_user_to_api_user(actualUser) - if actualUser is not None - else None - ) - return MultiplayerRoomUser( - UserID=id, - MatchUserState=None, - BeatmapAvailability=BeatmapAvailability( - State=DownloadState.UNKOWN, DownloadProgress=None - ), - RulesetId=None, - BeatmapId=None, - User=user, - ) - - -class MatchRoomState(BaseModel): - class Config: - extra = "allow" - - -class MultiPlayerTeam(BaseModel): - id: int = 0 - name: str = "" - - -class TeamVersusRoomState(BaseModel): - teams: list[MultiPlayerTeam] = [] - - class Config: - pass - - @classmethod - def create_default(cls): - return cls( - teams=[ - MultiPlayerTeam(id=0, name="Team Red"), - MultiPlayerTeam(id=1, name="Team Blue"), - ] - ) - - -MatchRoomStateType = TeamVersusRoomState | MatchRoomState - - -class MultiPlayerListItem(BaseModel): - id: int - OwnerID: int - BeatmapID: int - BeatmapChecksum: str = "" - RulesetID: int - RequierdMods: list[APIMod] - AllowedMods: list[APIMod] - Expired: bool - PlaylistOrder: int | None - PlayedAt: datetime | None - StarRating: float - Freestyle: bool - - @classmethod - async def from_apiItem(cls, item: PlaylistItem, db: AsyncSession, fetcher: Fetcher): - s = cls.model_validate(item.model_dump()) - s.id = item.id - s.OwnerID = item.owner_id - if item.beatmap is None: # 从客户端接受的一定没有这字段 - cur_beatmap = await Beatmap.get_or_fetch( - db, fetcher=fetcher, bid=item.beatmap_id - ) - s.BeatmapID = cur_beatmap.id if cur_beatmap.id is not None else 0 - s.BeatmapChecksum = cur_beatmap.checksum - s.StarRating = cur_beatmap.difficulty_rating - s.RulesetID = item.ruleset_id - s.RequierdMods = item.required_mods - s.AllowedMods = item.allowed_mods - s.Expired = item.expired - s.PlaylistOrder = item.playlist_order if item.playlist_order is not None else 0 - s.PlayedAt = item.played_at - s.Freestyle = item.freestyle - return s - - -class MultiplayerCountdown(BaseModel): - id: int = 0 - time_remaining: timedelta = timedelta(seconds=0) - is_exclusive: bool = True - - class Config: - extra = "allow" - - -class MatchStartCountdown(MultiplayerCountdown): - pass - - -class ForceGameplayStartCountdown(MultiplayerCountdown): - pass - - -class ServerShuttingCountdown(MultiplayerCountdown): - pass - - -MultiplayerCountdownType = ( - MatchStartCountdown - | ForceGameplayStartCountdown - | ServerShuttingCountdown - | MultiplayerCountdown -) - - class PlaylistStatus(BaseModel): count_active: int count_total: int ruleset_ids: list[int] - - -class MultiplayerRoom(BaseModel): - RoomId: int - State: MultiplayerRoomState - Settings: MultiplayerRoomSettings = MultiplayerRoomSettings( - PlaylistItemId=0, - MatchType=MatchType.HEAD_TO_HEAD, - QueueMode=QueueMode.HOST_ONLY, - AutoStartDuration=timedelta(0), - AutoSkip=False, - ) - Users: list[MultiplayerRoomUser] - Host: MultiplayerRoomUser - MatchState: MatchRoomState | None - Playlist: list[MultiPlayerListItem] - ActivecCountDowns: list[MultiplayerCountdownType] - ChannelID: int - - @classmethod - def CanAddPlayistItem(cls, user: MultiplayerRoomUser) -> bool: - return user == cls.Host or cls.Settings.QueueMode != QueueMode.HOST_ONLY - - @classmethod - async def from_apiRoom(cls, room: Room, db: AsyncSession, fetcher: Fetcher): - s = cls.model_validate(room.model_dump()) - s.RoomId = room.room_id if room.room_id is not None else 0 - s.ChannelID = room.channel_id - s.Settings = MultiplayerRoomSettings.from_apiRoom(room) - s.Host = await MultiplayerRoomUser.from_id(room.host.id if room.host else 0, db) - s.Playlist = [ - await MultiPlayerListItem.from_apiItem(item, db, fetcher) - for item in room.playlist - ] - return s - - -class Room(BaseModel): - room_id: int - name: str - password: str | None - has_password: bool = Field(exclude=True) - host: User | None - category: RoomCategory - duration: int | None - starts_at: datetime | None - ends_at: datetime | None - max_particapants: int | None = Field(exclude=True) - particapant_count: int - recent_particapants: list[User] - type: MatchType - max_attempts: int | None - playlist: list[PlaylistItem] - playlist_item_status: list[RoomPlaylistItemStats] - difficulity_range: RoomDifficultyRange - queue_mode: QueueMode - auto_skip: bool - auto_start_duration: int - current_user_score: PlaylistAggregateScore | None - current_playlist_item: PlaylistItem | None - channel_id: int - status: RoomStatus - availability: RoomAvailability = Field(exclude=True) - - class Config: - exclude_none = True - - @classmethod - async def from_mpRoom( - cls, room: MultiplayerRoom, db: AsyncSession, fetcher: Fetcher - ): - s = cls.model_validate(room.model_dump()) - s.room_id = room.RoomId - s.name = room.Settings.Name - s.password = room.Settings.Password - s.type = room.Settings.MatchType - s.queue_mode = room.Settings.QueueMode - s.auto_skip = room.Settings.AutoSkip - s.host = room.Host.User - s.playlist = [ - await PlaylistItem.from_mpListItem(item, db, fetcher) - for item in room.Playlist - ] - return s diff --git a/app/router/__init__.py b/app/router/__init__.py index 1e87343..22f6c70 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -7,6 +7,7 @@ from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401 beatmapset, me, relationship, + room, score, user, ) @@ -14,4 +15,9 @@ from .api_router import router as api_router from .auth import router as auth_router from .fetcher import fetcher_router as fetcher_router -__all__ = ["api_router", "auth_router", "fetcher_router", "signalr_router"] +__all__ = [ + "api_router", + "auth_router", + "fetcher_router", + "signalr_router", +] diff --git a/app/router/room.py b/app/router/room.py index a2347ec..ba909c6 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -1,109 +1,86 @@ from __future__ import annotations -from app.database.room import RoomIndex +from typing import Literal + +from app.database.room import RoomResp from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.fetcher import Fetcher -from app.models.room import MultiplayerRoom, MultiplayerRoomState, Room +from app.models.room import RoomStatus +from app.signalr.hub import MultiplayerHubs from .api_router import router -from fastapi import Depends, HTTPException, Query +from fastapi import Depends, Query from redis.asyncio import Redis -from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession -@router.get("/rooms", tags=["rooms"], response_model=list[Room]) +@router.get("/rooms", tags=["rooms"], response_model=list[RoomResp]) async def get_all_rooms( - mode: str | None = Query(None), # TODO: 对房间根据状态进行筛选 - status: str | None = Query(None), - category: str | None = Query( + mode: Literal["open", "ended", "participated", "owned", None] = Query( None - ), # TODO: 对房间根据分类进行筛选(真的有人用这功能吗) + ), # TODO: 对房间根据状态进行筛选 + category: str = Query(default="realtime"), # TODO + status: RoomStatus | None = Query(None), db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), redis: Redis = Depends(get_redis), ): - all_roomID = (await db.exec(select(RoomIndex))).all() - redis = get_redis() - if redis is not None: - resp: list[Room] = [] - for id in all_roomID: - dumped_room = redis.get(str(id)) - validated_room = MultiplayerRoom.model_validate_json(str(dumped_room)) - flag: bool = False - if status is not None: - if ( - validated_room.State == MultiplayerRoomState.OPEN - and status == "idle" - ): - flag = True - elif validated_room != MultiplayerRoomState.CLOSED: - flag = True - if flag: - resp.append( - await Room.from_mpRoom( - MultiplayerRoom.model_validate_json(str(dumped_room)), - db, - fetcher, - ) - ) - return resp - else: - raise HTTPException(status_code=500, detail="Redis Error") + rooms = MultiplayerHubs.rooms.values() + return [await RoomResp.from_hub(room) for room in rooms] -@router.get("/rooms/{room}", tags=["room"], response_model=Room) -async def get_room( - room: int, - db: AsyncSession = Depends(get_db), - fetcher: Fetcher = Depends(get_fetcher), -): - redis = get_redis() - if redis: - dumped_room = str(redis.get(str(room))) - if dumped_room is not None: - resp = await Room.from_mpRoom( - MultiplayerRoom.model_validate_json(str(dumped_room)), db, fetcher - ) - return resp - else: - raise HTTPException(status_code=404, detail="Room Not Found") - else: - raise HTTPException(status_code=500, detail="Redis error") +# @router.get("/rooms/{room}", tags=["room"], response_model=Room) +# async def get_room( +# room: int, +# db: AsyncSession = Depends(get_db), +# fetcher: Fetcher = Depends(get_fetcher), +# ): +# redis = get_redis() +# if redis: +# dumped_room = str(redis.get(str(room))) +# if dumped_room is not None: +# resp = await Room.from_mpRoom( +# MultiplayerRoom.model_validate_json(str(dumped_room)), db, fetcher +# ) +# return resp +# else: +# raise HTTPException(status_code=404, detail="Room Not Found") +# else: +# raise HTTPException(status_code=500, detail="Redis error") -class APICreatedRoom(Room): - error: str | None +# class APICreatedRoom(Room): +# error: str | None -@router.post("/rooms", tags=["beatmap"], response_model=APICreatedRoom) -async def create_room( - room: Room, - db: AsyncSession = Depends(get_db), - fetcher: Fetcher = Depends(get_fetcher), -): - redis = get_redis() - if redis: - room_index = RoomIndex() - db.add(room_index) - await db.commit() - await db.refresh(room_index) - server_room = await MultiplayerRoom.from_apiRoom(room, db, fetcher) - redis.set(str(room_index.id), server_room.model_dump_json()) - room.room_id = room_index.id - return APICreatedRoom(**room.model_dump(), error=None) - else: - raise HTTPException(status_code=500, detail="redis error") +# @router.post("/rooms", tags=["beatmap"], response_model=APICreatedRoom) +# async def create_room( +# room: Room, +# db: AsyncSession = Depends(get_db), +# fetcher: Fetcher = Depends(get_fetcher), +# ): +# redis = get_redis() +# if redis: +# room_index = RoomIndex() +# db.add(room_index) +# await db.commit() +# await db.refresh(room_index) +# server_room = await MultiplayerRoom.from_apiRoom(room, db, fetcher) +# redis.set(str(room_index.id), server_room.model_dump_json()) +# room.room_id = room_index.id +# return APICreatedRoom(**room.model_dump(), error=None) +# else: +# raise HTTPException(status_code=500, detail="redis error") -@router.delete("/rooms/{room}", tags=["room"]) -async def remove_room(room: int, db: AsyncSession = Depends(get_db)): - redis = get_redis() - if redis: - redis.delete(str(room)) - room_index = await db.get(RoomIndex, room) - if room_index: - await db.delete(room_index) - await db.commit() +# @router.delete("/rooms/{room}", tags=["room"]) +# async def remove_room(room: int, db: AsyncSession = Depends(get_db)): +# redis = get_redis() +# if redis: +# redis.delete(str(room)) +# room_index = await db.get(RoomIndex, room) +# if room_index: +# await db.delete(room_index) +# await db.commit() diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 72b4a52..23ca69b 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -1,6 +1,103 @@ from __future__ import annotations -from .hub import Hub +from typing import override + +from app.database import Room +from app.database.playlists import Playlist +from app.dependencies.database import engine +from app.log import logger +from app.models.multiplayer_hub import ( + MultiplayerClientState, + MultiplayerRoom, + MultiplayerRoomUser, + ServerMultiplayerRoom, +) +from app.models.room import RoomCategory, RoomStatus +from app.models.signalr import serialize_to_list +from app.signalr.exception import InvokeException + +from .hub import Client, Hub + +from sqlmodel.ext.asyncio.session import AsyncSession -class MultiplayerHub(Hub): ... +class MultiplayerHub(Hub[MultiplayerClientState]): + @override + def __init__(self): + super().__init__() + self.rooms: dict[int, ServerMultiplayerRoom] = {} + + @staticmethod + def group_id(room: int) -> str: + return f"room:{room}" + + @override + def create_state(self, client: Client) -> MultiplayerClientState: + return MultiplayerClientState( + connection_id=client.connection_id, + connection_token=client.connection_token, + ) + + async def CreateRoom(self, client: Client, room: MultiplayerRoom): + logger.info(f"[MultiplayerHub] {client.user_id} creating room") + async with AsyncSession(engine) as session: + async with session: + db_room = Room( + name=room.settings.name, + category=RoomCategory.NORMAL, + type=room.settings.match_type, + queue_mode=room.settings.queue_mode, + auto_skip=room.settings.auto_skip, + auto_start_duration=room.settings.auto_start_duration, + host_id=client.user_id, + status=RoomStatus.IDLE, + ) + session.add(db_room) + await session.commit() + await session.refresh(db_room) + playitem = room.playlist[0] + playitem.owner_id = client.user_id + playitem.order = 1 + db_playlist = await Playlist.from_hub(playitem, db_room.id) + session.add(db_playlist) + room.room_id = db_room.id + starts_at = db_room.starts_at + await session.commit() + await session.refresh(db_playlist) + # room.playlist.append() + server_room = ServerMultiplayerRoom( + room=room, + category=RoomCategory.NORMAL, + status=RoomStatus.IDLE, + start_at=starts_at, + ) + self.rooms[room.room_id] = server_room + return await self.JoinRoomWithPassword( + client, room.room_id, room.settings.password + ) + + async def JoinRoomWithPassword(self, client: Client, room_id: int, password: str): + logger.info(f"[MultiplayerHub] {client.user_id} joining room {room_id}") + store = self.get_or_create_state(client) + if store.room_id != 0: + raise InvokeException("You are already in a room") + user = MultiplayerRoomUser(user_id=client.user_id) + if room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[room_id] + room = server_room.room + for u in room.users: + if u.user_id == client.user_id: + raise InvokeException("You are already in this room") + if room.settings.password != password: + raise InvokeException("Incorrect password") + if room.host is None: + # from CreateRoom + room.host = user + store.room_id = room_id + await self.broadcast_group_call( + self.group_id(room_id), "UserJoined", serialize_to_list(user) + ) + room.users.append(user) + self.add_to_group(client, self.group_id(room_id)) + return serialize_to_list(room) diff --git a/main.py b/main.py index 72444ef..b12f543 100644 --- a/main.py +++ b/main.py @@ -6,7 +6,12 @@ from datetime import datetime from app.config import settings from app.dependencies.database import create_tables, engine, redis_client from app.dependencies.fetcher import get_fetcher -from app.router import api_router, auth_router, fetcher_router, signalr_router +from app.router import ( + api_router, + auth_router, + fetcher_router, + signalr_router, +) from fastapi import FastAPI From 0b68bdc0c11fc75a6a76e97a4f874c98a784c639 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 01:55:30 +0000 Subject: [PATCH 009/104] fix(beatmap,beatmapset): fix lookup --- app/router/beatmap.py | 2 +- app/router/beatmapset.py | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/router/beatmap.py b/app/router/beatmap.py index 9574bdb..7dfd0f9 100644 --- a/app/router/beatmap.py +++ b/app/router/beatmap.py @@ -74,7 +74,7 @@ class BatchGetResp(BaseModel): @router.get("/beatmaps", tags=["beatmap"], response_model=BatchGetResp) @router.get("/beatmaps/", tags=["beatmap"], response_model=BatchGetResp) async def batch_get_beatmaps( - b_ids: list[int] = Query(alias="id", default_factory=list), + b_ids: list[int] = Query(alias="ids[]", default_factory=list), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): diff --git a/app/router/beatmapset.py b/app/router/beatmapset.py index b4d2e4c..f77c2ed 100644 --- a/app/router/beatmapset.py +++ b/app/router/beatmapset.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Literal -from app.database import Beatmapset, BeatmapsetResp, FavouriteBeatmapset, User +from app.database import Beatmap, Beatmapset, BeatmapsetResp, FavouriteBeatmapset, User from app.dependencies.database import get_db from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user @@ -17,6 +17,32 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +@router.get("/beatmapsets/lookup", tags=["beatmapset"], response_model=BeatmapsetResp) +async def lookup_beatmapset( + beatmap_id: int = Query(), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + fetcher: Fetcher = Depends(get_fetcher), +): + beatmapset_id = ( + await db.exec(select(Beatmap.beatmapset_id).where(Beatmap.id == beatmap_id)) + ).first() + if not beatmapset_id: + try: + resp = await fetcher.get_beatmap(beatmap_id) + await Beatmap.from_resp(db, resp) + await db.refresh(current_user) + except HTTPStatusError: + raise HTTPException(status_code=404, detail="Beatmapset not found") + beatmapset = ( + await db.exec(select(Beatmapset).where(Beatmapset.id == beatmapset_id)) + ).first() + if not beatmapset: + raise HTTPException(status_code=404, detail="Beatmapset not found") + resp = await BeatmapsetResp.from_db(beatmapset, session=db, user=current_user) + return resp + + @router.get("/beatmapsets/{sid}", tags=["beatmapset"], response_model=BeatmapsetResp) async def get_beatmapset( sid: int, From 884a3f1cc23e298e8396172a1ee63086e6393242 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 01:56:00 +0000 Subject: [PATCH 010/104] fix(leaderboard): missing filter condition for user score --- app/database/score.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/database/score.py b/app/database/score.py index 79cb005..1bd5978 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -328,6 +328,10 @@ async def get_leaderboard( self_query = ( select(BestScore) .where(BestScore.user_id == user.id) + .where( + col(BestScore.beatmap_id) == beatmap, + col(BestScore.gamemode) == mode, + ) .order_by(col(BestScore.total_score).desc()) .limit(1) ) From 86e2313c50d2e5c537eb2ddf29df83ad903c23a1 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 01:56:54 +0000 Subject: [PATCH 011/104] feat(multiplayer): support add/edit/remove playlist item --- app/database/playlists.py | 62 +++++++- app/{signalr => }/exception.py | 0 app/models/multiplayer_hub.py | 256 ++++++++++++++++++++++++++++++++- app/signalr/hub/hub.py | 2 +- app/signalr/hub/multiplayer.py | 136 ++++++++++++++++-- 5 files changed, 441 insertions(+), 15 deletions(-) rename app/{signalr => }/exception.py (100%) diff --git a/app/database/playlists.py b/app/database/playlists.py index 42567b6..10ad86b 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -16,7 +16,10 @@ from sqlmodel import ( ForeignKey, Relationship, SQLModel, + func, + select, ) +from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: from .room import Room @@ -59,9 +62,20 @@ class Playlist(PlaylistBase, table=True): room: "Room" = Relationship() @classmethod - async def from_hub(cls, playlist: PlaylistItem, room_id: int) -> "Playlist": + async def get_next_id_for_room(cls, room_id: int, session: AsyncSession) -> int: + stmt = select(func.coalesce(func.max(cls.id), -1) + 1).where( + cls.room_id == room_id + ) + result = await session.exec(stmt) + return result.one() + + @classmethod + async def from_hub( + cls, playlist: PlaylistItem, room_id: int, session: AsyncSession + ) -> "Playlist": + next_id = await cls.get_next_id_for_room(room_id, session=session) return cls( - id=playlist.id, + id=next_id, owner_id=playlist.owner_id, ruleset_id=playlist.ruleset_id, beatmap_id=playlist.beatmap_id, @@ -74,6 +88,50 @@ class Playlist(PlaylistBase, table=True): room_id=room_id, ) + @classmethod + async def update(cls, playlist: PlaylistItem, room_id: int, session: AsyncSession): + db_playlist = await session.exec( + select(cls).where(cls.id == playlist.id, cls.room_id == room_id) + ) + db_playlist = db_playlist.first() + if db_playlist is None: + raise ValueError("Playlist item not found") + db_playlist.owner_id = playlist.owner_id + db_playlist.ruleset_id = playlist.ruleset_id + db_playlist.beatmap_id = playlist.beatmap_id + db_playlist.required_mods = [ + msgpack_to_apimod(mod) for mod in playlist.required_mods + ] + db_playlist.allowed_mods = [ + msgpack_to_apimod(mod) for mod in playlist.allowed_mods + ] + db_playlist.expired = playlist.expired + db_playlist.playlist_order = playlist.order + db_playlist.played_at = playlist.played_at + db_playlist.freestyle = playlist.freestyle + await session.commit() + + @classmethod + async def add_to_db( + cls, playlist: PlaylistItem, room_id: int, session: AsyncSession + ): + db_playlist = await cls.from_hub(playlist, room_id, session) + session.add(db_playlist) + await session.commit() + await session.refresh(db_playlist) + playlist.id = db_playlist.id + + @classmethod + async def delete_item(cls, item_id: int, room_id: int, session: AsyncSession): + db_playlist = await session.exec( + select(cls).where(cls.id == item_id, cls.room_id == room_id) + ) + db_playlist = db_playlist.first() + if db_playlist is None: + raise ValueError("Playlist item not found") + await session.delete(db_playlist) + await session.commit() + class PlaylistResp(PlaylistBase): beatmap: BeatmapResp | None = None diff --git a/app/signalr/exception.py b/app/exception.py similarity index 100% rename from app/signalr/exception.py rename to app/exception.py diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index fa5e935..39ced12 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -1,7 +1,12 @@ from __future__ import annotations +from dataclasses import dataclass import datetime -from typing import Annotated, Any, Literal +from typing import TYPE_CHECKING, Annotated, Any, Literal + +from app.database.beatmap import Beatmap +from app.dependencies.database import engine +from app.exception import InvokeException from .room import ( DownloadState, @@ -21,7 +26,14 @@ from .signalr import ( ) from msgpack_lazer_api import APIMod -from pydantic import BaseModel, Field, field_serializer, field_validator +from pydantic import Field, field_serializer, field_validator +from sqlmodel.ext.asyncio.session import AsyncSession + +if TYPE_CHECKING: + from app.signalr.hub import MultiplayerHub + +HOST_LIMIT = 50 +PER_USER_LIMIT = 3 class MultiplayerClientState(UserState): @@ -161,8 +173,246 @@ class MultiplayerRoom(MessagePackArrayModel): return msgpack_union_dump(v) -class ServerMultiplayerRoom(BaseModel): +class MultiplayerQueue: + def __init__(self, room: "ServerMultiplayerRoom", hub: "MultiplayerHub"): + self.server_room = room + self.hub = hub + self.current_index = 0 + + @property + def upcoming_items(self): + return sorted( + (item for item in self.room.playlist if not item.expired), + key=lambda i: i.order, + ) + + @property + def room(self): + return self.server_room.room + + async def update_order(self): + from app.database import Playlist + + match self.room.settings.queue_mode: + case QueueMode.ALL_PLAYERS_ROUND_ROBIN: + ordered_active_items = [] + + is_first_set = True + first_set_order_by_user_id = {} + + active_items = [item for item in self.room.playlist if not item.expired] + active_items.sort(key=lambda x: x.id) + + user_item_groups = {} + for item in active_items: + if item.owner_id not in user_item_groups: + user_item_groups[item.owner_id] = [] + user_item_groups[item.owner_id].append(item) + + max_items = max( + (len(items) for items in user_item_groups.values()), default=0 + ) + + for i in range(max_items): + current_set = [] + for user_id, items in user_item_groups.items(): + if i < len(items): + current_set.append(items[i]) + + if is_first_set: + current_set.sort(key=lambda item: (item.order, item.id)) + ordered_active_items.extend(current_set) + first_set_order_by_user_id = { + item.owner_id: idx + for idx, item in enumerate(ordered_active_items) + } + else: + current_set.sort( + key=lambda item: first_set_order_by_user_id.get( + item.owner_id, 0 + ) + ) + ordered_active_items.extend(current_set) + + is_first_set = False + + for idx, item in enumerate(ordered_active_items): + item.order = idx + case _: + ordered_active_items = sorted( + (item for item in self.room.playlist if not item.expired), + key=lambda x: x.id, + ) + async with AsyncSession(engine) as session: + for idx, item in enumerate(ordered_active_items): + if item.order == idx: + continue + item.order = idx + await Playlist.update(item, self.room.room_id, session) + await self.hub.playlist_changed( + self.server_room, item, beatmap_changed=False + ) + + async def update_current_item(self): + upcoming_items = self.upcoming_items + next_item = ( + upcoming_items[0] + if upcoming_items + else max( + self.room.playlist, + key=lambda i: i.played_at or datetime.datetime.min, + ) + ) + self.current_index = self.room.playlist.index(next_item) + last_id = self.room.settings.playlist_item_id + self.room.settings.playlist_item_id = next_item.id + if last_id != next_item.id: + await self.hub.setting_changed(self.server_room, True) + + async def add_item(self, item: PlaylistItem, user: MultiplayerRoomUser): + from app.database import Playlist + + is_host = self.room.host and self.room.host.user_id == user.user_id + if self.room.settings.queue_mode == QueueMode.HOST_ONLY and not is_host: + raise InvokeException("You are not the host") + + limit = HOST_LIMIT if is_host else PER_USER_LIMIT + if ( + len( + list( + filter( + lambda x: x.owner_id == user.user_id, + self.room.playlist, + ) + ) + ) + >= limit + ): + raise InvokeException(f"You can only have {limit} items in the queue") + + if item.freestyle and len(item.allowed_mods) > 0: + raise InvokeException("Freestyle items cannot have allowed mods") + + async with AsyncSession(engine) as session: + async with session: + beatmap = await session.get(Beatmap, item.beatmap_id) + if beatmap is None: + raise InvokeException("Beatmap not found") + if item.checksum != beatmap.checksum: + raise InvokeException("Checksum mismatch") + # TODO: mods validation + item.owner_id = user.user_id + item.star = float( + beatmap.difficulty_rating + ) # FIXME: beatmap use decimal + await Playlist.add_to_db(item, self.room.room_id, session) + self.room.playlist.append(item) + await self.hub.playlist_added(self.server_room, item) + await self.update_order() + await self.update_current_item() + + async def edit_item(self, item: PlaylistItem, user: MultiplayerRoomUser): + from app.database import Playlist + + if item.freestyle and len(item.allowed_mods) > 0: + raise InvokeException("Freestyle items cannot have allowed mods") + + async with AsyncSession(engine) as session: + async with session: + beatmap = await session.get(Beatmap, item.beatmap_id) + if beatmap is None: + raise InvokeException("Beatmap not found") + if item.checksum != beatmap.checksum: + raise InvokeException("Checksum mismatch") + + existing_item = next( + (i for i in self.room.playlist if i.id == item.id), None + ) + if existing_item is None: + raise InvokeException( + "Attempted to change an item that doesn't exist" + ) + + if existing_item.owner_id != user.user_id and self.room.host != user: + raise InvokeException( + "Attempted to change an item which is not owned by the user" + ) + + if existing_item.expired: + raise InvokeException( + "Attempted to change an item which has already been played" + ) + + # TODO: mods validation + item.owner_id = user.user_id + item.star = float(beatmap.difficulty_rating) + item.order = existing_item.order + + await Playlist.update(item, self.room.room_id, session) + + # Update item in playlist + for idx, playlist_item in enumerate(self.room.playlist): + if playlist_item.id == item.id: + self.room.playlist[idx] = item + break + + await self.hub.playlist_changed( + self.server_room, + item, + beatmap_changed=item.checksum != existing_item.checksum, + ) + + async def remove_item(self, playlist_item_id: int, user: MultiplayerRoomUser): + from app.database import Playlist + + item = next( + (i for i in self.room.playlist if i.id == playlist_item_id), + None, + ) + + if item is None: + raise InvokeException("Item does not exist in the room") + + # Check if it's the only item and current item + if item == self.current_item: + upcoming_items = [i for i in self.room.playlist if not i.expired] + if len(upcoming_items) == 1: + raise InvokeException("The only item in the room cannot be removed") + + if item.owner_id != user.user_id and self.room.host != user: + raise InvokeException( + "Attempted to remove an item which is not owned by the user" + ) + + if item.expired: + raise InvokeException( + "Attempted to remove an item which has already been played" + ) + + async with AsyncSession(engine) as session: + await Playlist.delete_item(item.id, self.room.room_id, session) + + self.room.playlist.remove(item) + self.current_index = self.room.playlist.index(self.upcoming_items[0]) + + await self.update_order() + await self.update_current_item() + await self.hub.playlist_removed(self.server_room, item.id) + + @property + def current_item(self): + """Get the current playlist item""" + current_id = self.room.settings.playlist_item_id + return next( + (item for item in self.room.playlist if item.id == current_id), + None, + ) + + +@dataclass +class ServerMultiplayerRoom: room: MultiplayerRoom category: RoomCategory status: RoomStatus start_at: datetime.datetime + queue: MultiplayerQueue | None = None diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index 276140f..4e2c9d6 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -6,9 +6,9 @@ import time from typing import Any from app.config import settings +from app.exception import InvokeException from app.log import logger from app.models.signalr import UserState -from app.signalr.exception import InvokeException from app.signalr.packet import ( ClosePacket, CompletionPacket, diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 23ca69b..477396b 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -5,16 +5,19 @@ from typing import override from app.database import Room from app.database.playlists import Playlist from app.dependencies.database import engine +from app.exception import InvokeException from app.log import logger from app.models.multiplayer_hub import ( + BeatmapAvailability, MultiplayerClientState, + MultiplayerQueue, MultiplayerRoom, MultiplayerRoomUser, + PlaylistItem, ServerMultiplayerRoom, ) from app.models.room import RoomCategory, RoomStatus from app.models.signalr import serialize_to_list -from app.signalr.exception import InvokeException from .hub import Client, Hub @@ -40,6 +43,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): async def CreateRoom(self, client: Client, room: MultiplayerRoom): logger.info(f"[MultiplayerHub] {client.user_id} creating room") + store = self.get_or_create_state(client) + if store.room_id != 0: + raise InvokeException("You are already in a room") async with AsyncSession(engine) as session: async with session: db_room = Room( @@ -55,22 +61,22 @@ class MultiplayerHub(Hub[MultiplayerClientState]): session.add(db_room) await session.commit() await session.refresh(db_room) - playitem = room.playlist[0] - playitem.owner_id = client.user_id - playitem.order = 1 - db_playlist = await Playlist.from_hub(playitem, db_room.id) - session.add(db_playlist) + item = room.playlist[0] + item.owner_id = client.user_id room.room_id = db_room.id starts_at = db_room.starts_at - await session.commit() - await session.refresh(db_playlist) - # room.playlist.append() + await Playlist.add_to_db(item, db_room.id, session) server_room = ServerMultiplayerRoom( room=room, category=RoomCategory.NORMAL, status=RoomStatus.IDLE, start_at=starts_at, ) + queue = MultiplayerQueue( + room=server_room, + hub=self, + ) + server_room.queue = queue self.rooms[room.room_id] = server_room return await self.JoinRoomWithPassword( client, room.room_id, room.settings.password @@ -101,3 +107,115 @@ class MultiplayerHub(Hub[MultiplayerClientState]): room.users.append(user) self.add_to_group(client, self.group_id(room_id)) return serialize_to_list(room) + + async def ChangeBeatmapAvailability( + self, client: Client, beatmap_availability: BeatmapAvailability + ): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + availability = user.availability + if ( + availability.state == beatmap_availability.state + and availability.progress == beatmap_availability.progress + ): + return + user.availability = availability + await self.broadcast_group_call( + self.group_id(store.room_id), + "UserBeatmapAvailabilityChanged", + user.user_id, + serialize_to_list(beatmap_availability), + ) + + async def AddPlaylistItem(self, client: Client, item: PlaylistItem): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await server_room.queue.add_item( + item, + user, + ) + + async def EditPlaylistItem(self, client: Client, item: PlaylistItem): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await server_room.queue.edit_item( + item, + user, + ) + + async def RemovePlaylistItem(self, client: Client, item_id: int): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await server_room.queue.remove_item( + item_id, + user, + ) + + async def setting_changed(self, room: ServerMultiplayerRoom, beatmap_changed: bool): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "SettingsChanged", + serialize_to_list(room.room.settings), + ) + + async def playlist_added(self, room: ServerMultiplayerRoom, item: PlaylistItem): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "PlaylistItemAdded", + serialize_to_list(item), + ) + + async def playlist_removed(self, room: ServerMultiplayerRoom, item_id: int): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "PlaylistItemRemoved", + item_id, + ) + + async def playlist_changed( + self, room: ServerMultiplayerRoom, item: PlaylistItem, beatmap_changed: bool + ): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "PlaylistItemChanged", + serialize_to_list(item), + ) From 693c18ba6e2f561b3fdcff9231e6537884dff7a7 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 04:24:13 +0000 Subject: [PATCH 012/104] feat(multiplayer): support change mods/playstyles(freestyle) --- app/models/multiplayer_hub.py | 79 ++++++++++++++- app/signalr/hub/multiplayer.py | 176 +++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 1 deletion(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 39ced12..9bccb71 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -103,6 +103,84 @@ class PlaylistItem(MessagePackArrayModel): star: float freestyle: bool + def validate_user_mods( + self, + user: "MultiplayerRoomUser", + proposed_mods: list[APIMod], + ) -> tuple[bool, list[APIMod]]: + """ + Validates user mods against playlist item rules and returns valid mods. + Returns (is_valid, valid_mods). + """ + from typing import Literal, cast + + from app.models.mods import API_MODS, init_mods + + if not API_MODS: + init_mods() + + ruleset_id = user.ruleset_id if user.ruleset_id is not None else self.ruleset_id + ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_id) + + valid_mods = [] + all_proposed_valid = True + + # Check if mods are valid for the ruleset + for mod in proposed_mods: + if ruleset_key not in API_MODS or mod.acronym not in API_MODS[ruleset_key]: + all_proposed_valid = False + continue + valid_mods.append(mod) + + # Check mod compatibility within user mods + incompatible_mods = set() + final_valid_mods = [] + for mod in valid_mods: + if mod.acronym in incompatible_mods: + all_proposed_valid = False + continue + setting_mods = API_MODS[ruleset_key].get(mod.acronym) + if setting_mods: + incompatible_mods.update(setting_mods["IncompatibleMods"]) + final_valid_mods.append(mod) + + # If not freestyle, check against allowed mods + if not self.freestyle: + allowed_acronyms = {mod.acronym for mod in self.allowed_mods} + filtered_valid_mods = [] + for mod in final_valid_mods: + if mod.acronym not in allowed_acronyms: + all_proposed_valid = False + else: + filtered_valid_mods.append(mod) + final_valid_mods = filtered_valid_mods + + # Check compatibility with required mods + required_mod_acronyms = {mod.acronym for mod in self.required_mods} + all_mod_acronyms = { + mod.acronym for mod in final_valid_mods + } | required_mod_acronyms + + # Check for incompatibility between required and user mods + filtered_valid_mods = [] + for mod in final_valid_mods: + mod_acronym = mod.acronym + is_compatible = True + + for other_acronym in all_mod_acronyms: + if other_acronym == mod_acronym: + continue + setting_mods = API_MODS[ruleset_key].get(mod_acronym) + if setting_mods and other_acronym in setting_mods["IncompatibleMods"]: + is_compatible = False + all_proposed_valid = False + break + + if is_compatible: + filtered_valid_mods.append(mod) + + return all_proposed_valid, filtered_valid_mods + class _MultiplayerCountdown(MessagePackArrayModel): id: int @@ -405,7 +483,6 @@ class MultiplayerQueue: current_id = self.room.settings.playlist_item_id return next( (item for item in self.room.playlist if item.id == current_id), - None, ) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 477396b..bd34be0 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import override from app.database import Room +from app.database.beatmap import Beatmap from app.database.playlists import Playlist from app.dependencies.database import engine from app.exception import InvokeException @@ -17,10 +18,13 @@ from app.models.multiplayer_hub import ( ServerMultiplayerRoom, ) from app.models.room import RoomCategory, RoomStatus +from app.models.score import GameMode from app.models.signalr import serialize_to_list from .hub import Client, Hub +from msgpack_lazer_api import APIMod +from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -219,3 +223,175 @@ class MultiplayerHub(Hub[MultiplayerClientState]): "PlaylistItemChanged", serialize_to_list(item), ) + + async def ChangeUserStyle( + self, client: Client, beatmap_id: int | None, ruleset_id: int | None + ): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await self.change_user_style( + beatmap_id, + ruleset_id, + server_room, + user, + ) + + async def validate_styles(self, room: ServerMultiplayerRoom): + assert room.queue + if not room.queue.current_item.freestyle: + for user in room.room.users: + await self.change_user_style( + None, + None, + room, + user, + ) + async with AsyncSession(engine) as session: + beatmap = await session.get(Beatmap, room.queue.current_item.beatmap_id) + if beatmap is None: + raise InvokeException("Beatmap not found") + beatmap_ids = ( + await session.exec( + select(Beatmap.id, Beatmap.mode).where( + Beatmap.beatmapset_id == beatmap.beatmapset_id, + ) + ) + ).all() + for user in room.room.users: + beatmap_id = user.beatmap_id + ruleset_id = user.ruleset_id + user_beatmap = next( + (b for b in beatmap_ids if b[0] == beatmap_id), + None, + ) + if beatmap_id is not None and user_beatmap is None: + beatmap_id = None + beatmap_ruleset = user_beatmap[1] if user_beatmap else beatmap.mode + if ( + ruleset_id is not None + and beatmap_ruleset != GameMode.OSU + and ruleset_id != beatmap_ruleset + ): + ruleset_id = None + await self.change_user_style( + beatmap_id, + ruleset_id, + room, + user, + ) + + for user in room.room.users: + is_valid, valid_mods = room.queue.current_item.validate_user_mods( + user, user.mods + ) + if not is_valid: + await self.change_user_mods(valid_mods, room, user) + + async def change_user_style( + self, + beatmap_id: int | None, + ruleset_id: int | None, + room: ServerMultiplayerRoom, + user: MultiplayerRoomUser, + ): + if user.beatmap_id == beatmap_id and user.ruleset_id == ruleset_id: + return + + if beatmap_id is not None or ruleset_id is not None: + assert room.queue + if not room.queue.current_item.freestyle: + raise InvokeException("Current item does not allow free user styles.") + + async with AsyncSession(engine) as session: + item_beatmap = await session.get( + Beatmap, room.queue.current_item.beatmap_id + ) + if item_beatmap is None: + raise InvokeException("Item beatmap not found") + + user_beatmap = ( + item_beatmap + if beatmap_id is None + else await session.get(Beatmap, beatmap_id) + ) + + if user_beatmap is None: + raise InvokeException("Invalid beatmap selected.") + + if user_beatmap.beatmapset_id != item_beatmap.beatmapset_id: + raise InvokeException( + "Selected beatmap is not from the same beatmap set." + ) + + if ( + ruleset_id is not None + and user_beatmap.mode != GameMode.OSU + and ruleset_id != user_beatmap.mode + ): + raise InvokeException( + "Selected ruleset is not supported for the given beatmap." + ) + + user.beatmap_id = beatmap_id + user.ruleset_id = ruleset_id + + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "UserStyleChanged", + user.user_id, + beatmap_id, + ruleset_id, + ) + + async def ChangeUserMods(self, client: Client, new_mods: list[APIMod]): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await self.change_user_mods(new_mods, server_room, user) + + async def change_user_mods( + self, + new_mods: list[APIMod], + room: ServerMultiplayerRoom, + user: MultiplayerRoomUser, + ): + assert room.queue + is_valid, valid_mods = room.queue.current_item.validate_user_mods( + user, new_mods + ) + if not is_valid: + incompatible_mods = [ + mod.acronym for mod in new_mods if mod not in valid_mods + ] + raise InvokeException( + f"Incompatible mods were selected: {','.join(incompatible_mods)}" + ) + + if user.mods == valid_mods: + return + + user.mods = valid_mods + + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "UserModsChanged", + user.user_id, + valid_mods, + ) From c83f950d132c6c1860b0f382f6de53578972d737 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 14:59:12 +0000 Subject: [PATCH 013/104] fix(signalr): encode enum by index --- app/models/signalr.py | 34 +++++++++++++++++----------------- app/signalr/hub/hub.py | 8 +++++++- app/signalr/packet.py | 4 +++- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/app/models/signalr.py b/app/models/signalr.py index 202da4f..9e189e9 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -15,26 +15,26 @@ from pydantic import ( ) +def serialize_msgpack(v: Any) -> Any: + typ = v.__class__ + if issubclass(typ, BaseModel): + return serialize_to_list(v) + elif issubclass(typ, list): + return TypeAdapter( + typ, config=ConfigDict(arbitrary_types_allowed=True) + ).dump_python(v) + elif issubclass(typ, datetime.datetime): + return [v, 0] + elif issubclass(typ, Enum): + list_ = list(typ) + return list_.index(v) if v in list_ else v.value + return v + + def serialize_to_list(value: BaseModel) -> list[Any]: data = [] for field, info in value.__class__.model_fields.items(): - v = getattr(value, field) - typ = v.__class__ - if issubclass(typ, BaseModel): - data.append(serialize_to_list(v)) - elif issubclass(typ, list): - data.append( - TypeAdapter( - info.annotation, config=ConfigDict(arbitrary_types_allowed=True) - ).dump_python(v) - ) - elif issubclass(typ, datetime.datetime): - data.append([v, 0]) - elif issubclass(typ, Enum): - list_ = list(typ) - data.append(list_.index(v) if v in list_ else v.value) - else: - data.append(v) + data.append(serialize_msgpack(v=getattr(value, field))) return data diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index 4e2c9d6..85292df 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -2,13 +2,15 @@ from __future__ import annotations from abc import abstractmethod import asyncio +from enum import Enum +import inspect import time from typing import Any from app.config import settings from app.exception import InvokeException from app.log import logger -from app.models.signalr import UserState +from app.models.signalr import UserState, _by_index from app.signalr.packet import ( ClosePacket, CompletionPacket, @@ -265,6 +267,10 @@ class Hub[TState: UserState]: continue if issubclass(param.annotation, BaseModel): call_params.append(param.annotation.model_validate(args.pop(0))) + elif inspect.isclass(param.annotation) and issubclass( + param.annotation, Enum + ): + call_params.append(_by_index(args.pop(0), param.annotation)) else: call_params.append(args.pop(0)) return await method_(client, *call_params) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index 387231c..de5ce8a 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -8,6 +8,8 @@ from typing import ( Protocol as TypingProtocol, ) +from app.models.signalr import serialize_msgpack + import msgpack_lazer_api as m SEP = b"\x1e" @@ -151,7 +153,7 @@ class MsgpackProtocol: ] ) if packet.arguments is not None: - payload.append(packet.arguments) + payload.append([serialize_msgpack(arg) for arg in packet.arguments]) if packet.stream_ids is not None: payload.append(packet.stream_ids) elif isinstance(packet, CompletionPacket): From 41631b839f35befc1962793d5d1c828174aaa5b5 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 2 Aug 2025 15:02:12 +0000 Subject: [PATCH 014/104] fix(user): last_visit is nullable --- app/database/lazer_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/database/lazer_user.py b/app/database/lazer_user.py index 3bd751b..2717c3a 100644 --- a/app/database/lazer_user.py +++ b/app/database/lazer_user.py @@ -66,7 +66,7 @@ class UserBase(UTCBaseModel, SQLModel): is_active: bool = True is_bot: bool = False is_supporter: bool = False - last_visit: datetime = Field( + last_visit: datetime | None = Field( default=datetime.now(UTC), sa_column=Column(DateTime(timezone=True)) ) pm_friends_only: bool = False From b7bc87b8b63543e75db317f9ac52b73fe8cbae9c Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 3 Aug 2025 11:01:25 +0000 Subject: [PATCH 015/104] fix(signalr): fix SignalRMeta cannot be read --- app/models/metadata_hub.py | 14 +++++++------- app/models/spectator_hub.py | 8 ++++---- app/signalr/packet.py | 14 +++++++++----- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index 3206d03..a678d7f 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import IntEnum -from typing import ClassVar, Literal +from typing import Annotated, ClassVar, Literal from app.models.signalr import SignalRMeta, SignalRUnionMessage, UserState @@ -100,12 +100,12 @@ UserActivity = ( class UserPresence(BaseModel): - activity: UserActivity | None = Field( - default=None, metadata=SignalRMeta(use_upper_case=True) - ) - status: OnlineStatus | None = Field( - default=None, metadata=SignalRMeta(use_upper_case=True) - ) + activity: Annotated[ + UserActivity | None, Field(default=None), SignalRMeta(use_upper_case=True) + ] + status: Annotated[ + OnlineStatus | None, Field(default=None), SignalRMeta(use_upper_case=True) + ] @property def pushable(self) -> bool: diff --git a/app/models/spectator_hub.py b/app/models/spectator_hub.py index a9e9042..9f35932 100644 --- a/app/models/spectator_hub.py +++ b/app/models/spectator_hub.py @@ -2,7 +2,7 @@ from __future__ import annotations import datetime from enum import IntEnum -from typing import Any +from typing import Annotated, Any from app.models.beatmap import BeatmapRankStatus from app.models.mods import APIMod @@ -89,9 +89,9 @@ class LegacyReplayFrame(BaseModel): mouse_y: float | None = None button_state: int - header: FrameHeader | None = Field( - default=None, metadata=[SignalRMeta(member_ignore=True)] - ) + header: Annotated[ + FrameHeader | None, Field(default=None), SignalRMeta(member_ignore=True) + ] class FrameDataBundle(BaseModel): diff --git a/app/signalr/packet.py b/app/signalr/packet.py index be98c39..70c2276 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -126,15 +126,19 @@ class MsgpackProtocol: def process_object(v: Any, typ: type[BaseModel]) -> Any: if isinstance(v, list): d = {} - for i, f in enumerate(typ.model_fields.items()): - field, info = f - if info.exclude: + i = 0 + for field, info in typ.model_fields.items(): + metadata = next( + (m for m in info.metadata if isinstance(m, SignalRMeta)), None + ) + if metadata and metadata.member_ignore: continue anno = info.annotation if anno is None: d[camel_to_snake(field)] = v[i] - continue - d[field] = MsgpackProtocol.validate_object(v[i], anno) + else: + d[field] = MsgpackProtocol.validate_object(v[i], anno) + i += 1 return d return v From 2600fa499f05d266072fb447cd708af4587fc1c9 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 3 Aug 2025 12:53:22 +0000 Subject: [PATCH 016/104] feat(multiplayer): support play WIP --- app/database/playlists.py | 18 +-- app/database/room.py | 2 +- app/database/score.py | 7 +- app/models/mods.py | 12 -- app/models/multiplayer_hub.py | 282 ++++++++++++++++++++++----------- app/models/room.py | 9 ++ app/models/signalr.py | 1 + app/router/score.py | 219 ++++++++++++++++++------- app/signalr/hub/multiplayer.py | 262 ++++++++++++++++++++++++++++-- app/signalr/packet.py | 46 +++++- app/utils.py | 4 +- 11 files changed, 666 insertions(+), 196 deletions(-) diff --git a/app/database/playlists.py b/app/database/playlists.py index 10ad86b..328f17d 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import TYPE_CHECKING from app.models.model import UTCBaseModel -from app.models.mods import APIMod, msgpack_to_apimod +from app.models.mods import APIMod from app.models.multiplayer_hub import PlaylistItem from .beatmap import Beatmap, BeatmapResp @@ -79,10 +79,10 @@ class Playlist(PlaylistBase, table=True): owner_id=playlist.owner_id, ruleset_id=playlist.ruleset_id, beatmap_id=playlist.beatmap_id, - required_mods=[msgpack_to_apimod(mod) for mod in playlist.required_mods], - allowed_mods=[msgpack_to_apimod(mod) for mod in playlist.allowed_mods], + required_mods=playlist.required_mods, + allowed_mods=playlist.allowed_mods, expired=playlist.expired, - playlist_order=playlist.order, + playlist_order=playlist.playlist_order, played_at=playlist.played_at, freestyle=playlist.freestyle, room_id=room_id, @@ -99,14 +99,10 @@ class Playlist(PlaylistBase, table=True): db_playlist.owner_id = playlist.owner_id db_playlist.ruleset_id = playlist.ruleset_id db_playlist.beatmap_id = playlist.beatmap_id - db_playlist.required_mods = [ - msgpack_to_apimod(mod) for mod in playlist.required_mods - ] - db_playlist.allowed_mods = [ - msgpack_to_apimod(mod) for mod in playlist.allowed_mods - ] + db_playlist.required_mods = playlist.required_mods + db_playlist.allowed_mods = playlist.allowed_mods db_playlist.expired = playlist.expired - db_playlist.playlist_order = playlist.order + db_playlist.playlist_order = playlist.playlist_order db_playlist.played_at = playlist.played_at db_playlist.freestyle = playlist.freestyle await session.commit() diff --git a/app/database/room.py b/app/database/room.py index 8eb882d..80457b6 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -125,7 +125,7 @@ class RoomResp(RoomBase): type=room.settings.match_type, queue_mode=room.settings.queue_mode, auto_skip=room.settings.auto_skip, - auto_start_duration=room.settings.auto_start_duration, + auto_start_duration=int(room.settings.auto_start_duration.total_seconds()), status=server_room.status, category=server_room.category, # duration = room.settings.duration, diff --git a/app/database/score.py b/app/database/score.py index 1bd5978..abc3d75 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -91,7 +91,7 @@ class ScoreBase(AsyncAttrs, SQLModel, UTCBaseModel): # optional # TODO: current_user_attributes - position: int | None = Field(default=None) # multiplayer + # position: int | None = Field(default=None) # multiplayer class Score(ScoreBase, table=True): @@ -162,6 +162,7 @@ class ScoreResp(ScoreBase): maximum_statistics: ScoreStatistics | None = None rank_global: int | None = None rank_country: int | None = None + position: int = 1 # TODO @classmethod async def from_db(cls, session: AsyncSession, score: Score) -> "ScoreResp": @@ -618,6 +619,8 @@ async def process_score( fetcher: "Fetcher", session: AsyncSession, redis: Redis, + item_id: int | None = None, + room_id: int | None = None, ) -> Score: assert user.id can_get_pp = info.passed and ranked and mods_can_get_pp(info.ruleset_id, info.mods) @@ -649,6 +652,8 @@ async def process_score( nsmall_tick_hit=info.statistics.get(HitResult.SMALL_TICK_HIT, 0), nlarge_tick_hit=info.statistics.get(HitResult.LARGE_TICK_HIT, 0), nslider_tail_hit=info.statistics.get(HitResult.SLIDER_TAIL_HIT, 0), + playlist_item_id=item_id, + room_id=room_id, ) if can_get_pp: beatmap_raw = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id) diff --git a/app/models/mods.py b/app/models/mods.py index 4b20138..299a05f 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -5,8 +5,6 @@ from typing import Literal, NotRequired, TypedDict from app.path import STATIC_DIR -from msgpack_lazer_api import APIMod as MsgpackAPIMod - class APIMod(TypedDict): acronym: str @@ -169,13 +167,3 @@ def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool: if expected_value != NO_CHECK and value != expected_value: return False return True - - -def msgpack_to_apimod(mod: MsgpackAPIMod) -> APIMod: - """ - Convert a MsgpackAPIMod to an APIMod. - """ - return APIMod( - acronym=mod.acronym, - settings=mod.settings, - ) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 9bccb71..ba8a050 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -1,13 +1,16 @@ from __future__ import annotations -from dataclasses import dataclass -import datetime -from typing import TYPE_CHECKING, Annotated, Any, Literal +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal from app.database.beatmap import Beatmap from app.dependencies.database import engine from app.exception import InvokeException +from .mods import APIMod from .room import ( DownloadState, MatchType, @@ -18,15 +21,14 @@ from .room import ( RoomStatus, ) from .signalr import ( - EnumByIndex, - MessagePackArrayModel, + SignalRMeta, + SignalRUnionMessage, UserState, - msgpack_union, - msgpack_union_dump, ) -from msgpack_lazer_api import APIMod -from pydantic import Field, field_serializer, field_validator +from pydantic import BaseModel, Field +from sqlalchemy import update +from sqlmodel import col from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: @@ -40,37 +42,37 @@ class MultiplayerClientState(UserState): room_id: int = 0 -class MultiplayerRoomSettings(MessagePackArrayModel): +class MultiplayerRoomSettings(BaseModel): name: str = "Unnamed Room" - playlist_item_id: int = 0 + playlist_item_id: Annotated[int, Field(default=0), SignalRMeta(use_abbr=False)] password: str = "" - match_type: Annotated[MatchType, EnumByIndex(MatchType)] = MatchType.HEAD_TO_HEAD - queue_mode: Annotated[QueueMode, EnumByIndex(QueueMode)] = QueueMode.HOST_ONLY - auto_start_duration: int = 0 + match_type: MatchType = MatchType.HEAD_TO_HEAD + queue_mode: QueueMode = QueueMode.HOST_ONLY + auto_start_duration: timedelta = timedelta(seconds=0) auto_skip: bool = False -class BeatmapAvailability(MessagePackArrayModel): - state: Annotated[DownloadState, EnumByIndex(DownloadState)] = DownloadState.UNKNOWN +class BeatmapAvailability(BaseModel): + state: DownloadState = DownloadState.UNKNOWN progress: float | None = None -class _MatchUserState(MessagePackArrayModel): ... +class _MatchUserState(SignalRUnionMessage): ... class TeamVersusUserState(_MatchUserState): team_id: int - type: Literal[0] = Field(0, exclude=True) + union_type: ClassVar[Literal[0]] = 0 MatchUserState = TeamVersusUserState -class _MatchRoomState(MessagePackArrayModel): ... +class _MatchRoomState(SignalRUnionMessage): ... -class MultiplayerTeam(MessagePackArrayModel): +class MultiplayerTeam(BaseModel): id: int name: str @@ -83,24 +85,24 @@ class TeamVersusRoomState(_MatchRoomState): ] ) - type: Literal[0] = Field(0, exclude=True) + union_type: ClassVar[Literal[0]] = 0 MatchRoomState = TeamVersusRoomState -class PlaylistItem(MessagePackArrayModel): - id: int +class PlaylistItem(BaseModel): + id: Annotated[int, Field(default=0), SignalRMeta(use_abbr=False)] owner_id: int beatmap_id: int - checksum: str + beatmap_checksum: str ruleset_id: int required_mods: list[APIMod] = Field(default_factory=list) allowed_mods: list[APIMod] = Field(default_factory=list) expired: bool - order: int - played_at: datetime.datetime | None = None - star: float + playlist_order: int + played_at: datetime | None = None + star_rating: float freestyle: bool def validate_user_mods( @@ -127,7 +129,10 @@ class PlaylistItem(MessagePackArrayModel): # Check if mods are valid for the ruleset for mod in proposed_mods: - if ruleset_key not in API_MODS or mod.acronym not in API_MODS[ruleset_key]: + if ( + ruleset_key not in API_MODS + or mod["acronym"] not in API_MODS[ruleset_key] + ): all_proposed_valid = False continue valid_mods.append(mod) @@ -136,35 +141,35 @@ class PlaylistItem(MessagePackArrayModel): incompatible_mods = set() final_valid_mods = [] for mod in valid_mods: - if mod.acronym in incompatible_mods: + if mod["acronym"] in incompatible_mods: all_proposed_valid = False continue - setting_mods = API_MODS[ruleset_key].get(mod.acronym) + setting_mods = API_MODS[ruleset_key].get(mod["acronym"]) if setting_mods: incompatible_mods.update(setting_mods["IncompatibleMods"]) final_valid_mods.append(mod) # If not freestyle, check against allowed mods if not self.freestyle: - allowed_acronyms = {mod.acronym for mod in self.allowed_mods} + allowed_acronyms = {mod["acronym"] for mod in self.allowed_mods} filtered_valid_mods = [] for mod in final_valid_mods: - if mod.acronym not in allowed_acronyms: + if mod["acronym"] not in allowed_acronyms: all_proposed_valid = False else: filtered_valid_mods.append(mod) final_valid_mods = filtered_valid_mods # Check compatibility with required mods - required_mod_acronyms = {mod.acronym for mod in self.required_mods} + required_mod_acronyms = {mod["acronym"] for mod in self.required_mods} all_mod_acronyms = { - mod.acronym for mod in final_valid_mods + mod["acronym"] for mod in final_valid_mods } | required_mod_acronyms # Check for incompatibility between required and user mods filtered_valid_mods = [] for mod in final_valid_mods: - mod_acronym = mod.acronym + mod_acronym = mod["acronym"] is_compatible = True for other_acronym in all_mod_acronyms: @@ -181,23 +186,29 @@ class PlaylistItem(MessagePackArrayModel): return all_proposed_valid, filtered_valid_mods + def clone(self) -> "PlaylistItem": + copy = self.model_copy() + copy.required_mods = list(self.required_mods) + copy.allowed_mods = list(self.allowed_mods) + return copy -class _MultiplayerCountdown(MessagePackArrayModel): - id: int - remaining: int - is_exclusive: bool + +class _MultiplayerCountdown(BaseModel): + id: int = 0 + remaining: timedelta + is_exclusive: bool = False class MatchStartCountdown(_MultiplayerCountdown): - type: Literal[0] = Field(0, exclude=True) + union_type: ClassVar[Literal[0]] = 0 class ForceGameplayStartCountdown(_MultiplayerCountdown): - type: Literal[1] = Field(1, exclude=True) + union_type: ClassVar[Literal[1]] = 1 class ServerShuttingDownCountdown(_MultiplayerCountdown): - type: Literal[2] = Field(2, exclude=True) + union_type: ClassVar[Literal[2]] = 2 MultiplayerCountdown = ( @@ -205,11 +216,9 @@ MultiplayerCountdown = ( ) -class MultiplayerRoomUser(MessagePackArrayModel): +class MultiplayerRoomUser(BaseModel): user_id: int - state: Annotated[MultiplayerUserState, EnumByIndex(MultiplayerUserState)] = ( - MultiplayerUserState.IDLE - ) + state: MultiplayerUserState = MultiplayerUserState.IDLE availability: BeatmapAvailability = BeatmapAvailability( state=DownloadState.UNKNOWN, progress=None ) @@ -218,50 +227,33 @@ class MultiplayerRoomUser(MessagePackArrayModel): ruleset_id: int | None = None # freestyle beatmap_id: int | None = None # freestyle - @field_validator("match_state", mode="before") - def union_validate(v: Any): - if isinstance(v, list): - return msgpack_union(v) - return v - @field_serializer("match_state") - def union_serialize(v: Any): - return msgpack_union_dump(v) - - -class MultiplayerRoom(MessagePackArrayModel): +class MultiplayerRoom(BaseModel): room_id: int - state: Annotated[MultiplayerRoomState, EnumByIndex(MultiplayerRoomState)] + state: MultiplayerRoomState settings: MultiplayerRoomSettings users: list[MultiplayerRoomUser] = Field(default_factory=list) host: MultiplayerRoomUser | None = None match_state: MatchRoomState | None = None playlist: list[PlaylistItem] = Field(default_factory=list) - active_cooldowns: list[MultiplayerCountdown] = Field(default_factory=list) + active_countdowns: list[MultiplayerCountdown] = Field(default_factory=list) channel_id: int - @field_validator("match_state", mode="before") - def union_validate(v: Any): - if isinstance(v, list): - return msgpack_union(v) - return v - - @field_serializer("match_state") - def union_serialize(v: Any): - return msgpack_union_dump(v) - class MultiplayerQueue: - def __init__(self, room: "ServerMultiplayerRoom", hub: "MultiplayerHub"): + def __init__(self, room: "ServerMultiplayerRoom"): self.server_room = room - self.hub = hub self.current_index = 0 + @property + def hub(self) -> "MultiplayerHub": + return self.server_room.hub + @property def upcoming_items(self): return sorted( (item for item in self.room.playlist if not item.expired), - key=lambda i: i.order, + key=lambda i: i.playlist_order, ) @property @@ -323,9 +315,9 @@ class MultiplayerQueue: ) async with AsyncSession(engine) as session: for idx, item in enumerate(ordered_active_items): - if item.order == idx: + if item.playlist_order == idx: continue - item.order = idx + item.playlist_order = idx await Playlist.update(item, self.room.room_id, session) await self.hub.playlist_changed( self.server_room, item, beatmap_changed=False @@ -338,7 +330,7 @@ class MultiplayerQueue: if upcoming_items else max( self.room.playlist, - key=lambda i: i.played_at or datetime.datetime.min, + key=lambda i: i.played_at or datetime.min, ) ) self.current_index = self.room.playlist.index(next_item) @@ -356,14 +348,7 @@ class MultiplayerQueue: limit = HOST_LIMIT if is_host else PER_USER_LIMIT if ( - len( - list( - filter( - lambda x: x.owner_id == user.user_id, - self.room.playlist, - ) - ) - ) + len([True for u in self.room.playlist if u.owner_id == user.user_id]) >= limit ): raise InvokeException(f"You can only have {limit} items in the queue") @@ -376,11 +361,11 @@ class MultiplayerQueue: beatmap = await session.get(Beatmap, item.beatmap_id) if beatmap is None: raise InvokeException("Beatmap not found") - if item.checksum != beatmap.checksum: + if item.beatmap_checksum != beatmap.checksum: raise InvokeException("Checksum mismatch") # TODO: mods validation item.owner_id = user.user_id - item.star = float( + item.star_rating = float( beatmap.difficulty_rating ) # FIXME: beatmap use decimal await Playlist.add_to_db(item, self.room.room_id, session) @@ -400,7 +385,7 @@ class MultiplayerQueue: beatmap = await session.get(Beatmap, item.beatmap_id) if beatmap is None: raise InvokeException("Beatmap not found") - if item.checksum != beatmap.checksum: + if item.beatmap_checksum != beatmap.checksum: raise InvokeException("Checksum mismatch") existing_item = next( @@ -423,8 +408,8 @@ class MultiplayerQueue: # TODO: mods validation item.owner_id = user.user_id - item.star = float(beatmap.difficulty_rating) - item.order = existing_item.order + item.star_rating = float(beatmap.difficulty_rating) + item.playlist_order = existing_item.playlist_order await Playlist.update(item, self.room.room_id, session) @@ -437,7 +422,8 @@ class MultiplayerQueue: await self.hub.playlist_changed( self.server_room, item, - beatmap_changed=item.checksum != existing_item.checksum, + beatmap_changed=item.beatmap_checksum + != existing_item.beatmap_checksum, ) async def remove_item(self, playlist_item_id: int, user: MultiplayerRoomUser): @@ -477,12 +463,46 @@ class MultiplayerQueue: await self.update_current_item() await self.hub.playlist_removed(self.server_room, item.id) + async def finish_current_item(self): + from app.database import Playlist + + async with AsyncSession(engine) as session: + played_at = datetime.now(UTC) + await session.execute( + update(Playlist) + .where( + col(Playlist.id) == self.current_item.id, + col(Playlist.room_id) == self.room.room_id, + ) + .values(expired=True, played_at=played_at) + ) + self.room.playlist[self.current_index].expired = True + self.room.playlist[self.current_index].played_at = played_at + await self.hub.playlist_changed(self.server_room, self.current_item, True) + await self.update_order() + if self.room.settings.queue_mode == QueueMode.HOST_ONLY and all( + playitem.expired for playitem in self.room.playlist + ): + assert self.room.host + await self.add_item(self.current_item.clone(), self.room.host) + @property def current_item(self): - """Get the current playlist item""" - current_id = self.room.settings.playlist_item_id - return next( - (item for item in self.room.playlist if item.id == current_id), + return self.room.playlist[self.current_index] + + +@dataclass +class CountdownInfo: + countdown: MultiplayerCountdown + duration: timedelta + task: asyncio.Task | None = None + + def __init__(self, countdown: MultiplayerCountdown): + self.countdown = countdown + self.duration = ( + countdown.remaining + if countdown.remaining > timedelta(seconds=0) + else timedelta(seconds=0) ) @@ -491,5 +511,79 @@ class ServerMultiplayerRoom: room: MultiplayerRoom category: RoomCategory status: RoomStatus - start_at: datetime.datetime + start_at: datetime + hub: "MultiplayerHub" queue: MultiplayerQueue | None = None + _next_countdown_id: int = 0 + _countdown_id_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + _tracked_countdown: dict[int, CountdownInfo] = field(default_factory=dict) + + async def get_next_countdown_id(self) -> int: + async with self._countdown_id_lock: + self._next_countdown_id += 1 + return self._next_countdown_id + + async def start_countdown( + self, + countdown: MultiplayerCountdown, + on_complete: Callable[["ServerMultiplayerRoom"], Awaitable[Any]] | None = None, + ): + async def _countdown_task(self: "ServerMultiplayerRoom"): + await asyncio.sleep(info.duration.total_seconds()) + await self.stop_countdown(countdown) + if on_complete is not None: + await on_complete(self) + + if countdown.is_exclusive: + await self.stop_all_countdowns() + + countdown.id = await self.get_next_countdown_id() + info = CountdownInfo(countdown) + self.room.active_countdowns.append(info.countdown) + self._tracked_countdown[countdown.id] = info + await self.hub.send_match_event( + self, CountdownStartedEvent(countdown=info.countdown) + ) + info.task = asyncio.create_task(_countdown_task(self)) + + async def stop_countdown(self, countdown: MultiplayerCountdown): + info = next( + ( + info + for info in self._tracked_countdown.values() + if info.countdown.id == countdown.id + ), + None, + ) + if info is None: + return + if info.task is not None and not info.task.done(): + info.task.cancel() + del self._tracked_countdown[countdown.id] + self.room.active_countdowns.remove(countdown) + await self.hub.send_match_event(self, CountdownStoppedEvent(id=countdown.id)) + + async def stop_all_countdowns(self): + for countdown in list(self._tracked_countdown.values()): + await self.stop_countdown(countdown.countdown) + + self._tracked_countdown.clear() + self.room.active_countdowns.clear() + + +class _MatchServerEvent(BaseModel): ... + + +class CountdownStartedEvent(_MatchServerEvent): + countdown: MultiplayerCountdown + + type: Literal[0] = Field(default=0, exclude=True) + + +class CountdownStoppedEvent(_MatchServerEvent): + id: int + + type: Literal[1] = Field(default=1, exclude=True) + + +MatchServerEvent = CountdownStartedEvent | CountdownStoppedEvent diff --git a/app/models/room.py b/app/models/room.py index 42f897c..392562a 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -53,6 +53,15 @@ class MultiplayerUserState(str, Enum): RESULTS = "results" SPECTATING = "spectating" + @property + def is_playing(self) -> bool: + return self in { + self.WAITING_FOR_LOAD, + self.PLAYING, + self.READY_FOR_GAMEPLAY, + self.LOADED, + } + class DownloadState(str, Enum): UNKNOWN = "unknown" diff --git a/app/models/signalr.py b/app/models/signalr.py index de66e30..7116ea0 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -14,6 +14,7 @@ class SignalRMeta: member_ignore: bool = False # implement of IgnoreMember (msgpack) attribute json_ignore: bool = False # implement of JsonIgnore (json) attribute use_upper_case: bool = False # use upper CamelCase for field names + use_abbr: bool = True class SignalRUnionMessage(BaseModel): diff --git a/app/router/score.py b/app/router/score.py index 2f1303e..b50911d 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -1,10 +1,19 @@ from __future__ import annotations -from app.database import Beatmap, Score, ScoreResp, ScoreToken, ScoreTokenResp, User +from app.database import ( + Beatmap, + Playlist, + Score, + ScoreResp, + ScoreToken, + ScoreTokenResp, + User, +) from app.database.score import get_leaderboard, process_score, process_user from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user +from app.fetcher import Fetcher from app.models.beatmap import BeatmapRankStatus from app.models.score import ( INT_TO_MODE, @@ -13,6 +22,7 @@ from app.models.score import ( Rank, SoloScoreSubmissionInfo, ) +from app.signalr.hub import MultiplayerHubs from .api_router import router @@ -24,6 +34,68 @@ from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession +async def submit_score( + info: SoloScoreSubmissionInfo, + beatmap: int, + token: int, + current_user: User, + db: AsyncSession, + redis: Redis, + fetcher: Fetcher, + item_id: int | None = None, + room_id: int | None = None, +): + if not info.passed: + info.rank = Rank.F + score_token = ( + await db.exec( + select(ScoreToken) + .options(joinedload(ScoreToken.beatmap)) # pyright: ignore[reportArgumentType] + .where(ScoreToken.id == token) + ) + ).first() + if not score_token or score_token.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Score token not found") + if score_token.score_id: + score = ( + await db.exec( + select(Score).where( + Score.id == score_token.score_id, + Score.user_id == current_user.id, + ) + ) + ).first() + if not score: + raise HTTPException(status_code=404, detail="Score not found") + else: + beatmap_status = ( + await db.exec(select(Beatmap.beatmap_status).where(Beatmap.id == beatmap)) + ).first() + if beatmap_status is None: + raise HTTPException(status_code=404, detail="Beatmap not found") + ranked = beatmap_status in { + BeatmapRankStatus.RANKED, + BeatmapRankStatus.APPROVED, + } + score = await process_score( + current_user, + beatmap, + ranked, + score_token, + info, + fetcher, + db, + redis, + ) + await db.refresh(current_user) + score_id = score.id + score_token.score_id = score_id + await process_user(db, current_user, score, ranked) + score = (await db.exec(select(Score).where(Score.id == score_id))).first() + assert score is not None + return await ScoreResp.from_db(db, score) + + class BeatmapScores(BaseModel): scores: list[ScoreResp] userScore: ScoreResp | None = None @@ -97,9 +169,10 @@ async def get_user_beatmap_score( status_code=404, detail=f"Cannot find user {user}'s score on this beatmap" ) else: + resp = await ScoreResp.from_db(db, user_score) return BeatmapUserScore( - position=user_score.position if user_score.position is not None else 0, - score=await ScoreResp.from_db(db, user_score), + position=resp.rank_global or 0, + score=resp, ) @@ -173,55 +246,95 @@ async def submit_solo_score( redis: Redis = Depends(get_redis), fetcher=Depends(get_fetcher), ): - if not info.passed: - info.rank = Rank.F - async with db: - score_token = ( - await db.exec( - select(ScoreToken) - .options(joinedload(ScoreToken.beatmap)) # pyright: ignore[reportArgumentType] - .where(ScoreToken.id == token, ScoreToken.user_id == current_user.id) + return await submit_score(info, beatmap, token, current_user, db, redis, fetcher) + + +@router.post( + "/rooms/{room_id}/playlist/{playlist_id}/scores", response_model=ScoreTokenResp +) +async def create_playlist_score( + room_id: int, + playlist_id: int, + beatmap_id: int = Form(), + beatmap_hash: str = Form(), + ruleset_id: int = Form(..., ge=0, le=3), + version_hash: str = Form(""), + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +): + room = MultiplayerHubs.rooms[room_id] + if not room: + raise HTTPException(status_code=404, detail="Room not found") + item = ( + await session.exec( + select(Playlist).where( + Playlist.id == playlist_id, Playlist.room_id == room_id ) - ).first() - if not score_token or score_token.user_id != current_user.id: - raise HTTPException(status_code=404, detail="Score token not found") - if score_token.score_id: - score = ( - await db.exec( - select(Score).where( - Score.id == score_token.score_id, - Score.user_id == current_user.id, - ) - ) - ).first() - if not score: - raise HTTPException(status_code=404, detail="Score not found") - else: - beatmap_status = ( - await db.exec( - select(Beatmap.beatmap_status).where(Beatmap.id == beatmap) - ) - ).first() - if beatmap_status is None: - raise HTTPException(status_code=404, detail="Beatmap not found") - ranked = beatmap_status in { - BeatmapRankStatus.RANKED, - BeatmapRankStatus.APPROVED, - } - score = await process_score( - current_user, - beatmap, - ranked, - score_token, - info, - fetcher, - db, - redis, + ) + ).first() + if not item: + raise HTTPException(status_code=404, detail="Playlist not found") + + # validate + if not item.freestyle: + if item.ruleset_id != ruleset_id: + raise HTTPException( + status_code=400, detail="Ruleset mismatch in playlist item" ) - await db.refresh(current_user) - score_id = score.id - score_token.score_id = score_id - await process_user(db, current_user, score, ranked) - score = (await db.exec(select(Score).where(Score.id == score_id))).first() - assert score is not None - return await ScoreResp.from_db(db, score) + if item.beatmap_id != beatmap_id: + raise HTTPException( + status_code=400, detail="Beatmap ID mismatch in playlist item" + ) + # TODO: max attempts + if item.expired: + raise HTTPException(status_code=400, detail="Playlist item has expired") + if item.played_at: + raise HTTPException( + status_code=400, detail="Playlist item has already been played" + ) + # 这里应该不用验证mod了吧。。。 + + score_token = ScoreToken( + user_id=current_user.id, + beatmap_id=beatmap_id, + ruleset_id=INT_TO_MODE[ruleset_id], + playlist_item_id=playlist_id, + ) + session.add(score_token) + await session.commit() + await session.refresh(score_token) + return ScoreTokenResp.from_db(score_token) + + +@router.put("/rooms/{room_id}/playlist/{playlist_id}/scores/{token}") +async def submit_playlist_score( + room_id: int, + playlist_id: int, + token: int, + info: SoloScoreSubmissionInfo, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), + fetcher: Fetcher = Depends(get_fetcher), +): + item = ( + await session.exec( + select(Playlist).where( + Playlist.id == playlist_id, Playlist.room_id == room_id + ) + ) + ).first() + if not item: + raise HTTPException(status_code=404, detail="Playlist item not found") + score_resp = await submit_score( + info, + item.beatmap_id, + token, + current_user, + session, + redis, + fetcher, + item.id, + room_id, + ) + return score_resp diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index bd34be0..21f192c 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +from datetime import timedelta from typing import override from app.database import Room @@ -8,8 +10,11 @@ from app.database.playlists import Playlist from app.dependencies.database import engine from app.exception import InvokeException from app.log import logger +from app.models.mods import APIMod from app.models.multiplayer_hub import ( BeatmapAvailability, + ForceGameplayStartCountdown, + MatchServerEvent, MultiplayerClientState, MultiplayerQueue, MultiplayerRoom, @@ -17,16 +22,22 @@ from app.models.multiplayer_hub import ( PlaylistItem, ServerMultiplayerRoom, ) -from app.models.room import RoomCategory, RoomStatus +from app.models.room import ( + DownloadState, + MultiplayerRoomState, + MultiplayerUserState, + RoomCategory, + RoomStatus, +) from app.models.score import GameMode -from app.models.signalr import serialize_to_list from .hub import Client, Hub -from msgpack_lazer_api import APIMod from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +GAMEPLAY_LOAD_TIMEOUT = 30 + class MultiplayerHub(Hub[MultiplayerClientState]): @override @@ -58,7 +69,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): type=room.settings.match_type, queue_mode=room.settings.queue_mode, auto_skip=room.settings.auto_skip, - auto_start_duration=room.settings.auto_start_duration, + auto_start_duration=int( + room.settings.auto_start_duration.total_seconds() + ), host_id=client.user_id, status=RoomStatus.IDLE, ) @@ -75,10 +88,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): category=RoomCategory.NORMAL, status=RoomStatus.IDLE, start_at=starts_at, + hub=self, ) queue = MultiplayerQueue( room=server_room, - hub=self, ) server_room.queue = queue self.rooms[room.room_id] = server_room @@ -86,6 +99,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): client, room.room_id, room.settings.password ) + async def JoinRoom(self, client: Client, room_id: int): + return self.JoinRoomWithPassword(client, room_id, "") + async def JoinRoomWithPassword(self, client: Client, room_id: int, password: str): logger.info(f"[MultiplayerHub] {client.user_id} joining room {room_id}") store = self.get_or_create_state(client) @@ -105,12 +121,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): # from CreateRoom room.host = user store.room_id = room_id - await self.broadcast_group_call( - self.group_id(room_id), "UserJoined", serialize_to_list(user) - ) + await self.broadcast_group_call(self.group_id(room_id), "UserJoined", user) room.users.append(user) self.add_to_group(client, self.group_id(room_id)) - return serialize_to_list(room) + return room async def ChangeBeatmapAvailability( self, client: Client, beatmap_availability: BeatmapAvailability @@ -132,12 +146,12 @@ class MultiplayerHub(Hub[MultiplayerClientState]): and availability.progress == beatmap_availability.progress ): return - user.availability = availability + user.availability = beatmap_availability await self.broadcast_group_call( self.group_id(store.room_id), "UserBeatmapAvailabilityChanged", user.user_id, - serialize_to_list(beatmap_availability), + (beatmap_availability), ) async def AddPlaylistItem(self, client: Client, item: PlaylistItem): @@ -198,14 +212,14 @@ class MultiplayerHub(Hub[MultiplayerClientState]): await self.broadcast_group_call( self.group_id(room.room.room_id), "SettingsChanged", - serialize_to_list(room.room.settings), + (room.room.settings), ) async def playlist_added(self, room: ServerMultiplayerRoom, item: PlaylistItem): await self.broadcast_group_call( self.group_id(room.room.room_id), "PlaylistItemAdded", - serialize_to_list(item), + (item), ) async def playlist_removed(self, room: ServerMultiplayerRoom, item_id: int): @@ -221,7 +235,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): await self.broadcast_group_call( self.group_id(room.room.room_id), "PlaylistItemChanged", - serialize_to_list(item), + (item), ) async def ChangeUserStyle( @@ -378,7 +392,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) if not is_valid: incompatible_mods = [ - mod.acronym for mod in new_mods if mod not in valid_mods + mod["acronym"] for mod in new_mods if mod not in valid_mods ] raise InvokeException( f"Incompatible mods were selected: {','.join(incompatible_mods)}" @@ -395,3 +409,221 @@ class MultiplayerHub(Hub[MultiplayerClientState]): user.user_id, valid_mods, ) + + async def validate_user_stare( + self, + room: ServerMultiplayerRoom, + old: MultiplayerUserState, + new: MultiplayerUserState, + ): + assert room.queue + match new: + case MultiplayerUserState.IDLE: + if old.is_playing: + raise InvokeException( + "Cannot return to idle without aborting gameplay." + ) + case MultiplayerUserState.READY: + if old != MultiplayerUserState.IDLE: + raise InvokeException(f"Cannot change state from {old} to {new}") + if room.queue.current_item.expired: + raise InvokeException( + "Cannot ready up while all items have been played." + ) + case MultiplayerUserState.WAITING_FOR_LOAD: + raise InvokeException("Cannot change state from {old} to {new}") + case MultiplayerUserState.LOADED: + if old != MultiplayerUserState.WAITING_FOR_LOAD: + raise InvokeException(f"Cannot change state from {old} to {new}") + case MultiplayerUserState.READY_FOR_GAMEPLAY: + if old != MultiplayerUserState.LOADED: + raise InvokeException(f"Cannot change state from {old} to {new}") + case MultiplayerUserState.PLAYING: + raise InvokeException("State is managed by the server.") + case MultiplayerUserState.FINISHED_PLAY: + if old != MultiplayerUserState.PLAYING: + raise InvokeException(f"Cannot change state from {old} to {new}") + case MultiplayerUserState.RESULTS: + raise InvokeException("Cannot change state from {old} to {new}") + case MultiplayerUserState.SPECTATING: + if old not in (MultiplayerUserState.IDLE, MultiplayerUserState.READY): + raise InvokeException(f"Cannot change state from {old} to {new}") + + async def ChangeState(self, client: Client, state: MultiplayerUserState): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + if user.state == state: + return + match state: + case MultiplayerUserState.IDLE: + if user.state.is_playing: + return + case MultiplayerUserState.LOADED | MultiplayerUserState.READY_FOR_GAMEPLAY: + if not user.state.is_playing: + return + await self.validate_user_stare( + server_room, + user.state, + state, + ) + await self.change_user_state(server_room, user, state) + if state == MultiplayerUserState.SPECTATING and ( + room.state == MultiplayerRoomState.PLAYING + or room.state == MultiplayerRoomState.WAITING_FOR_LOAD + ): + await self.call_noblock(client, "LoadRequested") + await self.update_room_state(server_room) + + async def change_user_state( + self, + room: ServerMultiplayerRoom, + user: MultiplayerRoomUser, + state: MultiplayerUserState, + ): + user.state = state + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "UserStateChanged", + user.user_id, + user.state, + ) + + async def update_room_state(self, room: ServerMultiplayerRoom): + match room.room.state: + case MultiplayerRoomState.WAITING_FOR_LOAD: + played_count = len( + [True for user in room.room.users if user.state.is_playing] + ) + ready_count = len( + [ + True + for user in room.room.users + if user.state == MultiplayerUserState.READY_FOR_GAMEPLAY + ] + ) + if played_count == ready_count: + await self.start_gameplay(room) + case MultiplayerRoomState.PLAYING: + assert room.queue + if all( + u.state != MultiplayerUserState.PLAYING for u in room.room.users + ): + for u in filter( + lambda u: u.state == MultiplayerUserState.FINISHED_PLAY, + room.room.users, + ): + await self.change_user_state( + room, u, MultiplayerUserState.RESULTS + ) + await self.change_room_state(room, MultiplayerRoomState.OPEN) + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "ResultsReady", + ) + await room.queue.finish_current_item() + + async def change_room_state( + self, room: ServerMultiplayerRoom, state: MultiplayerRoomState + ): + room.room.state = state + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "RoomStateChanged", + state, + ) + + async def StartMatch(self, client: Client): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + if room.host is None or room.host.user_id != client.user_id: + raise InvokeException("You are not the host of this room") + if any(u.state != MultiplayerUserState.READY for u in room.users): + raise InvokeException("Not all users are ready") + + await self.start_match(server_room) + + async def start_match(self, room: ServerMultiplayerRoom): + assert room.queue + if room.room.state != MultiplayerRoomState.OPEN: + raise InvokeException("Can't start match when already in a running state.") + if room.queue.current_item.expired: + raise InvokeException("Current playlist item is expired") + ready_users = [ + u + for u in room.room.users + if u.availability.state == DownloadState.LOCALLY_AVAILABLE + and ( + u.state == MultiplayerUserState.READY + or u.state == MultiplayerUserState.IDLE + ) + ] + await asyncio.gather( + *[ + self.change_user_state(room, u, MultiplayerUserState.WAITING_FOR_LOAD) + for u in ready_users + ] + ) + await self.change_room_state( + room, + MultiplayerRoomState.WAITING_FOR_LOAD, + ) + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "LoadRequested", + ) + await room.start_countdown( + ForceGameplayStartCountdown( + remaining=timedelta(seconds=GAMEPLAY_LOAD_TIMEOUT) + ), + self.start_gameplay, + ) + + async def start_gameplay(self, room: ServerMultiplayerRoom): + assert room.queue + if room.room.state != MultiplayerRoomState.WAITING_FOR_LOAD: + raise InvokeException("Room is not ready for gameplay") + if room.queue.current_item.expired: + raise InvokeException("Current playlist item is expired") + playing = False + for user in room.room.users: + client = self.get_client_by_id(str(user.user_id)) + if client is None: + continue + + if user.state in ( + MultiplayerUserState.READY_FOR_GAMEPLAY, + MultiplayerUserState.LOADED, + ): + playing = True + await self.change_user_state(room, user, MultiplayerUserState.PLAYING) + await self.call_noblock(client, "GameplayStarted") + await self.change_room_state( + room, + (MultiplayerRoomState.PLAYING if playing else MultiplayerRoomState.OPEN), + ) + + async def send_match_event( + self, room: ServerMultiplayerRoom, event: MatchServerEvent + ): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "MatchEvent", + event, + ) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index 70c2276..9afb78d 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -97,6 +97,8 @@ class MsgpackProtocol: return [cls.serialize_msgpack(item) for item in v] elif issubclass(typ, datetime.datetime): return [v, 0] + elif issubclass(typ, datetime.timedelta): + return int(v.total_seconds()) elif isinstance(v, dict): return { cls.serialize_msgpack(k): cls.serialize_msgpack(value) @@ -213,6 +215,8 @@ class MsgpackProtocol: return typ.model_validate(obj=cls.process_object(v, typ)) elif inspect.isclass(typ) and issubclass(typ, datetime.datetime): return v[0] + elif inspect.isclass(typ) and issubclass(typ, datetime.timedelta): + return datetime.timedelta(seconds=int(v)) elif isinstance(v, list): return [cls.validate_object(item, get_args(typ)[0]) for item in v] elif inspect.isclass(typ) and issubclass(typ, Enum): @@ -296,21 +300,30 @@ class MsgpackProtocol: class JSONProtocol: @classmethod - def serialize_to_json(cls, v: Any): + def serialize_to_json(cls, v: Any, dict_key: bool = False): typ = v.__class__ if issubclass(typ, BaseModel): return cls.serialize_model(v) elif isinstance(v, dict): return { - cls.serialize_to_json(k): cls.serialize_to_json(value) + cls.serialize_to_json(k, True): cls.serialize_to_json(value) for k, value in v.items() } elif isinstance(v, list): return [cls.serialize_to_json(item) for item in v] elif isinstance(v, datetime.datetime): return v.isoformat() - elif isinstance(v, Enum): + elif isinstance(v, datetime.timedelta): + # d.hh:mm:ss + total_seconds = int(v.total_seconds()) + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return f"{hours:02}:{minutes:02}:{seconds:02}" + elif isinstance(v, Enum) and dict_key: return v.value + elif isinstance(v, Enum): + list_ = list(typ) + return list_.index(v) return v @classmethod @@ -322,9 +335,13 @@ class JSONProtocol: ) if metadata and metadata.json_ignore: continue - d[snake_to_camel(field, metadata.use_upper_case if metadata else False)] = ( - cls.serialize_to_json(getattr(v, field)) - ) + d[ + snake_to_camel( + field, + metadata.use_upper_case if metadata else False, + metadata.use_abbr if metadata else True, + ) + ] = cls.serialize_to_json(getattr(v, field)) if issubclass(v.__class__, SignalRUnionMessage): return { "$dtype": v.__class__.__name__, @@ -343,7 +360,11 @@ class JSONProtocol: ) if metadata and metadata.json_ignore: continue - value = v.get(snake_to_camel(field, not from_union)) + value = v.get( + snake_to_camel( + field, not from_union, metadata.use_abbr if metadata else True + ) + ) anno = typ.model_fields[field].annotation if anno is None: d[field] = value @@ -401,6 +422,17 @@ class JSONProtocol: return typ.model_validate(JSONProtocol.process_object(v, typ, from_union)) elif inspect.isclass(typ) and issubclass(typ, datetime.datetime): return datetime.datetime.fromisoformat(v) + elif inspect.isclass(typ) and issubclass(typ, datetime.timedelta): + # d.hh:mm:ss + parts = v.split(":") + if len(parts) == 3: + return datetime.timedelta( + hours=int(parts[0]), minutes=int(parts[1]), seconds=int(parts[2]) + ) + elif len(parts) == 2: + return datetime.timedelta(minutes=int(parts[0]), seconds=int(parts[1])) + elif len(parts) == 1: + return datetime.timedelta(seconds=int(parts[0])) elif isinstance(v, list): return [cls.validate_object(item, get_args(typ)[0]) for item in v] elif inspect.isclass(typ) and issubclass(typ, Enum): diff --git a/app/utils.py b/app/utils.py index 0d759a1..ac51b90 100644 --- a/app/utils.py +++ b/app/utils.py @@ -21,7 +21,7 @@ def camel_to_snake(name: str) -> str: return "".join(result) -def snake_to_camel(name: str, lower_case: bool = True) -> str: +def snake_to_camel(name: str, lower_case: bool = True, use_abbr: bool = True) -> str: """Convert a snake_case string to camelCase.""" if not name: return name @@ -47,7 +47,7 @@ def snake_to_camel(name: str, lower_case: bool = True) -> str: result = [] for part in parts: - if part.lower() in abbreviations: + if part.lower() in abbreviations and use_abbr: result.append(part.upper()) else: if result or not lower_case: From c2579e86ebc5595a2a8d8b65a646411953e568e8 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 3 Aug 2025 13:50:59 +0000 Subject: [PATCH 017/104] feat(multiplayer): supoort manage user (kick, transfer host, leave) --- app/signalr/hub/multiplayer.py | 116 ++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 21f192c..9350c34 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import UTC, datetime, timedelta from typing import override from app.database import Room @@ -33,7 +33,8 @@ from app.models.score import GameMode from .hub import Client, Hub -from sqlmodel import select +from sqlalchemy import update +from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession GAMEPLAY_LOAD_TIMEOUT = 30 @@ -627,3 +628,114 @@ class MultiplayerHub(Hub[MultiplayerClientState]): "MatchEvent", event, ) + + async def make_user_leave( + self, + client: Client, + room: ServerMultiplayerRoom, + user: MultiplayerRoomUser, + kicked: bool = False, + ): + self.remove_from_group(client, self.group_id(room.room.room_id)) + room.room.users.remove(user) + + if len(room.room.users) == 0: + await self.end_room(room) + await self.update_room_state(room) + if room.room.host and room.room.host.user_id == user.user_id: + next_host = room.room.users[0] + await self.set_host(room, next_host) + + if kicked: + await self.call_noblock(client, "UserKicked", user) + await self.broadcast_group_call( + self.group_id(room.room.room_id), "UserKicked", user + ) + else: + await self.broadcast_group_call( + self.group_id(room.room.room_id), "UserLeft", user + ) + + target_store = self.state.get(user.user_id) + if target_store: + target_store.room_id = 0 + + async def end_room(self, room: ServerMultiplayerRoom): + assert room.room.host + async with AsyncSession(engine) as session: + await session.execute( + update(Room) + .where(col(Room.id) == room.room.room_id) + .values( + name=room.room.settings.name, + ended_at=datetime.now(UTC), + type=room.room.settings.match_type, + queue_mode=room.room.settings.queue_mode, + auto_skip=room.room.settings.auto_skip, + auto_start_duration=int( + room.room.settings.auto_start_duration.total_seconds() + ), + host_id=room.room.host.user_id, + ) + ) + del self.rooms[room.room.room_id] + + async def LeaveRoom(self, client: Client): + store = self.get_or_create_state(client) + if store.room_id == 0: + return + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + await self.make_user_leave(client, server_room, user) + + async def KickUser(self, client: Client, user_id: int): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + + if room.host is None or room.host.user_id != client.user_id: + raise InvokeException("You are not the host of this room") + + user = next((u for u in room.users if u.user_id == user_id), None) + if user is None: + raise InvokeException("User not found in this room") + + target_client = self.get_client_by_id(str(user.user_id)) + if target_client is None: + return + await self.make_user_leave(target_client, server_room, user, kicked=True) + + async def set_host(self, room: ServerMultiplayerRoom, user: MultiplayerRoomUser): + room.room.host = user + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "HostChanged", + user.user_id, + ) + + async def TransferHost(self, client: Client, user_id: int): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + + if room.host is None or room.host.user_id != client.user_id: + raise InvokeException("You are not the host of this room") + + new_host = next((u for u in room.users if u.user_id == user_id), None) + if new_host is None: + raise InvokeException("User not found in this room") + await self.set_host(server_room, new_host) From 1e304542bd4d61da3082a9d58aa029a47cca3fb2 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 3 Aug 2025 14:00:49 +0000 Subject: [PATCH 018/104] feat(multiplayer): supoort abort match --- app/models/multiplayer_hub.py | 6 ++++ app/signalr/hub/multiplayer.py | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index ba8a050..9c30523 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta +from enum import IntEnum from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal from app.database.beatmap import Beatmap @@ -587,3 +588,8 @@ class CountdownStoppedEvent(_MatchServerEvent): MatchServerEvent = CountdownStartedEvent | CountdownStoppedEvent + + +class GameplayAbortReason(IntEnum): + LOAD_TOOK_TOO_LONG = 0 + HOST_ABORTED = 1 diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 9350c34..1a2b332 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -14,6 +14,7 @@ from app.models.mods import APIMod from app.models.multiplayer_hub import ( BeatmapAvailability, ForceGameplayStartCountdown, + GameplayAbortReason, MatchServerEvent, MultiplayerClientState, MultiplayerQueue, @@ -615,6 +616,13 @@ class MultiplayerHub(Hub[MultiplayerClientState]): playing = True await self.change_user_state(room, user, MultiplayerUserState.PLAYING) await self.call_noblock(client, "GameplayStarted") + elif user.state == MultiplayerUserState.WAITING_FOR_LOAD: + await self.change_user_state(room, user, MultiplayerUserState.IDLE) + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "GameplayAborted", + GameplayAbortReason.LOAD_TOOK_TOO_LONG, + ) await self.change_room_state( room, (MultiplayerRoomState.PLAYING if playing else MultiplayerRoomState.OPEN), @@ -739,3 +747,57 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if new_host is None: raise InvokeException("User not found in this room") await self.set_host(server_room, new_host) + + async def AbortGameplay(self, client: Client): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + if not user.state.is_playing: + raise InvokeException("Cannot abort gameplay while not in a gameplay state") + + await self.change_user_state( + server_room, + user, + MultiplayerUserState.IDLE, + ) + await self.update_room_state(server_room) + + async def AbortMatch(self, client: Client): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + + if room.host is None or room.host.user_id != client.user_id: + raise InvokeException("You are not the host of this room") + + if ( + room.state != MultiplayerRoomState.PLAYING + or room.state == MultiplayerRoomState.WAITING_FOR_LOAD + ): + raise InvokeException("Room is not in a playable state") + + await asyncio.gather( + *[ + self.change_user_state(server_room, u, MultiplayerUserState.IDLE) + for u in room.users + if u.state.is_playing + ] + ) + await self.broadcast_group_call( + self.group_id(room.room_id), + "GameplayAborted", + GameplayAbortReason.HOST_ABORTED, + ) + await self.update_room_state(server_room) From 34bf2c6b324c3f16da4eb0bbd511d2e0fffa268e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 3 Aug 2025 15:14:30 +0000 Subject: [PATCH 019/104] feat(multiplayer): support change settings --- app/models/multiplayer_hub.py | 172 +++++++++++++++++++++++++++++++-- app/signalr/hub/multiplayer.py | 92 +++++++++++++++--- 2 files changed, 240 insertions(+), 24 deletions(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 9c30523..97148db 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -1,11 +1,12 @@ from __future__ import annotations +from abc import ABC, abstractmethod import asyncio from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from enum import IntEnum -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, override from app.database.beatmap import Beatmap from app.dependencies.database import engine @@ -291,7 +292,9 @@ class MultiplayerQueue: current_set.append(items[i]) if is_first_set: - current_set.sort(key=lambda item: (item.order, item.id)) + current_set.sort( + key=lambda item: (item.playlist_order, item.id) + ) ordered_active_items.extend(current_set) first_set_order_by_user_id = { item.owner_id: idx @@ -308,7 +311,7 @@ class MultiplayerQueue: is_first_set = False for idx, item in enumerate(ordered_active_items): - item.order = idx + item.playlist_order = idx case _: ordered_active_items = sorted( (item for item in self.room.playlist if not item.expired), @@ -487,6 +490,15 @@ class MultiplayerQueue: assert self.room.host await self.add_item(self.current_item.clone(), self.room.host) + async def update_queue_mode(self): + if self.room.settings.queue_mode == QueueMode.HOST_ONLY and all( + playitem.expired for playitem in self.room.playlist + ): + assert self.room.host + await self.add_item(self.current_item.clone(), self.room.host) + await self.update_order() + await self.update_current_item() + @property def current_item(self): return self.room.playlist[self.current_index] @@ -507,6 +519,125 @@ class CountdownInfo: ) +class _MatchRequest(SignalRUnionMessage): ... + + +class ChangeTeamRequest(_MatchRequest): + union_type: ClassVar[Literal[0]] = 0 + team_id: int + + +class StartMatchCountdownRequest(_MatchRequest): + union_type: ClassVar[Literal[1]] = 1 + duration: timedelta + + +class StopCountdownRequest(_MatchRequest): + union_type: ClassVar[Literal[2]] = 2 + id: int + + +MatchRequest = ChangeTeamRequest | StartMatchCountdownRequest | StopCountdownRequest + + +class MatchTypeHandler(ABC): + def __init__(self, room: "ServerMultiplayerRoom"): + self.room = room + self.hub = room.hub + + @abstractmethod + async def handle_join(self, user: MultiplayerRoomUser): ... + + @abstractmethod + async def handle_request( + self, user: MultiplayerRoomUser, request: MatchRequest + ): ... + + @abstractmethod + async def handle_leave(self, user: MultiplayerRoomUser): ... + + +class HeadToHeadHandler(MatchTypeHandler): + @override + async def handle_join(self, user: MultiplayerRoomUser): + if user.match_state is not None: + user.match_state = None + await self.hub.change_user_match_state(self.room, user) + + @override + async def handle_request( + self, user: MultiplayerRoomUser, request: MatchRequest + ): ... + + @override + async def handle_leave(self, user: MultiplayerRoomUser): ... + + +class TeamVersusHandler(MatchTypeHandler): + @override + def __init__(self, room: "ServerMultiplayerRoom"): + super().__init__(room) + self.state = TeamVersusRoomState() + room.room.match_state = self.state + task = asyncio.create_task(self.hub.change_room_match_state(self.room)) + self.hub.tasks.add(task) + task.add_done_callback(self.hub.tasks.discard) + + def _get_best_available_team(self) -> int: + for team in self.state.teams: + if all( + ( + user.match_state is None + or not isinstance(user.match_state, TeamVersusUserState) + or user.match_state.team_id != team.id + ) + for user in self.room.room.users + ): + return team.id + + from collections import defaultdict + + team_counts = defaultdict(int) + for user in self.room.room.users: + if user.match_state is not None and isinstance( + user.match_state, TeamVersusUserState + ): + team_counts[user.match_state.team_id] += 1 + + if team_counts: + min_count = min(team_counts.values()) + for team_id, count in team_counts.items(): + if count == min_count: + return team_id + return self.state.teams[0].id if self.state.teams else 0 + + @override + async def handle_join(self, user: MultiplayerRoomUser): + best_team_id = self._get_best_available_team() + user.match_state = TeamVersusUserState(team_id=best_team_id) + await self.hub.change_user_match_state(self.room, user) + + @override + async def handle_request(self, user: MultiplayerRoomUser, request: MatchRequest): + if not isinstance(request, ChangeTeamRequest): + return + + if request.team_id not in [team.id for team in self.state.teams]: + raise InvokeException("Invalid team ID") + + user.match_state = TeamVersusUserState(team_id=request.team_id) + await self.hub.change_user_match_state(self.room, user) + + @override + async def handle_leave(self, user: MultiplayerRoomUser): ... + + +MATCH_TYPE_HANDLERS = { + MatchType.HEAD_TO_HEAD: HeadToHeadHandler, + MatchType.TEAM_VERSUS: TeamVersusHandler, +} + + @dataclass class ServerMultiplayerRoom: room: MultiplayerRoom @@ -514,10 +645,35 @@ class ServerMultiplayerRoom: status: RoomStatus start_at: datetime hub: "MultiplayerHub" - queue: MultiplayerQueue | None = None - _next_countdown_id: int = 0 - _countdown_id_lock: asyncio.Lock = field(default_factory=asyncio.Lock) - _tracked_countdown: dict[int, CountdownInfo] = field(default_factory=dict) + match_type_handler: MatchTypeHandler + queue: MultiplayerQueue + _next_countdown_id: int + _countdown_id_lock: asyncio.Lock + _tracked_countdown: dict[int, CountdownInfo] + + def __init__( + self, + room: MultiplayerRoom, + category: RoomCategory, + start_at: datetime, + hub: "MultiplayerHub", + ): + self.room = room + self.category = category + self.status = RoomStatus.IDLE + self.start_at = start_at + self.hub = hub + self.queue = MultiplayerQueue(self) + self._next_countdown_id = 0 + self._countdown_id_lock = asyncio.Lock() + self._tracked_countdown = {} + + async def set_handler(self): + self.match_type_handler = MATCH_TYPE_HANDLERS[self.room.settings.match_type]( + self + ) + for i in self.room.users: + await self.match_type_handler.handle_join(i) async def get_next_countdown_id(self) -> int: async with self._countdown_id_lock: diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 1a2b332..1a27497 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -15,16 +15,20 @@ from app.models.multiplayer_hub import ( BeatmapAvailability, ForceGameplayStartCountdown, GameplayAbortReason, + MatchRequest, MatchServerEvent, MultiplayerClientState, - MultiplayerQueue, MultiplayerRoom, + MultiplayerRoomSettings, MultiplayerRoomUser, PlaylistItem, ServerMultiplayerRoom, + StartMatchCountdownRequest, + StopCountdownRequest, ) from app.models.room import ( DownloadState, + MatchType, MultiplayerRoomState, MultiplayerUserState, RoomCategory, @@ -88,15 +92,11 @@ class MultiplayerHub(Hub[MultiplayerClientState]): server_room = ServerMultiplayerRoom( room=room, category=RoomCategory.NORMAL, - status=RoomStatus.IDLE, start_at=starts_at, hub=self, ) - queue = MultiplayerQueue( - room=server_room, - ) - server_room.queue = queue self.rooms[room.room_id] = server_room + await server_room.set_handler() return await self.JoinRoomWithPassword( client, room.room_id, room.settings.password ) @@ -126,6 +126,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): await self.broadcast_group_call(self.group_id(room_id), "UserJoined", user) room.users.append(user) self.add_to_group(client, self.group_id(room_id)) + await server_room.match_type_handler.handle_join(user) return room async def ChangeBeatmapAvailability( @@ -164,7 +165,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException("Room does not exist") server_room = self.rooms[store.room_id] room = server_room.room - assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) if user is None: raise InvokeException("You are not in this room") @@ -182,7 +183,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException("Room does not exist") server_room = self.rooms[store.room_id] room = server_room.room - assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) if user is None: raise InvokeException("You are not in this room") @@ -200,7 +201,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException("Room does not exist") server_room = self.rooms[store.room_id] room = server_room.room - assert server_room.queue + user = next((u for u in room.users if u.user_id == client.user_id), None) if user is None: raise InvokeException("You are not in this room") @@ -262,7 +263,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def validate_styles(self, room: ServerMultiplayerRoom): - assert room.queue if not room.queue.current_item.freestyle: for user in room.room.users: await self.change_user_style( @@ -323,7 +323,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): return if beatmap_id is not None or ruleset_id is not None: - assert room.queue if not room.queue.current_item.freestyle: raise InvokeException("Current item does not allow free user styles.") @@ -388,7 +387,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): room: ServerMultiplayerRoom, user: MultiplayerRoomUser, ): - assert room.queue is_valid, valid_mods = room.queue.current_item.validate_user_mods( user, new_mods ) @@ -418,7 +416,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): old: MultiplayerUserState, new: MultiplayerUserState, ): - assert room.queue match new: case MultiplayerUserState.IDLE: if old.is_playing: @@ -515,7 +512,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if played_count == ready_count: await self.start_gameplay(room) case MultiplayerRoomState.PLAYING: - assert room.queue if all( u.state != MultiplayerUserState.PLAYING for u in room.room.users ): @@ -562,7 +558,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): await self.start_match(server_room) async def start_match(self, room: ServerMultiplayerRoom): - assert room.queue if room.room.state != MultiplayerRoomState.OPEN: raise InvokeException("Can't start match when already in a running state.") if room.queue.current_item.expired: @@ -598,7 +593,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def start_gameplay(self, room: ServerMultiplayerRoom): - assert room.queue if room.room.state != MultiplayerRoomState.WAITING_FOR_LOAD: raise InvokeException("Room is not ready for gameplay") if room.queue.current_item.expired: @@ -801,3 +795,69 @@ class MultiplayerHub(Hub[MultiplayerClientState]): GameplayAbortReason.HOST_ABORTED, ) await self.update_room_state(server_room) + + async def change_user_match_state( + self, room: ServerMultiplayerRoom, user: MultiplayerRoomUser + ): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "MatchUserStateChanged", + user.user_id, + user.match_state, + ) + + async def change_room_match_state(self, room: ServerMultiplayerRoom): + await self.broadcast_group_call( + self.group_id(room.room.room_id), + "MatchRoomStateChanged", + room.room.match_state, + ) + + async def ChangeSettings(self, client: Client, settings: MultiplayerRoomSettings): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + + if room.host is None or room.host.user_id != client.user_id: + raise InvokeException("You are not the host of this room") + + if room.state != MultiplayerRoomState.OPEN: + raise InvokeException("Cannot change settings while playing") + + if settings.match_type == MatchType.PLAYLISTS: + raise InvokeException("Invalid match type selected") + + previous_settings = room.settings + room.settings = settings + + if previous_settings.match_type != settings.match_type: + await server_room.set_handler() + if previous_settings.queue_mode != settings.queue_mode: + await server_room.queue.update_queue_mode() + + await self.setting_changed(server_room, beatmap_changed=False) + await self.update_room_state(server_room) + + async def SendMatchRequest(self, client: Client, request: MatchRequest): + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + if isinstance(request, StartMatchCountdownRequest): + # TODO: countdown + ... + elif isinstance(request, StopCountdownRequest): + ... + else: + await server_room.match_type_handler.handle_request(user, request) From f82a1bb3c0be2c8c8f33ec51e74e93c66546725e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 4 Aug 2025 01:31:24 +0000 Subject: [PATCH 020/104] feat(multiplayer): support invite player --- app/signalr/hub/multiplayer.py | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 1a27497..eb602fe 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -6,7 +6,9 @@ from typing import override from app.database import Room from app.database.beatmap import Beatmap +from app.database.lazer_user import User from app.database.playlists import Playlist +from app.database.relationship import Relationship, RelationshipType from app.dependencies.database import engine from app.exception import InvokeException from app.log import logger @@ -861,3 +863,73 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ... else: await server_room.match_type_handler.handle_request(user, request) + + async def InvitePlayer(self, client: Client, user_id: int): + print(f"Inviting player... {client.user_id} {user_id}") + store = self.get_or_create_state(client) + if store.room_id == 0: + raise InvokeException("You are not in a room") + if store.room_id not in self.rooms: + raise InvokeException("Room does not exist") + server_room = self.rooms[store.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == client.user_id), None) + if user is None: + raise InvokeException("You are not in this room") + + async with AsyncSession(engine) as session: + db_user = await session.get(User, user_id) + target_relationship = ( + await session.exec( + select(Relationship).where( + Relationship.user_id == user_id, + Relationship.target_id == client.user_id, + ) + ) + ).first() + inviter_relationship = ( + await session.exec( + select(Relationship).where( + Relationship.user_id == client.user_id, + Relationship.target_id == user_id, + ) + ) + ).first() + if db_user is None: + raise InvokeException("User not found") + if db_user.id == client.user_id: + raise InvokeException("You cannot invite yourself") + if db_user.id in [u.user_id for u in room.users]: + raise InvokeException("User already invited") + if db_user.is_restricted: + raise InvokeException("User is restricted") + if ( + inviter_relationship + and inviter_relationship.type == RelationshipType.BLOCK + ): + raise InvokeException("Cannot perform action due to user being blocked") + if ( + target_relationship + and target_relationship.type == RelationshipType.BLOCK + ): + raise InvokeException("Cannot perform action due to user being blocked") + if ( + db_user.pm_friends_only + and target_relationship is not None + and target_relationship.type != RelationshipType.FOLLOW + ): + raise InvokeException( + "Cannot perform action " + "because user has disabled non-friend communications" + ) + + target_client = self.get_client_by_id(str(user_id)) + if target_client is None: + raise InvokeException("User is not online") + await self.call_noblock( + target_client, + "Invited", + client.user_id, + room.room_id, + room.settings.password, + ) From 9da9f27febcccabbded8dd736c8376976fe66c4e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 4 Aug 2025 02:20:14 +0000 Subject: [PATCH 021/104] feat(multiplayer): complete validation --- app/models/multiplayer_hub.py | 103 ++++++++++++++++++++++++++++++--- app/signalr/hub/multiplayer.py | 38 ++++++++++-- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 97148db..e2f4edf 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import UTC, datetime, timedelta from enum import IntEnum -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, override +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, cast, override from app.database.beatmap import Beatmap from app.dependencies.database import engine @@ -107,6 +107,97 @@ class PlaylistItem(BaseModel): star_rating: float freestyle: bool + def _get_api_mods(self): + from app.models.mods import API_MODS, init_mods + + if not API_MODS: + init_mods() + return API_MODS + + def _validate_mod_for_ruleset( + self, mod: APIMod, ruleset_key: int, context: str = "mod" + ) -> None: + from typing import Literal, cast + + API_MODS = self._get_api_mods() + typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key) + + # Check if mod is valid for ruleset + if ( + typed_ruleset_key not in API_MODS + or mod["acronym"] not in API_MODS[typed_ruleset_key] + ): + raise InvokeException( + f"{context} {mod['acronym']} is invalid for this ruleset" + ) + + mod_settings = API_MODS[typed_ruleset_key][mod["acronym"]] + + # Check if mod is unplayable in multiplayer + if mod_settings.get("UserPlayable", True) is False: + raise InvokeException( + f"{context} {mod['acronym']} is not playable by users" + ) + + if mod_settings.get("ValidForMultiplayer", True) is False: + raise InvokeException( + f"{context} {mod['acronym']} is not valid for multiplayer" + ) + + def _check_mod_compatibility(self, mods: list[APIMod], ruleset_key: int) -> None: + from typing import Literal, cast + + API_MODS = self._get_api_mods() + typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key) + + for i, mod1 in enumerate(mods): + mod1_settings = API_MODS[typed_ruleset_key].get(mod1["acronym"]) + if mod1_settings: + incompatible = set(mod1_settings.get("IncompatibleMods", [])) + for mod2 in mods[i + 1 :]: + if mod2["acronym"] in incompatible: + raise InvokeException( + f"Mods {mod1['acronym']} and " + f"{mod2['acronym']} are incompatible" + ) + + def _check_required_allowed_compatibility(self, ruleset_key: int) -> None: + from typing import Literal, cast + + API_MODS = self._get_api_mods() + typed_ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_key) + allowed_acronyms = {mod["acronym"] for mod in self.allowed_mods} + + for req_mod in self.required_mods: + req_acronym = req_mod["acronym"] + req_settings = API_MODS[typed_ruleset_key].get(req_acronym) + if req_settings: + incompatible = set(req_settings.get("IncompatibleMods", [])) + conflicting_allowed = allowed_acronyms & incompatible + if conflicting_allowed: + conflict_list = ", ".join(conflicting_allowed) + raise InvokeException( + f"Required mod {req_acronym} conflicts with " + f"allowed mods: {conflict_list}" + ) + + def validate_playlist_item_mods(self) -> None: + ruleset_key = cast(Literal[0, 1, 2, 3], self.ruleset_id) + + # Validate required mods + for mod in self.required_mods: + self._validate_mod_for_ruleset(mod, ruleset_key, "Required mod") + + # Validate allowed mods + for mod in self.allowed_mods: + self._validate_mod_for_ruleset(mod, ruleset_key, "Allowed mod") + + # Check internal compatibility of required mods + self._check_mod_compatibility(self.required_mods, ruleset_key) + + # Check compatibility between required and allowed mods + self._check_required_allowed_compatibility(ruleset_key) + def validate_user_mods( self, user: "MultiplayerRoomUser", @@ -118,10 +209,7 @@ class PlaylistItem(BaseModel): """ from typing import Literal, cast - from app.models.mods import API_MODS, init_mods - - if not API_MODS: - init_mods() + API_MODS = self._get_api_mods() ruleset_id = user.ruleset_id if user.ruleset_id is not None else self.ruleset_id ruleset_key = cast(Literal[0, 1, 2, 3], ruleset_id) @@ -367,7 +455,8 @@ class MultiplayerQueue: raise InvokeException("Beatmap not found") if item.beatmap_checksum != beatmap.checksum: raise InvokeException("Checksum mismatch") - # TODO: mods validation + + item.validate_playlist_item_mods() item.owner_id = user.user_id item.star_rating = float( beatmap.difficulty_rating @@ -410,7 +499,7 @@ class MultiplayerQueue: "Attempted to change an item which has already been played" ) - # TODO: mods validation + item.validate_playlist_item_mods() item.owner_id = user.user_id item.star_rating = float(beatmap.difficulty_rating) item.playlist_order = existing_item.playlist_order diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index eb602fe..3be8024 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -64,6 +64,18 @@ class MultiplayerHub(Hub[MultiplayerClientState]): connection_token=client.connection_token, ) + @override + async def _clean_state(self, state: MultiplayerClientState): + user_id = int(state.connection_id) + if state.room_id != 0 and state.room_id in self.rooms: + server_room = self.rooms[state.room_id] + room = server_room.room + user = next((u for u in room.users if u.user_id == user_id), None) + if user is not None: + await self.make_user_leave( + self.get_client_by_id(str(user_id)), server_room, user + ) + async def CreateRoom(self, client: Client, room: MultiplayerRoom): logger.info(f"[MultiplayerHub] {client.user_id} creating room") store = self.get_or_create_state(client) @@ -554,8 +566,17 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException("You are not in this room") if room.host is None or room.host.user_id != client.user_id: raise InvokeException("You are not the host of this room") - if any(u.state != MultiplayerUserState.READY for u in room.users): - raise InvokeException("Not all users are ready") + + # Check host state - host must be ready or spectating + if room.host.state not in ( + MultiplayerUserState.SPECTATING, + MultiplayerUserState.READY, + ): + raise InvokeException("Can't start match when the host is not ready.") + + # Check if any users are ready + if all(u.state != MultiplayerUserState.READY for u in room.users): + raise InvokeException("Can't start match when no users are ready.") await self.start_match(server_room) @@ -646,7 +667,11 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if len(room.room.users) == 0: await self.end_room(room) await self.update_room_state(room) - if room.room.host and room.room.host.user_id == user.user_id: + if ( + len(room.room.users) != 0 + and room.room.host + and room.room.host.user_id == user.user_id + ): next_host = room.room.users[0] await self.set_host(room, next_host) @@ -710,6 +735,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if room.host is None or room.host.user_id != client.user_id: raise InvokeException("You are not the host of this room") + if user_id == client.user_id: + raise InvokeException("Can't kick self") + user = next((u for u in room.users if u.user_id == user_id), None) if user is None: raise InvokeException("User not found in this room") @@ -780,9 +808,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if ( room.state != MultiplayerRoomState.PLAYING - or room.state == MultiplayerRoomState.WAITING_FOR_LOAD + and room.state != MultiplayerRoomState.WAITING_FOR_LOAD ): - raise InvokeException("Room is not in a playable state") + raise InvokeException("Cannot abort a match that hasn't started.") await asyncio.gather( *[ From cfcf9ad03457da3cd3e7c8a92bcc8e37f5462812 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 4 Aug 2025 02:21:40 +0000 Subject: [PATCH 022/104] chore(mods): update mod definitions catch: add MF --- static/README.md | 2 +- static/mods.json | 27 ++++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/static/README.md b/static/README.md index 16ece63..77b54fe 100644 --- a/static/README.md +++ b/static/README.md @@ -2,4 +2,4 @@ - `mods.json`: 包含了游戏中的所有可用mod的详细信息。 - Origin: https://github.com/ppy/osu-web/blob/master/database/mods.json - - Version: 2025/6/10 `b68c920b1db3d443b9302fdc3f86010c875fe380` + - Version: 2025/7/30 `ff49b66b27a2850aea4b6b3ba563cfe936cb6082` diff --git a/static/mods.json b/static/mods.json index defb57f..0a8449b 100644 --- a/static/mods.json +++ b/static/mods.json @@ -2438,7 +2438,8 @@ "Settings": [], "IncompatibleMods": [ "CN", - "RX" + "RX", + "MF" ], "RequiresConfiguration": false, "UserPlayable": false, @@ -2460,7 +2461,8 @@ "AC", "AT", "CN", - "RX" + "RX", + "MF" ], "RequiresConfiguration": false, "UserPlayable": false, @@ -2477,7 +2479,8 @@ "Settings": [], "IncompatibleMods": [ "AT", - "CN" + "CN", + "MF" ], "RequiresConfiguration": false, "UserPlayable": true, @@ -2638,6 +2641,24 @@ "ValidForMultiplayerAsFreeMod": true, "AlwaysValidForSubmission": false }, + { + "Acronym": "MF", + "Name": "Moving Fast", + "Description": "Dashing by default, slow down!", + "Type": "Fun", + "Settings": [], + "IncompatibleMods": [ + "AT", + "CN", + "RX" + ], + "RequiresConfiguration": false, + "UserPlayable": true, + "ValidForMultiplayer": true, + "ValidForFreestyleAsRequiredMod": false, + "ValidForMultiplayerAsFreeMod": true, + "AlwaysValidForSubmission": false + }, { "Acronym": "SV2", "Name": "Score V2", From 082883599e3a743c18231976bc8078fc08b84803 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Tue, 5 Aug 2025 07:29:41 +0000 Subject: [PATCH 023/104] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0gitignore,?= =?UTF-8?q?=E6=96=B9=E4=BE=BF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 369e759..05622b7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports +test-cert/ htmlcov/ .tox/ .nox/ From 0988f1fc0c40350030ec746ab9216fe77c10e7cc Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Tue, 5 Aug 2025 16:17:33 +0000 Subject: [PATCH 024/104] feat(multiplayer): partital support for multiplayer rooms' filtering --- app/router/room.py | 79 +++++++++++++--------------------------------- 1 file changed, 22 insertions(+), 57 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index ba909c6..cfaaf56 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -2,9 +2,11 @@ from __future__ import annotations from typing import Literal +from app.database.lazer_user import User from app.database.room import RoomResp from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher +from app.dependencies.user import get_current_user from app.fetcher import Fetcher from app.models.room import RoomStatus from app.signalr.hub import MultiplayerHubs @@ -19,68 +21,31 @@ from sqlmodel.ext.asyncio.session import AsyncSession @router.get("/rooms", tags=["rooms"], response_model=list[RoomResp]) async def get_all_rooms( mode: Literal["open", "ended", "participated", "owned", None] = Query( - None + default="open" ), # TODO: 对房间根据状态进行筛选 category: str = Query(default="realtime"), # TODO status: RoomStatus | None = Query(None), db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), redis: Redis = Depends(get_redis), + current_user: User = Depends(get_current_user), ): rooms = MultiplayerHubs.rooms.values() - return [await RoomResp.from_hub(room) for room in rooms] - - -# @router.get("/rooms/{room}", tags=["room"], response_model=Room) -# async def get_room( -# room: int, -# db: AsyncSession = Depends(get_db), -# fetcher: Fetcher = Depends(get_fetcher), -# ): -# redis = get_redis() -# if redis: -# dumped_room = str(redis.get(str(room))) -# if dumped_room is not None: -# resp = await Room.from_mpRoom( -# MultiplayerRoom.model_validate_json(str(dumped_room)), db, fetcher -# ) -# return resp -# else: -# raise HTTPException(status_code=404, detail="Room Not Found") -# else: -# raise HTTPException(status_code=500, detail="Redis error") - - -# class APICreatedRoom(Room): -# error: str | None - - -# @router.post("/rooms", tags=["beatmap"], response_model=APICreatedRoom) -# async def create_room( -# room: Room, -# db: AsyncSession = Depends(get_db), -# fetcher: Fetcher = Depends(get_fetcher), -# ): -# redis = get_redis() -# if redis: -# room_index = RoomIndex() -# db.add(room_index) -# await db.commit() -# await db.refresh(room_index) -# server_room = await MultiplayerRoom.from_apiRoom(room, db, fetcher) -# redis.set(str(room_index.id), server_room.model_dump_json()) -# room.room_id = room_index.id -# return APICreatedRoom(**room.model_dump(), error=None) -# else: -# raise HTTPException(status_code=500, detail="redis error") - - -# @router.delete("/rooms/{room}", tags=["room"]) -# async def remove_room(room: int, db: AsyncSession = Depends(get_db)): -# redis = get_redis() -# if redis: -# redis.delete(str(room)) -# room_index = await db.get(RoomIndex, room) -# if room_index: -# await db.delete(room_index) -# await db.commit() + resp_list: list[RoomResp] = [] + for room in rooms: + if category != "realtime": # 歌单模式的处理逻辑 + if room.category == category: + if mode == "owned": + if ( + room.room.host.user_id if room.room.host is not None else 0 + ) != current_user.id: + continue + else: + if ( + room.room.host.user_id if room.room.host is not None else 0 + ) != current_user.id: + continue + if room.status != status: + continue + resp_list.append(await RoomResp.from_hub(room)) + return resp_list From 0a80c5051cb7e5c1651322c174466474ebd8c7ea Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Tue, 5 Aug 2025 17:21:45 +0000 Subject: [PATCH 025/104] feat(multiplayer): support countdown --- app/models/metadata_hub.py | 15 ++++------ app/models/multiplayer_hub.py | 42 +++++++++++++-------------- app/models/signalr.py | 1 - app/signalr/hub/multiplayer.py | 52 ++++++++++++++++++++++++++++++---- app/signalr/packet.py | 39 ++++++++++++++----------- app/utils.py | 38 +++++++++++++++++++++++-- 6 files changed, 131 insertions(+), 56 deletions(-) diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index a678d7f..684ab54 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -1,11 +1,11 @@ from __future__ import annotations from enum import IntEnum -from typing import Annotated, ClassVar, Literal +from typing import ClassVar, Literal -from app.models.signalr import SignalRMeta, SignalRUnionMessage, UserState +from app.models.signalr import SignalRUnionMessage, UserState -from pydantic import BaseModel, Field +from pydantic import BaseModel class _UserActivity(SignalRUnionMessage): ... @@ -100,12 +100,9 @@ UserActivity = ( class UserPresence(BaseModel): - activity: Annotated[ - UserActivity | None, Field(default=None), SignalRMeta(use_upper_case=True) - ] - status: Annotated[ - OnlineStatus | None, Field(default=None), SignalRMeta(use_upper_case=True) - ] + activity: UserActivity | None = None + + status: OnlineStatus | None = None @property def pushable(self) -> bool: diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index e2f4edf..9d78282 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -53,10 +53,14 @@ class MultiplayerRoomSettings(BaseModel): auto_start_duration: timedelta = timedelta(seconds=0) auto_skip: bool = False + @property + def auto_start_enabled(self) -> bool: + return self.auto_start_duration != timedelta(seconds=0) + class BeatmapAvailability(BaseModel): state: DownloadState = DownloadState.UNKNOWN - progress: float | None = None + download_progress: float | None = None class _MatchUserState(SignalRUnionMessage): ... @@ -283,10 +287,12 @@ class PlaylistItem(BaseModel): return copy -class _MultiplayerCountdown(BaseModel): +class _MultiplayerCountdown(SignalRUnionMessage): id: int = 0 - remaining: timedelta - is_exclusive: bool = False + time_remaining: timedelta + is_exclusive: Annotated[ + bool, Field(default=True), SignalRMeta(member_ignore=True) + ] = True class MatchStartCountdown(_MultiplayerCountdown): @@ -310,7 +316,7 @@ class MultiplayerRoomUser(BaseModel): user_id: int state: MultiplayerUserState = MultiplayerUserState.IDLE availability: BeatmapAvailability = BeatmapAvailability( - state=DownloadState.UNKNOWN, progress=None + state=DownloadState.UNKNOWN, download_progress=None ) mods: list[APIMod] = Field(default_factory=list) match_state: MatchUserState | None = None @@ -602,8 +608,8 @@ class CountdownInfo: def __init__(self, countdown: MultiplayerCountdown): self.countdown = countdown self.duration = ( - countdown.remaining - if countdown.remaining > timedelta(seconds=0) + countdown.time_remaining + if countdown.time_remaining > timedelta(seconds=0) else timedelta(seconds=0) ) @@ -776,13 +782,12 @@ class ServerMultiplayerRoom: ): async def _countdown_task(self: "ServerMultiplayerRoom"): await asyncio.sleep(info.duration.total_seconds()) - await self.stop_countdown(countdown) if on_complete is not None: await on_complete(self) + await self.stop_countdown(countdown) if countdown.is_exclusive: await self.stop_all_countdowns() - countdown.id = await self.get_next_countdown_id() info = CountdownInfo(countdown) self.room.active_countdowns.append(info.countdown) @@ -793,21 +798,14 @@ class ServerMultiplayerRoom: info.task = asyncio.create_task(_countdown_task(self)) async def stop_countdown(self, countdown: MultiplayerCountdown): - info = next( - ( - info - for info in self._tracked_countdown.values() - if info.countdown.id == countdown.id - ), - None, - ) + info = self._tracked_countdown.get(countdown.id) if info is None: return - if info.task is not None and not info.task.done(): - info.task.cancel() del self._tracked_countdown[countdown.id] self.room.active_countdowns.remove(countdown) await self.hub.send_match_event(self, CountdownStoppedEvent(id=countdown.id)) + if info.task is not None and not info.task.done(): + info.task.cancel() async def stop_all_countdowns(self): for countdown in list(self._tracked_countdown.values()): @@ -817,19 +815,19 @@ class ServerMultiplayerRoom: self.room.active_countdowns.clear() -class _MatchServerEvent(BaseModel): ... +class _MatchServerEvent(SignalRUnionMessage): ... class CountdownStartedEvent(_MatchServerEvent): countdown: MultiplayerCountdown - type: Literal[0] = Field(default=0, exclude=True) + union_type: ClassVar[Literal[0]] = 0 class CountdownStoppedEvent(_MatchServerEvent): id: int - type: Literal[1] = Field(default=1, exclude=True) + union_type: ClassVar[Literal[1]] = 1 MatchServerEvent = CountdownStartedEvent | CountdownStoppedEvent diff --git a/app/models/signalr.py b/app/models/signalr.py index 7116ea0..ffbaf6b 100644 --- a/app/models/signalr.py +++ b/app/models/signalr.py @@ -13,7 +13,6 @@ from pydantic import ( class SignalRMeta: member_ignore: bool = False # implement of IgnoreMember (msgpack) attribute json_ignore: bool = False # implement of JsonIgnore (json) attribute - use_upper_case: bool = False # use upper CamelCase for field names use_abbr: bool = True diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 3be8024..ef3dfcd 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -19,12 +19,14 @@ from app.models.multiplayer_hub import ( GameplayAbortReason, MatchRequest, MatchServerEvent, + MatchStartCountdown, MultiplayerClientState, MultiplayerRoom, MultiplayerRoomSettings, MultiplayerRoomUser, PlaylistItem, ServerMultiplayerRoom, + ServerShuttingDownCountdown, StartMatchCountdownRequest, StopCountdownRequest, ) @@ -160,7 +162,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): availability = user.availability if ( availability.state == beatmap_availability.state - and availability.progress == beatmap_availability.progress + and availability.download_progress == beatmap_availability.download_progress ): return user.availability = beatmap_availability @@ -512,6 +514,25 @@ class MultiplayerHub(Hub[MultiplayerClientState]): async def update_room_state(self, room: ServerMultiplayerRoom): match room.room.state: + case MultiplayerRoomState.OPEN: + if room.room.settings.auto_start_enabled: + if ( + not room.queue.current_item.expired + and any( + u.state == MultiplayerUserState.READY + for u in room.room.users + ) + and not any( + isinstance(countdown, MatchStartCountdown) + for countdown in room.room.active_countdowns + ) + ): + await room.start_countdown( + MatchStartCountdown( + time_remaining=room.room.settings.auto_start_duration + ), + self.start_match, + ) case MultiplayerRoomState.WAITING_FOR_LOAD: played_count = len( [True for user in room.room.users if user.state.is_playing] @@ -610,7 +631,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) await room.start_countdown( ForceGameplayStartCountdown( - remaining=timedelta(seconds=GAMEPLAY_LOAD_TIMEOUT) + time_remaining=timedelta(seconds=GAMEPLAY_LOAD_TIMEOUT) ), self.start_gameplay, ) @@ -885,15 +906,34 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException("You are not in this room") if isinstance(request, StartMatchCountdownRequest): - # TODO: countdown - ... + if room.host and room.host.user_id != user.user_id: + raise InvokeException("You are not the host of this room") + if room.state != MultiplayerRoomState.OPEN: + raise InvokeException("Cannot start a countdown during ongoing play") + await server_room.start_countdown( + MatchStartCountdown(time_remaining=request.duration), + self.start_match, + ) elif isinstance(request, StopCountdownRequest): - ... + countdown = next( + (c for c in room.active_countdowns if c.id == request.id), + None, + ) + if countdown is None: + return + if ( + isinstance(countdown, MatchStartCountdown) + and room.settings.auto_start_enabled + ) or isinstance( + countdown, (ForceGameplayStartCountdown | ServerShuttingDownCountdown) + ): + raise InvokeException("Cannot stop the requested countdown") + + await server_room.stop_countdown(countdown) else: await server_room.match_type_handler.handle_request(user, request) async def InvitePlayer(self, client: Client, user_id: int): - print(f"Inviting player... {client.user_id} {user_id}") store = self.get_or_create_state(client) if store.room_id == 0: raise InvokeException("You are not in a room") diff --git a/app/signalr/packet.py b/app/signalr/packet.py index 9afb78d..09a36bd 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -15,7 +15,7 @@ from typing import ( ) from app.models.signalr import SignalRMeta, SignalRUnionMessage -from app.utils import camel_to_snake, snake_to_camel +from app.utils import camel_to_snake, snake_to_camel, snake_to_pascal import msgpack_lazer_api as m from pydantic import BaseModel @@ -98,7 +98,7 @@ class MsgpackProtocol: elif issubclass(typ, datetime.datetime): return [v, 0] elif issubclass(typ, datetime.timedelta): - return int(v.total_seconds()) + return int(v.total_seconds() * 10_000_000) elif isinstance(v, dict): return { cls.serialize_msgpack(k): cls.serialize_msgpack(value) @@ -216,8 +216,8 @@ class MsgpackProtocol: elif inspect.isclass(typ) and issubclass(typ, datetime.datetime): return v[0] elif inspect.isclass(typ) and issubclass(typ, datetime.timedelta): - return datetime.timedelta(seconds=int(v)) - elif isinstance(v, list): + return datetime.timedelta(seconds=int(v / 10_000_000)) + elif get_origin(typ) is list: return [cls.validate_object(item, get_args(typ)[0]) for item in v] elif inspect.isclass(typ) and issubclass(typ, Enum): list_ = list(typ) @@ -300,10 +300,10 @@ class MsgpackProtocol: class JSONProtocol: @classmethod - def serialize_to_json(cls, v: Any, dict_key: bool = False): + def serialize_to_json(cls, v: Any, dict_key: bool = False, in_union: bool = False): typ = v.__class__ if issubclass(typ, BaseModel): - return cls.serialize_model(v) + return cls.serialize_model(v, in_union) elif isinstance(v, dict): return { cls.serialize_to_json(k, True): cls.serialize_to_json(value) @@ -327,22 +327,28 @@ class JSONProtocol: return v @classmethod - def serialize_model(cls, v: BaseModel) -> dict[str, Any]: + def serialize_model(cls, v: BaseModel, in_union: bool = False) -> dict[str, Any]: d = {} + is_union = issubclass(v.__class__, SignalRUnionMessage) for field, info in v.__class__.model_fields.items(): metadata = next( (m for m in info.metadata if isinstance(m, SignalRMeta)), None ) if metadata and metadata.json_ignore: continue - d[ + name = ( snake_to_camel( field, - metadata.use_upper_case if metadata else False, metadata.use_abbr if metadata else True, ) - ] = cls.serialize_to_json(getattr(v, field)) - if issubclass(v.__class__, SignalRUnionMessage): + if not is_union + else snake_to_pascal( + field, + metadata.use_abbr if metadata else True, + ) + ) + d[name] = cls.serialize_to_json(getattr(v, field), in_union=is_union) + if is_union and not in_union: return { "$dtype": v.__class__.__name__, "$value": d, @@ -360,11 +366,12 @@ class JSONProtocol: ) if metadata and metadata.json_ignore: continue - value = v.get( - snake_to_camel( - field, not from_union, metadata.use_abbr if metadata else True - ) + name = ( + snake_to_camel(field, metadata.use_abbr if metadata else True) + if not from_union + else snake_to_pascal(field, metadata.use_abbr if metadata else True) ) + value = v.get(name) anno = typ.model_fields[field].annotation if anno is None: d[field] = value @@ -433,7 +440,7 @@ class JSONProtocol: return datetime.timedelta(minutes=int(parts[0]), seconds=int(parts[1])) elif len(parts) == 1: return datetime.timedelta(seconds=int(parts[0])) - elif isinstance(v, list): + elif get_origin(typ) is list: return [cls.validate_object(item, get_args(typ)[0]) for item in v] elif inspect.isclass(typ) and issubclass(typ, Enum): list_ = list(typ) diff --git a/app/utils.py b/app/utils.py index ac51b90..22f06dd 100644 --- a/app/utils.py +++ b/app/utils.py @@ -21,7 +21,7 @@ def camel_to_snake(name: str) -> str: return "".join(result) -def snake_to_camel(name: str, lower_case: bool = True, use_abbr: bool = True) -> str: +def snake_to_camel(name: str, use_abbr: bool = True) -> str: """Convert a snake_case string to camelCase.""" if not name: return name @@ -50,9 +50,43 @@ def snake_to_camel(name: str, lower_case: bool = True, use_abbr: bool = True) -> if part.lower() in abbreviations and use_abbr: result.append(part.upper()) else: - if result or not lower_case: + if result: result.append(part.capitalize()) else: result.append(part.lower()) return "".join(result) + + +def snake_to_pascal(name: str, use_abbr: bool = True) -> str: + """Convert a snake_case string to PascalCase.""" + if not name: + return name + + parts = name.split("_") + if not parts: + return name + + # 常见缩写词列表 + abbreviations = { + "id", + "url", + "api", + "http", + "https", + "xml", + "json", + "css", + "html", + "sql", + "db", + } + + result = [] + for part in parts: + if part.lower() in abbreviations and use_abbr: + result.append(part.upper()) + else: + result.append(part.capitalize()) + + return "".join(result) From 2b4d366e3e8160f28fcfd9f7029d417f2942b1c6 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Tue, 5 Aug 2025 17:21:53 +0000 Subject: [PATCH 026/104] fix(score): remove foreign key to fix missing index error --- app/database/best_score.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/database/best_score.py b/app/database/best_score.py index 42b0024..8688d5b 100644 --- a/app/database/best_score.py +++ b/app/database/best_score.py @@ -29,9 +29,7 @@ class BestScore(SQLModel, table=True): ) beatmap_id: int = Field(foreign_key="beatmaps.id", index=True) gamemode: GameMode = Field(index=True) - total_score: int = Field( - default=0, sa_column=Column(BigInteger, ForeignKey("scores.total_score")) - ) + total_score: int = Field(default=0, sa_column=Column(BigInteger)) mods: list[str] = Field( default_factory=list, sa_column=Column(JSON), From 84dac34a05ec3270ce3c86549ae7a3ff4746216a Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Wed, 6 Aug 2025 06:55:45 +0000 Subject: [PATCH 027/104] fix(multiplayer): fix fliters --- app/router/room.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index cfaaf56..476eaf2 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -40,11 +40,14 @@ async def get_all_rooms( room.room.host.user_id if room.room.host is not None else 0 ) != current_user.id: continue - else: - if ( - room.room.host.user_id if room.room.host is not None else 0 - ) != current_user.id: + else: continue + else: + if mode == "owned": + if ( + room.room.host.user_id if room.room.host is not None else 0 + ) != current_user.id: + continue if room.status != status: continue resp_list.append(await RoomResp.from_hub(room)) From 87bb74d1caa0affba79b2f32e9fed22bc147ba19 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Wed, 6 Aug 2025 10:51:37 +0000 Subject: [PATCH 028/104] feat(multiplayer): support leaderboard --- app/database/__init__.py | 6 + app/database/playlist_best_score.py | 107 +++++++++++ app/database/playlists.py | 2 +- app/database/score.py | 19 +- app/models/model.py | 7 + app/router/score.py | 177 +++++++++++++++++- app/signalr/hub/multiplayer.py | 11 +- ...d0c1b2cefe91_playlist_index_playlist_id.py | 89 +++++++++ 8 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 app/database/playlist_best_score.py create mode 100644 migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 2c01f7a..b3e65f9 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -16,12 +16,15 @@ from .lazer_user import ( UserResp, ) from .playlist_attempts import ItemAttemptsCount +from .playlist_best_score import PlaylistBestScore from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore from .relationship import Relationship, RelationshipResp, RelationshipType from .room import Room, RoomResp from .score import ( + MultiplayerScores, Score, + ScoreAround, ScoreBase, ScoreResp, ScoreStatistics, @@ -47,9 +50,11 @@ __all__ = [ "DailyChallengeStatsResp", "FavouriteBeatmapset", "ItemAttemptsCount", + "MultiplayerScores", "OAuthToken", "PPBestScore", "Playlist", + "PlaylistBestScore", "PlaylistResp", "Relationship", "RelationshipResp", @@ -57,6 +62,7 @@ __all__ = [ "Room", "RoomResp", "Score", + "ScoreAround", "ScoreBase", "ScoreResp", "ScoreStatistics", diff --git a/app/database/playlist_best_score.py b/app/database/playlist_best_score.py new file mode 100644 index 0000000..49fb459 --- /dev/null +++ b/app/database/playlist_best_score.py @@ -0,0 +1,107 @@ +from typing import TYPE_CHECKING + +from .lazer_user import User + +from redis.asyncio import Redis +from sqlmodel import ( + BigInteger, + Column, + Field, + ForeignKey, + Relationship, + SQLModel, + col, + func, + select, +) +from sqlmodel.ext.asyncio.session import AsyncSession + +if TYPE_CHECKING: + from .score import Score + + +class PlaylistBestScore(SQLModel, table=True): + __tablename__ = "playlist_best_scores" # pyright: ignore[reportAssignmentType] + + user_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) + ) + score_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("scores.id"), primary_key=True) + ) + room_id: int = Field(foreign_key="rooms.id", index=True) + playlist_id: int = Field(foreign_key="room_playlists.id", index=True) + total_score: int = Field(default=0, sa_column=Column(BigInteger)) + + user: User = Relationship() + score: "Score" = Relationship( + sa_relationship_kwargs={ + "foreign_keys": "[PlaylistBestScore.score_id]", + "lazy": "joined", + } + ) + + +async def process_playlist_best_score( + room_id: int, + playlist_id: int, + user_id: int, + score_id: int, + total_score: int, + session: AsyncSession, + redis: Redis, +): + previous = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.room_id == room_id, + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.user_id == user_id, + ) + ) + ).first() + if previous is None: + score = PlaylistBestScore( + user_id=user_id, + score_id=score_id, + room_id=room_id, + playlist_id=playlist_id, + total_score=total_score, + ) + session.add(score) + else: + previous.score_id = score_id + previous.total_score = total_score + await session.commit() + await redis.decr(f"multiplayer:{room_id}:gameplay:players") + + +async def get_position( + room_id: int, + playlist_id: int, + score_id: int, + session: AsyncSession, +) -> int: + rownum = ( + func.row_number() + .over( + partition_by=( + col(PlaylistBestScore.playlist_id), + col(PlaylistBestScore.room_id), + ), + order_by=col(PlaylistBestScore.total_score).desc(), + ) + .label("row_number") + ) + subq = ( + select(PlaylistBestScore, rownum) + .where( + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.room_id == room_id, + ) + .subquery() + ) + stmt = select(subq.c.row_number).where(subq.c.score_id == score_id) + result = await session.exec(stmt) + s = result.one_or_none() + return s if s else 0 diff --git a/app/database/playlists.py b/app/database/playlists.py index 328f17d..432c3b0 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: class PlaylistBase(SQLModel, UTCBaseModel): - id: int = 0 + id: int = Field(index=True) owner_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"))) ruleset_id: int = Field(ge=0, le=3) expired: bool = Field(default=False) diff --git a/app/database/score.py b/app/database/score.py index abc3d75..37b96a3 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from datetime import UTC, date, datetime import json import math -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from app.calculator import ( calculate_pp, @@ -14,7 +14,7 @@ from app.calculator import ( clamp, ) from app.database.team import TeamMember -from app.models.model import UTCBaseModel +from app.models.model import RespWithCursor, UTCBaseModel from app.models.mods import APIMod, mods_can_get_pp from app.models.score import ( INT_TO_MODE, @@ -88,6 +88,7 @@ class ScoreBase(AsyncAttrs, SQLModel, UTCBaseModel): default=0, sa_column=Column(BigInteger), exclude=True ) type: str + beatmap_id: int = Field(index=True, foreign_key="beatmaps.id") # optional # TODO: current_user_attributes @@ -99,7 +100,6 @@ class Score(ScoreBase, table=True): id: int | None = Field( default=None, sa_column=Column(BigInteger, autoincrement=True, primary_key=True) ) - beatmap_id: int = Field(index=True, foreign_key="beatmaps.id") user_id: int = Field( default=None, sa_column=Column( @@ -162,7 +162,8 @@ class ScoreResp(ScoreBase): maximum_statistics: ScoreStatistics | None = None rank_global: int | None = None rank_country: int | None = None - position: int = 1 # TODO + position: int | None = None + scores_around: "ScoreAround | None" = None @classmethod async def from_db(cls, session: AsyncSession, score: Score) -> "ScoreResp": @@ -234,6 +235,16 @@ class ScoreResp(ScoreBase): return s +class MultiplayerScores(RespWithCursor): + scores: list[ScoreResp] = Field(default_factory=list) + params: dict[str, Any] = Field(default_factory=dict) + + +class ScoreAround(SQLModel): + higher: MultiplayerScores | None = None + lower: MultiplayerScores | None = None + + async def get_best_id(session: AsyncSession, score_id: int) -> None: rownum = ( func.row_number() diff --git a/app/models/model.py b/app/models/model.py index bc00585..5ba8093 100644 --- a/app/models/model.py +++ b/app/models/model.py @@ -13,3 +13,10 @@ class UTCBaseModel(BaseModel): v = v.replace(tzinfo=UTC) return v.astimezone(UTC).isoformat() return v + + +Cursor = dict[str, int] + + +class RespWithCursor(BaseModel): + cursor: Cursor | None = None diff --git a/app/router/score.py b/app/router/score.py index b50911d..818155d 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -1,5 +1,8 @@ from __future__ import annotations +import time + +from app.calculator import clamp from app.database import ( Beatmap, Playlist, @@ -9,7 +12,18 @@ from app.database import ( ScoreTokenResp, User, ) -from app.database.score import get_leaderboard, process_score, process_user +from app.database.playlist_best_score import ( + PlaylistBestScore, + get_position, + process_playlist_best_score, +) +from app.database.score import ( + MultiplayerScores, + ScoreAround, + get_leaderboard, + process_score, + process_user, +) from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user @@ -33,6 +47,8 @@ from sqlalchemy.orm import joinedload from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession +READ_SCORE_TIMEOUT = 10 + async def submit_score( info: SoloScoreSubmissionInfo, @@ -337,4 +353,163 @@ async def submit_playlist_score( item.id, room_id, ) + await process_playlist_best_score( + room_id, + playlist_id, + current_user.id, + score_resp.id, + score_resp.total_score, + session, + redis, + ) return score_resp + + +class IndexedScoreResp(MultiplayerScores): + total: int + user_score: ScoreResp | None = None + + +@router.get( + "/rooms/{room_id}/playlist/{playlist_id}/scores", response_model=IndexedScoreResp +) +async def index_playlist_scores( + room_id: int, + playlist_id: int, + limit: int = 50, + cursor: int = Query(2000000, alias="cursor[total_score]"), + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +): + limit = clamp(limit, 1, 50) + + scores = ( + await session.exec( + select(PlaylistBestScore) + .where( + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.room_id == room_id, + PlaylistBestScore.total_score < cursor, + ) + .order_by(col(PlaylistBestScore.total_score).desc()) + .limit(limit + 1) + ) + ).all() + has_more = len(scores) > limit + if has_more: + scores = scores[:-1] + + user_score = None + score_resp = [await ScoreResp.from_db(session, score.score) for score in scores] + for score in score_resp: + score.position = await get_position(room_id, playlist_id, score.id, session) + if score.user_id == current_user.id: + user_score = score + resp = IndexedScoreResp( + scores=score_resp, + user_score=user_score, + total=len(scores), + params={ + "limit": limit, + }, + ) + if has_more: + resp.cursor = { + "total_score": scores[-1].total_score, + } + return resp + + +@router.get( + "/rooms/{room_id}/playlist/{playlist_id}/scores/{score_id}", + response_model=ScoreResp, +) +async def show_playlist_score( + room_id: int, + playlist_id: int, + score_id: int, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), +): + start_time = time.time() + score_record = None + completed = False + while time.time() - start_time < READ_SCORE_TIMEOUT: + if score_record is None: + score_record = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.score_id == score_id, + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.room_id == room_id, + ) + ) + ).first() + if completed_players := await redis.get( + f"multiplayer:{room_id}:gameplay:players" + ): + completed = completed_players == "0" + if score_record and completed: + break + if not score_record: + raise HTTPException(status_code=404, detail="Score not found") + resp = await ScoreResp.from_db(session, score_record.score) + resp.position = await get_position(room_id, playlist_id, score_id, session) + if completed: + scores = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.room_id == room_id, + ) + ) + ).all() + higher_scores = [] + lower_scores = [] + for score in scores: + if score.total_score > resp.total_score: + higher_scores.append(await ScoreResp.from_db(session, score.score)) + elif score.total_score < resp.total_score: + lower_scores.append(await ScoreResp.from_db(session, score.score)) + resp.scores_around = ScoreAround( + higher=MultiplayerScores(scores=higher_scores), + lower=MultiplayerScores(scores=lower_scores), + ) + + return resp + + +@router.get( + "rooms/{room_id}/playlist/{playlist_id}/scores/users/{user_id}", + response_model=ScoreResp, +) +async def get_user_playlist_score( + room_id: int, + playlist_id: int, + user_id: int, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +): + score_record = None + start_time = time.time() + while time.time() - start_time < READ_SCORE_TIMEOUT: + score_record = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.user_id == user_id, + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.room_id == room_id, + ) + ) + ).first() + if score_record: + break + if not score_record: + raise HTTPException(status_code=404, detail="Score not found") + + resp = await ScoreResp.from_db(session, score_record.score) + resp.position = await get_position( + room_id, playlist_id, score_record.score_id, session + ) + return resp diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index ef3dfcd..af28d26 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -9,7 +9,7 @@ from app.database.beatmap import Beatmap from app.database.lazer_user import User from app.database.playlists import Playlist from app.database.relationship import Relationship, RelationshipType -from app.dependencies.database import engine +from app.dependencies.database import engine, get_redis from app.exception import InvokeException from app.log import logger from app.models.mods import APIMod @@ -642,6 +642,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if room.queue.current_item.expired: raise InvokeException("Current playlist item is expired") playing = False + played_user = 0 for user in room.room.users: client = self.get_client_by_id(str(user.user_id)) if client is None: @@ -652,6 +653,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): MultiplayerUserState.LOADED, ): playing = True + played_user += 1 await self.change_user_state(room, user, MultiplayerUserState.PLAYING) await self.call_noblock(client, "GameplayStarted") elif user.state == MultiplayerUserState.WAITING_FOR_LOAD: @@ -665,6 +667,13 @@ class MultiplayerHub(Hub[MultiplayerClientState]): room, (MultiplayerRoomState.PLAYING if playing else MultiplayerRoomState.OPEN), ) + if playing: + redis = get_redis() + await redis.set( + f"multiplayer:{room.room.room_id}:gameplay:players", + played_user, + ex=3600, + ) async def send_match_event( self, room: ServerMultiplayerRoom, event: MatchServerEvent diff --git a/migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py b/migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py new file mode 100644 index 0000000..74f2e56 --- /dev/null +++ b/migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py @@ -0,0 +1,89 @@ +"""playlist: index playlist id + +Revision ID: d0c1b2cefe91 +Revises: 58a11441d302 +Create Date: 2025-08-06 06:02:10.512616 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "d0c1b2cefe91" +down_revision: str | Sequence[str] | None = "58a11441d302" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_index( + op.f("ix_room_playlists_id"), "room_playlists", ["id"], unique=False + ) + op.create_table( + "playlist_best_scores", + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("score_id", sa.BigInteger(), nullable=False), + sa.Column("room_id", sa.Integer(), nullable=False), + sa.Column("playlist_id", sa.Integer(), nullable=False), + sa.Column("total_score", sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint( + ["playlist_id"], + ["room_playlists.id"], + ), + sa.ForeignKeyConstraint( + ["room_id"], + ["rooms.id"], + ), + sa.ForeignKeyConstraint( + ["score_id"], + ["scores.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("score_id"), + ) + op.create_index( + op.f("ix_playlist_best_scores_playlist_id"), + "playlist_best_scores", + ["playlist_id"], + unique=False, + ) + op.create_index( + op.f("ix_playlist_best_scores_room_id"), + "playlist_best_scores", + ["room_id"], + unique=False, + ) + op.create_index( + op.f("ix_playlist_best_scores_user_id"), + "playlist_best_scores", + ["user_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_playlist_best_scores_user_id"), table_name="playlist_best_scores" + ) + op.drop_index( + op.f("ix_playlist_best_scores_room_id"), table_name="playlist_best_scores" + ) + op.drop_index( + op.f("ix_playlist_best_scores_playlist_id"), table_name="playlist_best_scores" + ) + op.drop_table("playlist_best_scores") + op.drop_index(op.f("ix_room_playlists_id"), table_name="room_playlists") + # ### end Alembic commands ### From 47d02e4e9c94d30e4cbe9fe3caba1ea577117023 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Thu, 7 Aug 2025 06:28:07 +0000 Subject: [PATCH 029/104] feat(room): add POST /room API --- app/database/room.py | 2 +- app/models/multiplayer_hub.py | 52 ++++++++++++++++++++ app/router/room.py | 86 +++++++++++++++++++++++++++------- app/signalr/hub/multiplayer.py | 2 +- 4 files changed, 122 insertions(+), 20 deletions(-) diff --git a/app/database/room.py b/app/database/room.py index 80457b6..08f1466 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -29,7 +29,7 @@ class RoomBase(SQLModel): name: str = Field(index=True) category: RoomCategory = Field(default=RoomCategory.NORMAL, index=True) duration: int | None = Field(default=None) # minutes - starts_at: datetime = Field( + starts_at: datetime | None = Field( sa_column=Column( DateTime(timezone=True), ), diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 9d78282..ed37b98 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -335,6 +335,58 @@ class MultiplayerRoom(BaseModel): active_countdowns: list[MultiplayerCountdown] = Field(default_factory=list) channel_id: int + @classmethod + def from_db(cls, room) -> "MultiplayerRoom": + """ + 将 Room (数据库模型) 转换为 MultiplayerRoom (业务模型) + """ + + # 用户列表 + users = [MultiplayerRoomUser(user_id=room.host_id)] + host_user = MultiplayerRoomUser(user_id=room.host_id) + # playlist 转换 + playlist = [] + if hasattr(room, "playlist"): + for item in room.playlist: + playlist.append( + PlaylistItem( + id=item.id, + owner_id=item.owner_id, + beatmap_id=item.beatmap_id, + beatmap_checksum=item.beatmap.checksum if item.beatmap else "", + ruleset_id=item.ruleset_id, + required_mods=item.required_mods, + allowed_mods=item.allowed_mods, + expired=item.expired, + playlist_order=item.playlist_order, + played_at=item.played_at, + star_rating=item.beatmap.difficulty_rating + if item.beatmap is not None + else 0.0, + freestyle=item.freestyle, + ) + ) + + return cls( + room_id=room.id, + state=getattr(room, "state", MultiplayerRoomState.OPEN), + settings=MultiplayerRoomSettings( + name=room.name, + playlist_item_id=playlist[0].id if playlist else 0, + password=getattr(room, "password", ""), + match_type=room.type, + queue_mode=room.queue_mode, + auto_start_duration=timedelta(seconds=room.auto_start_duration), + auto_skip=room.auto_skip, + ), + users=users, + host=host_user, + match_state=None, + playlist=playlist, + active_countdowns=[], + channel_id=getattr(room, "channel_id", 0), + ) + class MultiplayerQueue: def __init__(self, room: "ServerMultiplayerRoom"): diff --git a/app/router/room.py b/app/router/room.py index 476eaf2..800c861 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -1,13 +1,17 @@ from __future__ import annotations +from datetime import UTC, datetime +from time import timezone from typing import Literal from app.database.lazer_user import User -from app.database.room import RoomResp +from app.database.playlists import Playlist +from app.database.room import Room, RoomBase, RoomResp from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher +from app.models.multiplayer_hub import MultiplayerRoom, ServerMultiplayerRoom from app.models.room import RoomStatus from app.signalr.hub import MultiplayerHubs @@ -33,22 +37,68 @@ async def get_all_rooms( rooms = MultiplayerHubs.rooms.values() resp_list: list[RoomResp] = [] for room in rooms: - if category != "realtime": # 歌单模式的处理逻辑 - if room.category == category: - if mode == "owned": - if ( - room.room.host.user_id if room.room.host is not None else 0 - ) != current_user.id: - continue - else: - continue - else: - if mode == "owned": - if ( - room.room.host.user_id if room.room.host is not None else 0 - ) != current_user.id: - continue - if room.status != status: - continue + if category == "realtime" and room.category != "normal": + continue + elif category != room.category: + continue resp_list.append(await RoomResp.from_hub(room)) return resp_list + + +class APICreatedRoom(RoomResp): + error: str = "" + + +class APIUploadedRoom(RoomBase): + def to_room(self) -> Room: + """ + 将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。 + """ + room_dict = self.model_dump() + room_dict.pop("playlist", None) + # host_id 已在字段中 + return Room(**room_dict) + + id: int | None + host_id: int | None = None + playlist: list[Playlist] + + +@router.post("/rooms", tags=["room"], response_model=APICreatedRoom) +async def create_room( + room: APIUploadedRoom, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + # db_room = Room.from_resp(room) + await db.refresh(current_user) + user_id = current_user.id + db_room = room.to_room() + db_room.host_id = current_user.id if current_user.id else 1 + db.add(db_room) + await db.commit() + await db.refresh(db_room) + + playlist: list[Playlist] = [] + # 处理 APIUploadedRoom 里的 playlist 字段 + for item in room.playlist: + # 确保 room_id 正确赋值 + item.id = await Playlist.get_next_id_for_room(db_room.id, db) + item.room_id = db_room.id + item.owner_id = user_id if user_id else 1 + db.add(item) + await db.commit() + await db.refresh(item) + playlist.append(item) + await db.refresh(db_room) + db_room.playlist = playlist + server_room = ServerMultiplayerRoom( + room=MultiplayerRoom.from_db(db_room), + category=db_room.category, + start_at=datetime.now(UTC), + hub=MultiplayerHubs, + ) + MultiplayerHubs.rooms[db_room.id] = server_room + created_room = APICreatedRoom.model_validate(db_room) + created_room.error = "" + return created_room diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index af28d26..fa869b6 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -103,7 +103,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): item = room.playlist[0] item.owner_id = client.user_id room.room_id = db_room.id - starts_at = db_room.starts_at + starts_at = db_room.starts_at or datetime.now(UTC) await Playlist.add_to_db(item, db_room.id, session) server_room = ServerMultiplayerRoom( room=room, From ff25e58696780157a1523151ce7d6493d47a8069 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Thu, 7 Aug 2025 07:37:24 +0000 Subject: [PATCH 030/104] fix(room): solve 500 in API POST /rooms --- app/database/playlists.py | 5 +++-- app/router/room.py | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/database/playlists.py b/app/database/playlists.py index 432c3b0..3ecb75f 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -134,6 +134,7 @@ class PlaylistResp(PlaylistBase): @classmethod async def from_db(cls, playlist: Playlist) -> "PlaylistResp": - resp = cls.model_validate(playlist) - resp.beatmap = await BeatmapResp.from_db(playlist.beatmap) + data = playlist.model_dump() + data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True) + resp = cls.model_validate(data) return resp diff --git a/app/router/room.py b/app/router/room.py index 800c861..5f3a684 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -37,10 +37,10 @@ async def get_all_rooms( rooms = MultiplayerHubs.rooms.values() resp_list: list[RoomResp] = [] for room in rooms: - if category == "realtime" and room.category != "normal": - continue - elif category != room.category: - continue + # if category == "realtime" and room.category != "normal": + # continue + # elif category != room.category and category != "": + # continue resp_list.append(await RoomResp.from_hub(room)) return resp_list @@ -99,6 +99,6 @@ async def create_room( hub=MultiplayerHubs, ) MultiplayerHubs.rooms[db_room.id] = server_room - created_room = APICreatedRoom.model_validate(db_room) + created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room)) created_room.error = "" return created_room From bf04ea02d861da9afc84a8724edc86ddbaed2c65 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Thu, 7 Aug 2025 08:11:26 +0000 Subject: [PATCH 031/104] fix(multiplayer): don't re-add the last item when `HOST_ONLY` --- app/models/multiplayer_hub.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index ed37b98..25e359c 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -284,6 +284,8 @@ class PlaylistItem(BaseModel): copy = self.model_copy() copy.required_mods = list(self.required_mods) copy.allowed_mods = list(self.allowed_mods) + copy.expired = False + copy.played_at = None return copy From d130915b4a4e194cca704784a682701ad61a61cc Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Thu, 7 Aug 2025 11:16:28 +0000 Subject: [PATCH 032/104] feat(rooms): add API GET /rooms/{room} --- app/router/room.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/router/room.py b/app/router/room.py index 5f3a684..b992e5b 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -102,3 +102,12 @@ async def create_room( created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room)) created_room.error = "" return created_room + + +@router.get("/rooms/{room}", tags=["room"], response_model=RoomResp) +async def get_room( + room: int, + db: AsyncSession = Depends(get_db), +): + server_room = MultiplayerHubs.rooms[room] + return await RoomResp.from_hub(server_room) From 18d16e2542997d5eb39e6b8b94ab8bb130be4e40 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Thu, 7 Aug 2025 12:00:19 +0000 Subject: [PATCH 033/104] feat(rooms): add router PUT /rooms/{room}/users/{user} --- app/router/room.py | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index b992e5b..1c51753 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -5,21 +5,27 @@ from time import timezone from typing import Literal from app.database.lazer_user import User -from app.database.playlists import Playlist +from app.database.playlists import Playlist, PlaylistResp from app.database.room import Room, RoomBase, RoomResp from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher -from app.models.multiplayer_hub import MultiplayerRoom, ServerMultiplayerRoom +from app.models.multiplayer_hub import ( + MultiplayerRoom, + MultiplayerRoomUser, + ServerMultiplayerRoom, +) from app.models.room import RoomStatus from app.signalr.hub import MultiplayerHubs from .api_router import router -from fastapi import Depends, Query +from fastapi import Depends, HTTPException, Query from redis.asyncio import Redis +from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from starlette.status import HTTP_417_EXPECTATION_FAILED @router.get("/rooms", tags=["rooms"], response_model=list[RoomResp]) @@ -111,3 +117,30 @@ async def get_room( ): server_room = MultiplayerHubs.rooms[room] return await RoomResp.from_hub(server_room) + + +@router.delete("/rooms/{room}", tags=["room"]) +async def delete_room(room: int, db: AsyncSession = Depends(get_db)): + db_room = (await db.exec(select(Room).where(Room.id == room))).first() + if db_room is None: + raise HTTPException(404, "Room not found") + else: + await db.delete(db_room) + return None + + +@router.put("/rooms/{room}/users/{user}", tags=["room"]) +async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_db)): + server_room = MultiplayerHubs.rooms[room] + server_room.room.users.append(MultiplayerRoomUser(user_id=user)) + db_room = (await db.exec(select(Room).where(Room.id == room))).first() + if db_room is not None: + db_room.participant_count += 1 + await db.commit() + resp = await RoomResp.from_hub(server_room) + await db.refresh(db_room) + for item in db_room.playlist: + resp.playlist.append(await PlaylistResp.from_db(item)) + return resp + else: + raise HTTPException(404, "room not found0") From bc2961de1094a4453b2f66e8ddaa349afb805ca2 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Thu, 7 Aug 2025 14:52:02 +0000 Subject: [PATCH 034/104] feat(playlist): support leaderboard **UNTESTED** --- app/database/__init__.py | 3 +- app/database/playlist_attempts.py | 117 ++++++++++++++++++++++++++-- app/database/playlist_best_score.py | 2 + app/database/room.py | 10 --- app/router/room.py | 40 +++++++++- app/router/score.py | 26 ++++++- 6 files changed, 175 insertions(+), 23 deletions(-) diff --git a/app/database/__init__.py b/app/database/__init__.py index b3e65f9..dbfd3b8 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -15,7 +15,7 @@ from .lazer_user import ( User, UserResp, ) -from .playlist_attempts import ItemAttemptsCount +from .playlist_attempts import ItemAttemptsCount, ItemAttemptsResp from .playlist_best_score import PlaylistBestScore from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore @@ -50,6 +50,7 @@ __all__ = [ "DailyChallengeStatsResp", "FavouriteBeatmapset", "ItemAttemptsCount", + "ItemAttemptsResp", "MultiplayerScores", "OAuthToken", "PPBestScore", diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py index 5b4710a..da49981 100644 --- a/app/database/playlist_attempts.py +++ b/app/database/playlist_attempts.py @@ -1,9 +1,116 @@ -from sqlmodel import Field, SQLModel +from .lazer_user import User, UserResp +from .playlist_best_score import PlaylistBestScore + +from sqlmodel import ( + BigInteger, + Column, + Field, + ForeignKey, + Relationship, + SQLModel, + col, + func, + select, +) +from sqlmodel.ext.asyncio.session import AsyncSession -class ItemAttemptsCount(SQLModel, table=True): - __tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType] - id: int = Field(foreign_key="room_playlists.db_id", primary_key=True, index=True) +class ItemAttemptsCountBase(SQLModel): room_id: int = Field(foreign_key="rooms.id", index=True) attempts: int = Field(default=0) - passed: int = Field(default=0) + completed: int = Field(default=0) + user_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) + ) + accuracy: float = 0.0 + pp: float = 0 + total_score: int = 0 + + +class ItemAttemptsCount(ItemAttemptsCountBase, table=True): + __tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType] + id: int | None = Field( + default=None, foreign_key="room_playlists.db_id", primary_key=True + ) + + user: User = Relationship() + + async def get_position(self, session: AsyncSession) -> int: + rownum = ( + func.row_number() + .over( + partition_by=col(ItemAttemptsCountBase.room_id), + order_by=col(ItemAttemptsCountBase.total_score).desc(), + ) + .label("rn") + ) + subq = select(ItemAttemptsCountBase, rownum).subquery() + stmt = select(subq.c.rn).where(subq.c.user_id == self.user_id) + result = await session.exec(stmt) + return result.one() + + async def update(self, session: AsyncSession): + playlist_scores = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.room_id == self.room_id, + PlaylistBestScore.user_id == self.user_id, + ) + ) + ).all() + self.attempts = sum(score.attempts for score in playlist_scores) + self.total_score = sum(score.total_score for score in playlist_scores) + self.pp = sum(score.score.pp for score in playlist_scores) + self.completed = len(playlist_scores) + self.accuracy = ( + sum(score.score.accuracy * score.attempts for score in playlist_scores) + / self.completed + if self.completed > 0 + else 0.0 + ) + await session.commit() + await session.refresh(self) + + @classmethod + async def get_or_create( + cls, + room_id: int, + user_id: int, + session: AsyncSession, + ) -> "ItemAttemptsCount": + item_attempts = await session.exec( + select(cls).where( + cls.room_id == room_id, + cls.user_id == user_id, + ) + ) + item_attempts = item_attempts.first() + if item_attempts is None: + item_attempts = cls(room_id=room_id, user_id=user_id) + session.add(item_attempts) + await session.commit() + await session.refresh(item_attempts) + await item_attempts.update(session) + return item_attempts + + +class ItemAttemptsResp(ItemAttemptsCountBase): + user: UserResp | None = None + position: int | None = None + + @classmethod + async def from_db( + cls, + item_attempts: ItemAttemptsCount, + session: AsyncSession, + include: list[str] = [], + ) -> "ItemAttemptsResp": + resp = cls.model_validate(item_attempts) + resp.user = await UserResp.from_db( + item_attempts.user, + session=session, + include=["statistics", "team", "daily_challenge_user_stats"], + ) + if "position" in include: + resp.position = await item_attempts.get_position(session) + return resp diff --git a/app/database/playlist_best_score.py b/app/database/playlist_best_score.py index 49fb459..46bbfba 100644 --- a/app/database/playlist_best_score.py +++ b/app/database/playlist_best_score.py @@ -32,6 +32,7 @@ class PlaylistBestScore(SQLModel, table=True): room_id: int = Field(foreign_key="rooms.id", index=True) playlist_id: int = Field(foreign_key="room_playlists.id", index=True) total_score: int = Field(default=0, sa_column=Column(BigInteger)) + attempts: int = Field(default=0) # playlist user: User = Relationship() score: "Score" = Relationship( @@ -72,6 +73,7 @@ async def process_playlist_best_score( else: previous.score_id = score_id previous.total_score = total_score + previous.attempts += 1 await session.commit() await redis.decr(f"multiplayer:{room_id}:gameplay:players") diff --git a/app/database/room.py b/app/database/room.py index 08f1466..e01dece 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -11,7 +11,6 @@ from app.models.room import ( ) from .lazer_user import User, UserResp -from .playlist_attempts import ItemAttemptsCount from .playlists import Playlist, PlaylistResp from sqlmodel import ( @@ -67,13 +66,6 @@ class Room(RoomBase, table=True): "overlaps": "room", } ) - # playlist_item_attempts: list["ItemAttemptsCount"] = Relationship( - # sa_relationship_kwargs={ - # "lazy": "joined", - # "cascade": "all, delete-orphan", - # "primaryjoin": "ItemAttemptsCount.room_id == Room.id", - # } - # ) class RoomResp(RoomBase): @@ -84,7 +76,6 @@ class RoomResp(RoomBase): playlist_item_stats: RoomPlaylistItemStats | None = None difficulty_range: RoomDifficultyRange | None = None current_playlist_item: PlaylistResp | None = None - playlist_item_attempts: list[ItemAttemptsCount] = [] @classmethod async def from_db(cls, room: Room) -> "RoomResp": @@ -112,7 +103,6 @@ class RoomResp(RoomBase): resp.playlist_item_stats = stats resp.difficulty_range = difficulty_range resp.current_playlist_item = resp.playlist[-1] if resp.playlist else None - # resp.playlist_item_attempts = room.playlist_item_attempts return resp diff --git a/app/router/room.py b/app/router/room.py index 1c51753..d5bc713 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -1,10 +1,10 @@ from __future__ import annotations from datetime import UTC, datetime -from time import timezone from typing import Literal from app.database.lazer_user import User +from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp from app.database.playlists import Playlist, PlaylistResp from app.database.room import Room, RoomBase, RoomResp from app.dependencies.database import get_db, get_redis @@ -22,10 +22,10 @@ from app.signalr.hub import MultiplayerHubs from .api_router import router from fastapi import Depends, HTTPException, Query +from pydantic import BaseModel, Field from redis.asyncio import Redis -from sqlmodel import select +from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession -from starlette.status import HTTP_417_EXPECTATION_FAILED @router.get("/rooms", tags=["rooms"], response_model=list[RoomResp]) @@ -144,3 +144,37 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ return resp else: raise HTTPException(404, "room not found0") + + +class APILeaderboard(BaseModel): + leaderboard: list[ItemAttemptsResp] = Field(default_factory=list) + user_score: ItemAttemptsResp | None = None + + +@router.get("/rooms/{room}/leaderboard", tags=["room"], response_model=APILeaderboard) +async def get_room_leaderboard( + room: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + server_room = MultiplayerHubs.rooms[room] + if not server_room: + raise HTTPException(404, "Room not found") + + aggs = await db.exec( + select(ItemAttemptsCount) + .where(ItemAttemptsCount.room_id == room) + .order_by(col(ItemAttemptsCount.total_score).desc()) + ) + aggs_resp = [] + user_agg = None + for i, agg in enumerate(aggs): + resp = await ItemAttemptsResp.from_db(agg, db) + resp.position = i + 1 + aggs_resp.append(resp) + if agg.user_id == current_user.id: + user_agg = resp + return APILeaderboard( + leaderboard=aggs_resp, + user_score=user_agg, + ) diff --git a/app/router/score.py b/app/router/score.py index 818155d..5db171d 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -1,17 +1,20 @@ from __future__ import annotations +from datetime import UTC, datetime import time from app.calculator import clamp from app.database import ( Beatmap, Playlist, + Room, Score, ScoreResp, ScoreToken, ScoreTokenResp, User, ) +from app.database.playlist_attempts import ItemAttemptsCount from app.database.playlist_best_score import ( PlaylistBestScore, get_position, @@ -36,7 +39,6 @@ from app.models.score import ( Rank, SoloScoreSubmissionInfo, ) -from app.signalr.hub import MultiplayerHubs from .api_router import router @@ -278,9 +280,11 @@ async def create_playlist_score( current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db), ): - room = MultiplayerHubs.rooms[room_id] + room = await session.get(Room, room_id) if not room: raise HTTPException(status_code=404, detail="Room not found") + if room.ended_at and room.ended_at < datetime.now(UTC): + raise HTTPException(status_code=400, detail="Room has ended") item = ( await session.exec( select(Playlist).where( @@ -301,7 +305,18 @@ async def create_playlist_score( raise HTTPException( status_code=400, detail="Beatmap ID mismatch in playlist item" ) - # TODO: max attempts + agg = await session.exec( + select(ItemAttemptsCount).where( + ItemAttemptsCount.room_id == room_id, + ItemAttemptsCount.user_id == current_user.id, + ) + ) + agg = agg.first() + if agg and room.max_attempts and agg.attempts >= room.max_attempts: + raise HTTPException( + status_code=422, + detail="You have reached the maximum attempts for this room", + ) if item.expired: raise HTTPException(status_code=400, detail="Playlist item has expired") if item.played_at: @@ -342,6 +357,8 @@ async def submit_playlist_score( ).first() if not item: raise HTTPException(status_code=404, detail="Playlist item not found") + + user_id = current_user.id score_resp = await submit_score( info, item.beatmap_id, @@ -356,12 +373,13 @@ async def submit_playlist_score( await process_playlist_best_score( room_id, playlist_id, - current_user.id, + user_id, score_resp.id, score_resp.total_score, session, redis, ) + await ItemAttemptsCount.get_or_create(room_id, user_id, session) return score_resp From 7a2c8c1fb4f8cebd065b846ede1331ec69103d76 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Thu, 7 Aug 2025 16:18:54 +0000 Subject: [PATCH 035/104] feat(multiplayer): support multiplayer events --- app/database/__init__.py | 3 + app/database/multiplayer_event.py | 53 +++++++++++++ app/database/playlist_attempts.py | 4 +- app/database/playlists.py | 7 +- app/database/room.py | 2 +- app/models/multiplayer_hub.py | 35 ++++++++- app/router/room.py | 120 +++++++++++++++++++++++++++- app/signalr/hub/multiplayer.py | 126 ++++++++++++++++++++++++++++++ 8 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 app/database/multiplayer_event.py diff --git a/app/database/__init__.py b/app/database/__init__.py index dbfd3b8..0ee253b 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -15,6 +15,7 @@ from .lazer_user import ( User, UserResp, ) +from .multiplayer_event import MultiplayerEvent, MultiplayerEventResp from .playlist_attempts import ItemAttemptsCount, ItemAttemptsResp from .playlist_best_score import PlaylistBestScore from .playlists import Playlist, PlaylistResp @@ -51,6 +52,8 @@ __all__ = [ "FavouriteBeatmapset", "ItemAttemptsCount", "ItemAttemptsResp", + "MultiplayerEvent", + "MultiplayerEventResp", "MultiplayerScores", "OAuthToken", "PPBestScore", diff --git a/app/database/multiplayer_event.py b/app/database/multiplayer_event.py new file mode 100644 index 0000000..b80f957 --- /dev/null +++ b/app/database/multiplayer_event.py @@ -0,0 +1,53 @@ +from datetime import UTC, datetime +from typing import Any + +from app.models.model import UTCBaseModel + +from sqlmodel import ( + JSON, + BigInteger, + Column, + DateTime, + Field, + ForeignKey, + SQLModel, +) + + +class MultiplayerEventBase(SQLModel, UTCBaseModel): + playlist_item_id: int | None = None + user_id: int | None = Field( + default=None, + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True), + ) + created_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), + ), + default=datetime.now(UTC), + ) + event_type: str = Field(index=True) + + +class MultiplayerEvent(MultiplayerEventBase, table=True): + __tablename__ = "multiplayer_events" # pyright: ignore[reportAssignmentType] + id: int | None = Field(default=None, primary_key=True) + room_id: int = Field(foreign_key="rooms.id", index=True) + updated_at: datetime = Field( + sa_column=Column( + DateTime(timezone=True), + ), + default=datetime.now(UTC), + ) + event_detail: dict[str, Any] | None = Field( + sa_column=Column(JSON), + default_factory=dict, + ) + + +class MultiplayerEventResp(MultiplayerEventBase): + id: int + + @classmethod + def from_db(cls, event: MultiplayerEvent) -> "MultiplayerEventResp": + return cls.model_validate(event) diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py index da49981..93bc8c5 100644 --- a/app/database/playlist_attempts.py +++ b/app/database/playlist_attempts.py @@ -29,9 +29,7 @@ class ItemAttemptsCountBase(SQLModel): class ItemAttemptsCount(ItemAttemptsCountBase, table=True): __tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType] - id: int | None = Field( - default=None, foreign_key="room_playlists.db_id", primary_key=True - ) + id: int | None = Field(default=None, primary_key=True) user: User = Relationship() diff --git a/app/database/playlists.py b/app/database/playlists.py index 3ecb75f..3f7ae40 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -133,8 +133,11 @@ class PlaylistResp(PlaylistBase): beatmap: BeatmapResp | None = None @classmethod - async def from_db(cls, playlist: Playlist) -> "PlaylistResp": + async def from_db( + cls, playlist: Playlist, include: list[str] = [] + ) -> "PlaylistResp": data = playlist.model_dump() - data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True) + if "beatmap" in include: + data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True) resp = cls.model_validate(data) return resp diff --git a/app/database/room.py b/app/database/room.py index e01dece..7817805 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -98,7 +98,7 @@ class RoomResp(RoomBase): difficulty_range.max = max( difficulty_range.max, playlist.beatmap.difficulty_rating ) - resp.playlist.append(await PlaylistResp.from_db(playlist)) + resp.playlist.append(await PlaylistResp.from_db(playlist, ["beatmap"])) stats.ruleset_ids = list(rulesets) resp.playlist_item_stats = stats resp.difficulty_range = difficulty_range diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 25e359c..09d8900 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -6,7 +6,16 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import UTC, datetime, timedelta from enum import IntEnum -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, cast, override +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + ClassVar, + Literal, + TypedDict, + cast, + override, +) from app.database.beatmap import Beatmap from app.dependencies.database import engine @@ -705,6 +714,9 @@ class MatchTypeHandler(ABC): @abstractmethod async def handle_leave(self, user: MultiplayerRoomUser): ... + @abstractmethod + def get_details(self) -> MatchStartedEventDetail: ... + class HeadToHeadHandler(MatchTypeHandler): @override @@ -721,6 +733,11 @@ class HeadToHeadHandler(MatchTypeHandler): @override async def handle_leave(self, user: MultiplayerRoomUser): ... + @override + def get_details(self) -> MatchStartedEventDetail: + detail = MatchStartedEventDetail(room_type="head_to_head", team=None) + return detail + class TeamVersusHandler(MatchTypeHandler): @override @@ -780,6 +797,17 @@ class TeamVersusHandler(MatchTypeHandler): @override async def handle_leave(self, user: MultiplayerRoomUser): ... + @override + def get_details(self) -> MatchStartedEventDetail: + teams: dict[int, Literal["blue", "red"]] = {} + for user in self.room.room.users: + if user.match_state is not None and isinstance( + user.match_state, TeamVersusUserState + ): + teams[user.user_id] = "blue" if user.match_state.team_id == 1 else "red" + detail = MatchStartedEventDetail(room_type="team_versus", team=teams) + return detail + MATCH_TYPE_HANDLERS = { MatchType.HEAD_TO_HEAD: HeadToHeadHandler, @@ -890,3 +918,8 @@ MatchServerEvent = CountdownStartedEvent | CountdownStoppedEvent class GameplayAbortReason(IntEnum): LOAD_TOOK_TOO_LONG = 0 HOST_ABORTED = 1 + + +class MatchStartedEventDetail(TypedDict): + room_type: Literal["playlists", "head_to_head", "team_versus"] + team: dict[int, Literal["blue", "red"]] | None diff --git a/app/router/room.py b/app/router/room.py index d5bc713..2677b75 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -3,10 +3,14 @@ from __future__ import annotations from datetime import UTC, datetime from typing import Literal -from app.database.lazer_user import User +from app.database.beatmap import Beatmap, BeatmapResp +from app.database.beatmapset import BeatmapsetResp +from app.database.lazer_user import User, UserResp +from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp from app.database.playlists import Playlist, PlaylistResp from app.database.room import Room, RoomBase, RoomResp +from app.database.score import Score from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user @@ -140,7 +144,7 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ resp = await RoomResp.from_hub(server_room) await db.refresh(db_room) for item in db_room.playlist: - resp.playlist.append(await PlaylistResp.from_db(item)) + resp.playlist.append(await PlaylistResp.from_db(item, ["beatmap"])) return resp else: raise HTTPException(404, "room not found0") @@ -178,3 +182,115 @@ async def get_room_leaderboard( leaderboard=aggs_resp, user_score=user_agg, ) + + +class RoomEvents(BaseModel): + beatmaps: list[BeatmapResp] = Field(default_factory=list) + beatmapsets: dict[int, BeatmapsetResp] = Field(default_factory=dict) + current_playlist_item_id: int = 0 + events: list[MultiplayerEventResp] = Field(default_factory=list) + first_event_id: int = 0 + last_event_id: int = 0 + playlist_items: list[PlaylistResp] = Field(default_factory=list) + room: RoomResp + user: list[UserResp] = Field(default_factory=list) + + +@router.get("/rooms/{room_id}/events", response_model=RoomEvents, tags=["room"]) +async def get_room_events( + room_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + limit: int = Query(100, ge=1, le=1000), + after: int | None = Query(None, ge=0), + before: int | None = Query(None, ge=0), +): + events = ( + await db.exec( + select(MultiplayerEvent) + .where( + MultiplayerEvent.room_id == room_id, + col(MultiplayerEvent.id) > after if after is not None else True, + col(MultiplayerEvent.id) < before if before is not None else True, + ) + .order_by(col(MultiplayerEvent.id).desc()) + .limit(limit) + ) + ).all() + + user_ids = set() + playlist_items = {} + beatmap_ids = set() + + event_resps = [] + first_event_id = 0 + last_event_id = 0 + + current_playlist_item_id = 0 + for event in events: + event_resps.append(MultiplayerEventResp.from_db(event)) + + if event.user_id: + user_ids.add(event.user_id) + + if event.playlist_item_id is not None and ( + playitem := ( + await db.exec( + select(Playlist).where( + Playlist.id == event.playlist_item_id, + Playlist.room_id == room_id, + ) + ) + ).first() + ): + current_playlist_item_id = playitem.id + playlist_items[event.playlist_item_id] = playitem + beatmap_ids.add(playitem.beatmap_id) + scores = await db.exec( + select(Score).where( + Score.playlist_item_id == event.playlist_item_id, + Score.room_id == room_id, + ) + ) + for score in scores: + user_ids.add(score.user_id) + beatmap_ids.add(score.beatmap_id) + + assert event.id is not None + first_event_id = min(first_event_id, event.id) + last_event_id = max(last_event_id, event.id) + + if room := MultiplayerHubs.rooms.get(room_id): + current_playlist_item_id = room.queue.current_item.id + room_resp = await RoomResp.from_hub(room) + else: + room = (await db.exec(select(Room).where(Room.id == room_id))).first() + if room is None: + raise HTTPException(404, "Room not found") + room_resp = await RoomResp.from_db(room) + + users = await db.exec(select(User).where(col(User.id).in_(user_ids))) + user_resps = [await UserResp.from_db(user, db) for user in users] + beatmaps = await db.exec(select(Beatmap).where(col(Beatmap.id).in_(beatmap_ids))) + beatmap_resps = [ + await BeatmapResp.from_db(beatmap, session=db) for beatmap in beatmaps + ] + beatmapset_resps = {} + for beatmap_resp in beatmap_resps: + beatmapset_resps[beatmap_resp.beatmapset_id] = beatmap_resp.beatmapset + + playlist_items_resps = [ + await PlaylistResp.from_db(item) for item in playlist_items.values() + ] + + return RoomEvents( + beatmaps=beatmap_resps, + beatmapsets=beatmapset_resps, + current_playlist_item_id=current_playlist_item_id, + events=event_resps, + first_event_id=first_event_id, + last_event_id=last_event_id, + playlist_items=playlist_items_resps, + room=room_resp, + user=user_resps, + ) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index fa869b6..3688efa 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -7,6 +7,7 @@ from typing import override from app.database import Room from app.database.beatmap import Beatmap from app.database.lazer_user import User +from app.database.multiplayer_event import MultiplayerEvent from app.database.playlists import Playlist from app.database.relationship import Relationship, RelationshipType from app.dependencies.database import engine, get_redis @@ -20,6 +21,7 @@ from app.models.multiplayer_hub import ( MatchRequest, MatchServerEvent, MatchStartCountdown, + MatchStartedEventDetail, MultiplayerClientState, MultiplayerRoom, MultiplayerRoomSettings, @@ -49,11 +51,100 @@ from sqlmodel.ext.asyncio.session import AsyncSession GAMEPLAY_LOAD_TIMEOUT = 30 +class MultiplayerEventLogger: + def __init__(self): + pass + + async def log_event(self, event: MultiplayerEvent): + try: + async with AsyncSession(engine) as session: + session.add(event) + await session.commit() + except Exception as e: + logger.warning(f"Failed to log multiplayer room event to database: {e}") + + async def room_created(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="room_created", + ) + await self.log_event(event) + + async def room_disbanded(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="room_disbanded", + ) + await self.log_event(event) + + async def player_joined(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="player_joined", + ) + await self.log_event(event) + + async def player_left(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="player_left", + ) + await self.log_event(event) + + async def player_kicked(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="player_kicked", + ) + await self.log_event(event) + + async def host_changed(self, room_id: int, user_id: int): + event = MultiplayerEvent( + room_id=room_id, + user_id=user_id, + event_type="host_changed", + ) + await self.log_event(event) + + async def game_started( + self, room_id: int, playlist_item_id: int, details: MatchStartedEventDetail + ): + event = MultiplayerEvent( + room_id=room_id, + playlist_item_id=playlist_item_id, + event_type="game_started", + event_detail=details, # pyright: ignore[reportArgumentType] + ) + await self.log_event(event) + + async def game_aborted(self, room_id: int, playlist_item_id: int): + event = MultiplayerEvent( + room_id=room_id, + playlist_item_id=playlist_item_id, + event_type="game_aborted", + ) + await self.log_event(event) + + async def game_completed(self, room_id: int, playlist_item_id: int): + event = MultiplayerEvent( + room_id=room_id, + playlist_item_id=playlist_item_id, + event_type="game_completed", + ) + await self.log_event(event) + + class MultiplayerHub(Hub[MultiplayerClientState]): @override def __init__(self): super().__init__() self.rooms: dict[int, ServerMultiplayerRoom] = {} + self.event_logger = MultiplayerEventLogger() @staticmethod def group_id(room: int) -> str: @@ -113,6 +204,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) self.rooms[room.room_id] = server_room await server_room.set_handler() + await self.event_logger.room_created(room.room_id, client.user_id) return await self.JoinRoomWithPassword( client, room.room_id, room.settings.password ) @@ -143,6 +235,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): room.users.append(user) self.add_to_group(client, self.group_id(room_id)) await server_room.match_type_handler.handle_join(user) + await self.event_logger.player_joined(room_id, user.user_id) return room async def ChangeBeatmapAvailability( @@ -550,10 +643,12 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if all( u.state != MultiplayerUserState.PLAYING for u in room.room.users ): + any_user_finished_playing = False for u in filter( lambda u: u.state == MultiplayerUserState.FINISHED_PLAY, room.room.users, ): + any_user_finished_playing = True await self.change_user_state( room, u, MultiplayerUserState.RESULTS ) @@ -562,6 +657,16 @@ class MultiplayerHub(Hub[MultiplayerClientState]): self.group_id(room.room.room_id), "ResultsReady", ) + if any_user_finished_playing: + await self.event_logger.game_completed( + room.room.room_id, + room.queue.current_item.id, + ) + else: + await self.event_logger.game_aborted( + room.room.room_id, + room.queue.current_item.id, + ) await room.queue.finish_current_item() async def change_room_state( @@ -635,6 +740,11 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ), self.start_gameplay, ) + await self.event_logger.game_started( + room.room.room_id, + room.queue.current_item.id, + details=room.match_type_handler.get_details(), + ) async def start_gameplay(self, room: ServerMultiplayerRoom): if room.room.state != MultiplayerRoomState.WAITING_FOR_LOAD: @@ -737,6 +847,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): host_id=room.room.host.user_id, ) ) + await self.event_logger.room_disbanded( + room.room.room_id, + room.room.host.user_id, + ) del self.rooms[room.room.room_id] async def LeaveRoom(self, client: Client): @@ -751,6 +865,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if user is None: raise InvokeException("You are not in this room") + await self.event_logger.player_left( + room.room_id, + user.user_id, + ) await self.make_user_leave(client, server_room, user) async def KickUser(self, client: Client, user_id: int): @@ -772,6 +890,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if user is None: raise InvokeException("User not found in this room") + await self.event_logger.player_kicked( + room.room_id, + user.user_id, + ) target_client = self.get_client_by_id(str(user.user_id)) if target_client is None: return @@ -800,6 +922,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): new_host = next((u for u in room.users if u.user_id == user_id), None) if new_host is None: raise InvokeException("User not found in this room") + await self.event_logger.host_changed( + room.room_id, + new_host.user_id, + ) await self.set_host(server_room, new_host) async def AbortGameplay(self, client: Client): From 2bb1e4bad2d6806f83cbf59b17a757b4868903ab Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Thu, 7 Aug 2025 16:21:56 +0000 Subject: [PATCH 036/104] fix(multiplayer): use bigint for `event.id` --- app/database/multiplayer_event.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/database/multiplayer_event.py b/app/database/multiplayer_event.py index b80f957..904fbe4 100644 --- a/app/database/multiplayer_event.py +++ b/app/database/multiplayer_event.py @@ -31,7 +31,10 @@ class MultiplayerEventBase(SQLModel, UTCBaseModel): class MultiplayerEvent(MultiplayerEventBase, table=True): __tablename__ = "multiplayer_events" # pyright: ignore[reportAssignmentType] - id: int | None = Field(default=None, primary_key=True) + id: int | None = Field( + default=None, + sa_column=Column(BigInteger, primary_key=True, autoincrement=True, index=True), + ) room_id: int = Field(foreign_key="rooms.id", index=True) updated_at: datetime = Field( sa_column=Column( From fb0bba1a6eeae8b0076cd0557dfeaeac9080503c Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 06:25:31 +0000 Subject: [PATCH 037/104] fix(signalr): fail to parse `MessagePack-CSharp-Union | None` type when protocol is msgpack --- app/signalr/packet.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/signalr/packet.py b/app/signalr/packet.py index 09a36bd..8949f4b 100644 --- a/app/signalr/packet.py +++ b/app/signalr/packet.py @@ -242,7 +242,9 @@ class MsgpackProtocol: # except `X (Other Type) | None` if NoneType in args and v is None: return None - if not all(issubclass(arg, SignalRUnionMessage) for arg in args): + if not all( + issubclass(arg, SignalRUnionMessage) or arg is NoneType for arg in args + ): raise ValueError( f"Cannot validate {v} to {typ}, " "only SignalRUnionMessage subclasses are supported" From 0ac4f1f516fc29da6333d6f974017cee195bbbf1 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 11:54:43 +0000 Subject: [PATCH 038/104] refactor(beatmap,beatmapset): use to ensure beatmap exists --- app/database/beatmapset.py | 12 +++++++++++ app/router/beatmapset.py | 37 +++++++++------------------------- app/router/score.py | 10 ++++----- app/signalr/hub/multiplayer.py | 28 +++++++++++++++++++++---- app/signalr/hub/spectator.py | 27 ++++++++++++------------- 5 files changed, 64 insertions(+), 50 deletions(-) diff --git a/app/database/beatmapset.py b/app/database/beatmapset.py index 3bad7e9..12f3c67 100644 --- a/app/database/beatmapset.py +++ b/app/database/beatmapset.py @@ -13,6 +13,8 @@ from sqlmodel import Field, Relationship, SQLModel, col, func, select from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: + from app.fetcher import Fetcher + from .beatmap import Beatmap, BeatmapResp from .favourite_beatmapset import FavouriteBeatmapset @@ -185,6 +187,16 @@ class Beatmapset(AsyncAttrs, BeatmapsetBase, table=True): await Beatmap.from_resp_batch(session, resp.beatmaps, from_=from_) return beatmapset + @classmethod + async def get_or_fetch( + cls, session: AsyncSession, fetcher: "Fetcher", sid: int + ) -> "Beatmapset": + beatmapset = await session.get(Beatmapset, sid) + if not beatmapset: + resp = await fetcher.get_beatmapset(sid) + beatmapset = await cls.from_resp(session, resp) + return beatmapset + class BeatmapsetResp(BeatmapsetBase): id: int diff --git a/app/router/beatmapset.py b/app/router/beatmapset.py index f77c2ed..7280bad 100644 --- a/app/router/beatmapset.py +++ b/app/router/beatmapset.py @@ -12,7 +12,7 @@ from .api_router import router from fastapi import Depends, Form, HTTPException, Query from fastapi.responses import RedirectResponse -from httpx import HTTPStatusError +from httpx import HTTPError from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -24,22 +24,10 @@ async def lookup_beatmapset( db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), ): - beatmapset_id = ( - await db.exec(select(Beatmap.beatmapset_id).where(Beatmap.id == beatmap_id)) - ).first() - if not beatmapset_id: - try: - resp = await fetcher.get_beatmap(beatmap_id) - await Beatmap.from_resp(db, resp) - await db.refresh(current_user) - except HTTPStatusError: - raise HTTPException(status_code=404, detail="Beatmapset not found") - beatmapset = ( - await db.exec(select(Beatmapset).where(Beatmapset.id == beatmapset_id)) - ).first() - if not beatmapset: - raise HTTPException(status_code=404, detail="Beatmapset not found") - resp = await BeatmapsetResp.from_db(beatmapset, session=db, user=current_user) + beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap_id) + resp = await BeatmapsetResp.from_db( + beatmap.beatmapset, session=db, user=current_user + ) return resp @@ -50,18 +38,13 @@ async def get_beatmapset( db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), ): - beatmapset = (await db.exec(select(Beatmapset).where(Beatmapset.id == sid))).first() - if not beatmapset: - try: - resp = await fetcher.get_beatmapset(sid) - await Beatmapset.from_resp(db, resp) - except HTTPStatusError: - raise HTTPException(status_code=404, detail="Beatmapset not found") - else: - resp = await BeatmapsetResp.from_db( + try: + beatmapset = await Beatmapset.get_or_fetch(db, fetcher, sid) + return await BeatmapsetResp.from_db( beatmapset, session=db, include=["recent_favourites"], user=current_user ) - return resp + except HTTPError: + raise HTTPException(status_code=404, detail="Beatmapset not found") @router.get("/beatmapsets/{beatmapset}/download", tags=["beatmapset"]) diff --git a/app/router/score.py b/app/router/score.py index 5db171d..d776b5a 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -43,6 +43,7 @@ from app.models.score import ( from .api_router import router from fastapi import Depends, Form, HTTPException, Query +from httpx import HTTPError from pydantic import BaseModel from redis.asyncio import Redis from sqlalchemy.orm import joinedload @@ -86,12 +87,11 @@ async def submit_score( if not score: raise HTTPException(status_code=404, detail="Score not found") else: - beatmap_status = ( - await db.exec(select(Beatmap.beatmap_status).where(Beatmap.id == beatmap)) - ).first() - if beatmap_status is None: + try: + db_beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap) + except HTTPError: raise HTTPException(status_code=404, detail="Beatmap not found") - ranked = beatmap_status in { + ranked = db_beatmap.beatmap_status in { BeatmapRankStatus.RANKED, BeatmapRankStatus.APPROVED, } diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 3688efa..3f081e3 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -11,6 +11,7 @@ from app.database.multiplayer_event import MultiplayerEvent from app.database.playlists import Playlist from app.database.relationship import Relationship, RelationshipType from app.dependencies.database import engine, get_redis +from app.dependencies.fetcher import get_fetcher from app.exception import InvokeException from app.log import logger from app.models.mods import APIMod @@ -44,8 +45,9 @@ from app.models.score import GameMode from .hub import Client, Hub +from httpx import HTTPError from sqlalchemy import update -from sqlmodel import col, select +from sqlmodel import col, exists, select from sqlmodel.ext.asyncio.session import AsyncSession GAMEPLAY_LOAD_TIMEOUT = 30 @@ -191,11 +193,25 @@ class MultiplayerHub(Hub[MultiplayerClientState]): session.add(db_room) await session.commit() await session.refresh(db_room) + item = room.playlist[0] item.owner_id = client.user_id room.room_id = db_room.id starts_at = db_room.starts_at or datetime.now(UTC) + beatmap_exists = await session.exec( + select(exists().where(col(Beatmap.id) == item.beatmap_id)) + ) + if not beatmap_exists.one(): + fetcher = await get_fetcher() + try: + resp = await fetcher.get_beatmap(item.beatmap_id) + await Beatmap.from_resp(session, resp) + except HTTPError: + raise InvokeException( + "Failed to fetch beatmap, please retry later" + ) await Playlist.add_to_db(item, db_room.id, session) + server_room = ServerMultiplayerRoom( room=room, category=RoomCategory.NORMAL, @@ -372,6 +388,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def validate_styles(self, room: ServerMultiplayerRoom): + fetcher = await get_fetcher() if not room.queue.current_item.freestyle: for user in room.room.users: await self.change_user_style( @@ -381,9 +398,12 @@ class MultiplayerHub(Hub[MultiplayerClientState]): user, ) async with AsyncSession(engine) as session: - beatmap = await session.get(Beatmap, room.queue.current_item.beatmap_id) - if beatmap is None: - raise InvokeException("Beatmap not found") + try: + beatmap = await Beatmap.get_or_fetch( + session, fetcher, bid=room.queue.current_item.beatmap_id + ) + except HTTPError: + raise InvokeException("Current item beatmap not found") beatmap_ids = ( await session.exec( select(Beatmap.id, Beatmap.mode).where( diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index b9a3c99..d5a12ff 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -11,6 +11,7 @@ from app.database import Beatmap, User from app.database.score import Score from app.database.score_token import ScoreToken from app.dependencies.database import engine +from app.dependencies.fetcher import get_fetcher from app.models.beatmap import BeatmapRankStatus from app.models.mods import mods_to_int from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatistics @@ -179,15 +180,13 @@ class SpectatorHub(Hub[StoreClientState]): return if state.beatmap_id is None or state.ruleset_id is None: return + + fetcher = await get_fetcher() async with AsyncSession(engine) as session: async with session.begin(): - beatmap = ( - await session.exec( - select(Beatmap).where(Beatmap.id == state.beatmap_id) - ) - ).first() - if not beatmap: - return + beatmap = await Beatmap.get_or_fetch( + session, fetcher, bid=state.beatmap_id + ) user = ( await session.exec(select(User).where(User.id == user_id)) ).first() @@ -237,16 +236,16 @@ class SpectatorHub(Hub[StoreClientState]): user_id = int(client.connection_id) store = self.get_or_create_state(client) score = store.score - assert store.beatmap_status is not None - assert store.state is not None - assert store.score is not None - if not score or not store.score_token: + if ( + score is None + or store.score_token is None + or store.beatmap_status is None + or store.state is None + ): return if ( BeatmapRankStatus.PENDING < store.beatmap_status <= BeatmapRankStatus.LOVED - ) and any( - k.is_hit() and v > 0 for k, v in store.score.score_info.statistics.items() - ): + ) and any(k.is_hit() and v > 0 for k, v in score.score_info.statistics.items()): await self._process_score(store, client) store.state = None store.beatmap_status = None From 5bf733a94ea72a6f79ea030db4625017f7495bcf Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 12:00:06 +0000 Subject: [PATCH 039/104] fix(multiplayer): fix fetch beatmap when creating room --- app/signalr/hub/multiplayer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 3f081e3..efaabd9 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -204,8 +204,9 @@ class MultiplayerHub(Hub[MultiplayerClientState]): if not beatmap_exists.one(): fetcher = await get_fetcher() try: - resp = await fetcher.get_beatmap(item.beatmap_id) - await Beatmap.from_resp(session, resp) + await Beatmap.get_or_fetch( + session, fetcher, bid=item.beatmap_id + ) except HTTPError: raise InvokeException( "Failed to fetch beatmap, please retry later" From dd7b8a14cdb9ebe46400a336850e84a94f0e756e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 12:07:48 +0000 Subject: [PATCH 040/104] fix(multiplayer): fail to fetch for multiplayer --- app/models/multiplayer_hub.py | 13 +++++++++---- app/router/beatmap.py | 25 ++++++++++++++++++++++--- app/signalr/hub/multiplayer.py | 2 +- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 09d8900..bf08ff0 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -19,6 +19,7 @@ from typing import ( from app.database.beatmap import Beatmap from app.dependencies.database import engine +from app.dependencies.fetcher import get_fetcher from app.exception import InvokeException from .mods import APIMod @@ -518,8 +519,11 @@ class MultiplayerQueue: raise InvokeException("Freestyle items cannot have allowed mods") async with AsyncSession(engine) as session: + fetcher = await get_fetcher() async with session: - beatmap = await session.get(Beatmap, item.beatmap_id) + beatmap = await Beatmap.get_or_fetch( + session, fetcher, bid=item.beatmap_id + ) if beatmap is None: raise InvokeException("Beatmap not found") if item.beatmap_checksum != beatmap.checksum: @@ -543,10 +547,11 @@ class MultiplayerQueue: raise InvokeException("Freestyle items cannot have allowed mods") async with AsyncSession(engine) as session: + fetcher = await get_fetcher() async with session: - beatmap = await session.get(Beatmap, item.beatmap_id) - if beatmap is None: - raise InvokeException("Beatmap not found") + beatmap = await Beatmap.get_or_fetch( + session, fetcher, bid=item.beatmap_id + ) if item.beatmap_checksum != beatmap.checksum: raise InvokeException("Checksum mismatch") diff --git a/app/router/beatmap.py b/app/router/beatmap.py index 7dfd0f9..6800246 100644 --- a/app/router/beatmap.py +++ b/app/router/beatmap.py @@ -77,6 +77,7 @@ async def batch_get_beatmaps( b_ids: list[int] = Query(alias="ids[]", default_factory=list), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), + fetcher: Fetcher = Depends(get_fetcher), ): if not b_ids: # select 50 beatmaps by last_updated @@ -86,9 +87,27 @@ async def batch_get_beatmaps( ) ).all() else: - beatmaps = ( - await db.exec(select(Beatmap).where(col(Beatmap.id).in_(b_ids)).limit(50)) - ).all() + beatmaps = list( + ( + await db.exec( + select(Beatmap).where(col(Beatmap.id).in_(b_ids)).limit(50) + ) + ).all() + ) + not_found_beatmaps = [ + bid for bid in b_ids if bid not in [bm.id for bm in beatmaps] + ] + beatmaps.extend( + beatmap + for beatmap in await asyncio.gather( + *[ + Beatmap.get_or_fetch(db, fetcher, bid=bid) + for bid in not_found_beatmaps + ], + return_exceptions=True, + ) + if isinstance(beatmap, Beatmap) + ) return BatchGetResp( beatmaps=[ diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index efaabd9..b7aa14c 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -211,7 +211,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): raise InvokeException( "Failed to fetch beatmap, please retry later" ) - await Playlist.add_to_db(item, db_room.id, session) + await Playlist.add_to_db(item, room.room_id, session) server_room = ServerMultiplayerRoom( room=room, From 07a23c522550a01ae45a6c3f8ed7423df7eb4963 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 12:15:29 +0000 Subject: [PATCH 041/104] fix(multiplayer): cannot play the next item when the count of items > 1 --- app/models/multiplayer_hub.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index bf08ff0..13c7119 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -652,6 +652,7 @@ class MultiplayerQueue: ): assert self.room.host await self.add_item(self.current_item.clone(), self.room.host) + await self.update_current_item() async def update_queue_mode(self): if self.room.settings.queue_mode == QueueMode.HOST_ONLY and all( From 28f78882941cccfb3af5b69f6e5ba7d54f0bfc0b Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 8 Aug 2025 12:35:26 +0000 Subject: [PATCH 042/104] fix(multiplayer): don't save `item_id` & `room_id` in database --- app/router/score.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/router/score.py b/app/router/score.py index d776b5a..73ec2e2 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -104,6 +104,8 @@ async def submit_score( fetcher, db, redis, + item_id, + room_id, ) await db.refresh(current_user) score_id = score.id From 0710ccecbef82037fefb6b73826b84a29b198ca4 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Fri, 8 Aug 2025 13:07:29 +0000 Subject: [PATCH 043/104] fix(multiplayer): move playlists rooms to database --- app/database/room.py | 3 ++- app/router/room.py | 45 +++++++++++++++++++------------------------- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/app/database/room.py b/app/database/room.py index 7817805..cbbc2ba 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -1,5 +1,6 @@ from datetime import UTC, datetime +from app.models.model import UTCBaseModel from app.models.multiplayer_hub import ServerMultiplayerRoom from app.models.room import ( MatchType, @@ -24,7 +25,7 @@ from sqlmodel import ( ) -class RoomBase(SQLModel): +class RoomBase(SQLModel, UTCBaseModel): name: str = Field(index=True) category: RoomCategory = Field(default=RoomCategory.NORMAL, index=True) duration: int | None = Field(default=None) # minutes diff --git a/app/router/room.py b/app/router/room.py index 2677b75..7ff1211 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -15,11 +15,6 @@ from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher -from app.models.multiplayer_hub import ( - MultiplayerRoom, - MultiplayerRoomUser, - ServerMultiplayerRoom, -) from app.models.room import RoomStatus from app.signalr.hub import MultiplayerHubs @@ -44,14 +39,11 @@ async def get_all_rooms( redis: Redis = Depends(get_redis), current_user: User = Depends(get_current_user), ): - rooms = MultiplayerHubs.rooms.values() resp_list: list[RoomResp] = [] - for room in rooms: - # if category == "realtime" and room.category != "normal": - # continue - # elif category != room.category and category != "": - # continue - resp_list.append(await RoomResp.from_hub(room)) + db_rooms = (await db.exec(select(Room).where(True))).unique().all() + for room in db_rooms: + if room.ended_at is not None and room.ended_at > datetime.now(UTC): + resp_list.append(await RoomResp.from_db(room)) return resp_list @@ -85,6 +77,10 @@ async def create_room( user_id = current_user.id db_room = room.to_room() db_room.host_id = current_user.id if current_user.id else 1 + db_room.starts_at = datetime.now(UTC) + # db_room.ended_at = db_room.starts_at + timedelta( + # minutes=db_room.duration if db_room.duration is not None else 0 + # ) db.add(db_room) await db.commit() await db.refresh(db_room) @@ -102,13 +98,7 @@ async def create_room( playlist.append(item) await db.refresh(db_room) db_room.playlist = playlist - server_room = ServerMultiplayerRoom( - room=MultiplayerRoom.from_db(db_room), - category=db_room.category, - start_at=datetime.now(UTC), - hub=MultiplayerHubs, - ) - MultiplayerHubs.rooms[db_room.id] = server_room + await db.refresh(db_room) created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room)) created_room.error = "" return created_room @@ -117,10 +107,15 @@ async def create_room( @router.get("/rooms/{room}", tags=["room"], response_model=RoomResp) async def get_room( room: int, + category: str = Query(default=""), db: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), ): - server_room = MultiplayerHubs.rooms[room] - return await RoomResp.from_hub(server_room) + # 直接从db获取信息,毕竟都一样 + db_room = (await db.exec(select(Room).where(Room.id == room))).first() + if db_room is None: + raise HTTPException(404, "Room not found") + return await RoomResp.from_db(db_room) @router.delete("/rooms/{room}", tags=["room"]) @@ -135,13 +130,11 @@ async def delete_room(room: int, db: AsyncSession = Depends(get_db)): @router.put("/rooms/{room}/users/{user}", tags=["room"]) async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_db)): - server_room = MultiplayerHubs.rooms[room] - server_room.room.users.append(MultiplayerRoomUser(user_id=user)) db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is not None: db_room.participant_count += 1 await db.commit() - resp = await RoomResp.from_hub(server_room) + resp = await RoomResp.from_db(db_room) await db.refresh(db_room) for item in db_room.playlist: resp.playlist.append(await PlaylistResp.from_db(item, ["beatmap"])) @@ -161,8 +154,8 @@ async def get_room_leaderboard( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - server_room = MultiplayerHubs.rooms[room] - if not server_room: + db_room = (await db.exec(select(Room).where(Room.id == room))).first() + if db_room is None: raise HTTPException(404, "Room not found") aggs = await db.exec( From a4461d4efb87707f31ba191a5541033f15dbcc86 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 17:34:47 +0000 Subject: [PATCH 044/104] fix(room): rename `ended_at` to `ends_at` to fix room ended bug --- app/database/room.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/database/room.py b/app/database/room.py index cbbc2ba..a478f16 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -35,7 +35,7 @@ class RoomBase(SQLModel, UTCBaseModel): ), default=datetime.now(UTC), ) - ended_at: datetime | None = Field( + ends_at: datetime | None = Field( sa_column=Column( DateTime(timezone=True), ), From 00076c247f654fc9c3c483e4d2ad7a3668120dd9 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 17:36:34 +0000 Subject: [PATCH 045/104] fix(playlist): fix model validation bug in playlist_attemps.py:106 --- app/database/playlist_attempts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py index 93bc8c5..d4085da 100644 --- a/app/database/playlist_attempts.py +++ b/app/database/playlist_attempts.py @@ -103,7 +103,7 @@ class ItemAttemptsResp(ItemAttemptsCountBase): session: AsyncSession, include: list[str] = [], ) -> "ItemAttemptsResp": - resp = cls.model_validate(item_attempts) + resp = cls.model_validate(item_attempts.model_dump()) resp.user = await UserResp.from_db( item_attempts.user, session=session, From f4a46054d2c9597d6b1432ea935faed6957aa941 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 17:38:02 +0000 Subject: [PATCH 046/104] fix(multiplayer): fix logic errors in a room's end time --- app/router/room.py | 13 +++++++------ app/router/score.py | 5 ++++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index 7ff1211..3da8ca5 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Literal from app.database.beatmap import Beatmap, BeatmapResp @@ -42,8 +42,8 @@ async def get_all_rooms( resp_list: list[RoomResp] = [] db_rooms = (await db.exec(select(Room).where(True))).unique().all() for room in db_rooms: - if room.ended_at is not None and room.ended_at > datetime.now(UTC): - resp_list.append(await RoomResp.from_db(room)) + # if room.ends_at is not None and room.ends_at > datetime.now(UTC): + resp_list.append(await RoomResp.from_db(room)) return resp_list @@ -78,9 +78,9 @@ async def create_room( db_room = room.to_room() db_room.host_id = current_user.id if current_user.id else 1 db_room.starts_at = datetime.now(UTC) - # db_room.ended_at = db_room.starts_at + timedelta( - # minutes=db_room.duration if db_room.duration is not None else 0 - # ) + db_room.ends_at = db_room.starts_at + timedelta( + minutes=db_room.duration if db_room.duration is not None else 0 + ) db.add(db_room) await db.commit() await db.refresh(db_room) @@ -134,6 +134,7 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ if db_room is not None: db_room.participant_count += 1 await db.commit() + await db.refresh(db_room) resp = await RoomResp.from_db(db_room) await db.refresh(db_room) for item in db_room.playlist: diff --git a/app/router/score.py b/app/router/score.py index 73ec2e2..9963559 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -285,7 +285,10 @@ async def create_playlist_score( room = await session.get(Room, room_id) if not room: raise HTTPException(status_code=404, detail="Room not found") - if room.ended_at and room.ended_at < datetime.now(UTC): + db_room_time = ( + room.ends_at.replace(tzinfo=UTC) if room.ends_at is not None else room.starts_at + ) + if db_room_time and db_room_time < datetime.now(UTC): raise HTTPException(status_code=400, detail="Room has ended") item = ( await session.exec( From 698c0c2a818bccf82f3643b9616c526d80f9015b Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 17:38:37 +0000 Subject: [PATCH 047/104] chore: add a little script to clean ANSI charactors from logs from uvicorn --- remove_ansi.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 remove_ansi.py diff --git a/remove_ansi.py b/remove_ansi.py new file mode 100644 index 0000000..1720888 --- /dev/null +++ b/remove_ansi.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Script to remove ANSI escape codes from log files +""" + +from __future__ import annotations + +import re +import sys + + +def remove_ansi_codes(text): + """ + Remove ANSI escape codes from text + """ + # Regular expression to match ANSI escape codes + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + return ansi_escape.sub("", text) + + +def process_log_file(input_file, output_file=None): + """ + Process log file and remove ANSI escape codes + """ + if output_file is None: + output_file = ( + input_file.replace(".log", "_clean.log") + if ".log" in input_file + else input_file + "_clean" + ) + + with open(input_file, "r", encoding="utf-8") as infile: + content = infile.read() + + # Remove ANSI escape codes + clean_content = remove_ansi_codes(content) + + with open(output_file, "w", encoding="utf-8") as outfile: + outfile.write(clean_content) + + print(f"Processed {input_file} -> {output_file}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python remove_ansi.py [output_file]") + sys.exit(1) + + input_file = sys.argv[1] + output_file = sys.argv[2] if len(sys.argv) > 2 else None + + process_log_file(input_file, output_file) From c49c0481d08d816fcbbccfb25f78a0b8f2da765f Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 18:21:15 +0000 Subject: [PATCH 048/104] fix(multiplayer): fix acc bug on leaderboards --- app/database/playlist_attempts.py | 4 ++-- app/router/room.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py index d4085da..cfd8147 100644 --- a/app/database/playlist_attempts.py +++ b/app/database/playlist_attempts.py @@ -61,8 +61,7 @@ class ItemAttemptsCount(ItemAttemptsCountBase, table=True): self.pp = sum(score.score.pp for score in playlist_scores) self.completed = len(playlist_scores) self.accuracy = ( - sum(score.score.accuracy * score.attempts for score in playlist_scores) - / self.completed + sum(score.score.accuracy for score in playlist_scores) / self.completed if self.completed > 0 else 0.0 ) @@ -111,4 +110,5 @@ class ItemAttemptsResp(ItemAttemptsCountBase): ) if "position" in include: resp.position = await item_attempts.get_position(session) + # resp.accuracy *= 100 return resp diff --git a/app/router/room.py b/app/router/room.py index 3da8ca5..45d636b 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -169,6 +169,7 @@ async def get_room_leaderboard( for i, agg in enumerate(aggs): resp = await ItemAttemptsResp.from_db(agg, db) resp.position = i + 1 + # resp.accuracy *= 100 aggs_resp.append(resp) if agg.user_id == current_user.id: user_agg = resp From 4ded7f296909c0f962182236c87d3335df548b49 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 18:30:10 +0000 Subject: [PATCH 049/104] feat(multiplayer): add support for users leaving playlists room --- app/router/room.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/router/room.py b/app/router/room.py index 45d636b..f98e294 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -144,6 +144,19 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ raise HTTPException(404, "room not found0") +@router.delete("/rooms/{room}/users/{user}", tags=["room"]) +async def remove_user_from_room( + room: int, user: int, db: AsyncSession = Depends(get_db) +): + db_room = (await db.exec(select(Room).where(Room.id == room))).first() + if db_room is not None: + db_room.participant_count -= 1 + await db.commit() + return None + else: + raise HTTPException(404, "Room not found") + + class APILeaderboard(BaseModel): leaderboard: list[ItemAttemptsResp] = Field(default_factory=list) user_score: ItemAttemptsResp | None = None From a4f5582c95c85a31e0b13e42d2ff61155c8dea55 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 18:47:41 +0000 Subject: [PATCH 050/104] feat(multiplayer): add basic filter options for /rooms API --- app/router/room.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index f98e294..b4d77e2 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -32,7 +32,7 @@ async def get_all_rooms( mode: Literal["open", "ended", "participated", "owned", None] = Query( default="open" ), # TODO: 对房间根据状态进行筛选 - category: str = Query(default="realtime"), # TODO + category: str | None = Query(None), # TODO status: RoomStatus | None = Query(None), db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), @@ -42,8 +42,15 @@ async def get_all_rooms( resp_list: list[RoomResp] = [] db_rooms = (await db.exec(select(Room).where(True))).unique().all() for room in db_rooms: - # if room.ends_at is not None and room.ends_at > datetime.now(UTC): - resp_list.append(await RoomResp.from_db(room)) + if category == "realtime": + if room.id in MultiplayerHubs.rooms: + resp_list.append(await RoomResp.from_db(room)) + elif category is not None: + if category == room.category: + resp_list.append(await RoomResp.from_db(room)) + else: + if room.id not in MultiplayerHubs.rooms: + resp_list.append(await RoomResp.from_db(room)) return resp_list From 3e3cf27acc2a67d9d3884b68885135d201d1e2a5 Mon Sep 17 00:00:00 2001 From: chenjintang-shrimp Date: Fri, 8 Aug 2025 18:49:05 +0000 Subject: [PATCH 051/104] fix(multiplayer): fxxk python datetime timezone-aware or not, who cares --- app/router/score.py | 6 ++---- app/signalr/hub/multiplayer.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/router/score.py b/app/router/score.py index 9963559..70664a5 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -285,10 +285,8 @@ async def create_playlist_score( room = await session.get(Room, room_id) if not room: raise HTTPException(status_code=404, detail="Room not found") - db_room_time = ( - room.ends_at.replace(tzinfo=UTC) if room.ends_at is not None else room.starts_at - ) - if db_room_time and db_room_time < datetime.now(UTC): + db_room_time = room.ends_at.replace(tzinfo=UTC) if room.ends_at else None + if db_room_time and db_room_time < datetime.now(UTC).replace(tzinfo=UTC): raise HTTPException(status_code=400, detail="Room has ended") item = ( await session.exec( diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index b7aa14c..135d2ac 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -858,7 +858,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): .where(col(Room.id) == room.room.room_id) .values( name=room.room.settings.name, - ended_at=datetime.now(UTC), + ends_at=datetime.now(UTC), type=room.room.settings.match_type, queue_mode=room.room.settings.queue_mode, auto_skip=room.room.settings.auto_skip, From d7002374b620a515c60d0c96da700c237fcd6406 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 04:53:20 +0000 Subject: [PATCH 052/104] fix(playlist): fix user attempts --- app/database/__init__.py | 7 +++++- app/database/playlist_attempts.py | 37 +++++++++++++++++++++++++++++ app/database/playlist_best_score.py | 11 +++++---- app/database/room.py | 15 +++++++++++- app/router/room.py | 6 ++++- 5 files changed, 68 insertions(+), 8 deletions(-) diff --git a/app/database/__init__.py b/app/database/__init__.py index 0ee253b..4501e21 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -16,7 +16,11 @@ from .lazer_user import ( UserResp, ) from .multiplayer_event import MultiplayerEvent, MultiplayerEventResp -from .playlist_attempts import ItemAttemptsCount, ItemAttemptsResp +from .playlist_attempts import ( + ItemAttemptsCount, + ItemAttemptsResp, + PlaylistAggregateScore, +) from .playlist_best_score import PlaylistBestScore from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore @@ -58,6 +62,7 @@ __all__ = [ "OAuthToken", "PPBestScore", "Playlist", + "PlaylistAggregateScore", "PlaylistBestScore", "PlaylistResp", "Relationship", diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py index cfd8147..5d580cf 100644 --- a/app/database/playlist_attempts.py +++ b/app/database/playlist_attempts.py @@ -1,6 +1,7 @@ from .lazer_user import User, UserResp from .playlist_best_score import PlaylistBestScore +from pydantic import BaseModel from sqlmodel import ( BigInteger, Column, @@ -112,3 +113,39 @@ class ItemAttemptsResp(ItemAttemptsCountBase): resp.position = await item_attempts.get_position(session) # resp.accuracy *= 100 return resp + + +class ItemAttemptsCountForItem(BaseModel): + id: int + attempts: int + passed: bool + + +class PlaylistAggregateScore(BaseModel): + playlist_item_attempts: list[ItemAttemptsCountForItem] = Field(default_factory=list) + + @classmethod + async def from_db( + cls, + room_id: int, + user_id: int, + session: AsyncSession, + ) -> "PlaylistAggregateScore": + playlist_scores = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.room_id == room_id, + PlaylistBestScore.user_id == user_id, + ) + ) + ).all() + playlist_item_attempts = [] + for score in playlist_scores: + playlist_item_attempts.append( + ItemAttemptsCountForItem( + id=score.playlist_id, + attempts=score.attempts, + passed=score.score.passed, + ) + ) + return cls(playlist_item_attempts=playlist_item_attempts) diff --git a/app/database/playlist_best_score.py b/app/database/playlist_best_score.py index 46bbfba..6ecb18a 100644 --- a/app/database/playlist_best_score.py +++ b/app/database/playlist_best_score.py @@ -62,20 +62,21 @@ async def process_playlist_best_score( ) ).first() if previous is None: - score = PlaylistBestScore( + previous = PlaylistBestScore( user_id=user_id, score_id=score_id, room_id=room_id, playlist_id=playlist_id, total_score=total_score, ) - session.add(score) - else: + session.add(previous) + elif not previous.score.passed or previous.total_score < total_score: previous.score_id = score_id previous.total_score = total_score - previous.attempts += 1 + previous.attempts += 1 await session.commit() - await redis.decr(f"multiplayer:{room_id}:gameplay:players") + if await redis.exists(f"multiplayer:{room_id}:gameplay:players"): + await redis.decr(f"multiplayer:{room_id}:gameplay:players") async def get_position( diff --git a/app/database/room.py b/app/database/room.py index a478f16..7fb18fa 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -1,5 +1,6 @@ from datetime import UTC, datetime +from app.database.playlist_attempts import PlaylistAggregateScore from app.models.model import UTCBaseModel from app.models.multiplayer_hub import ServerMultiplayerRoom from app.models.room import ( @@ -23,6 +24,7 @@ from sqlmodel import ( Relationship, SQLModel, ) +from sqlmodel.ext.asyncio.session import AsyncSession class RoomBase(SQLModel, UTCBaseModel): @@ -77,9 +79,16 @@ class RoomResp(RoomBase): playlist_item_stats: RoomPlaylistItemStats | None = None difficulty_range: RoomDifficultyRange | None = None current_playlist_item: PlaylistResp | None = None + current_user_score: PlaylistAggregateScore | None = None @classmethod - async def from_db(cls, room: Room) -> "RoomResp": + async def from_db( + cls, + room: Room, + include: list[str] = [], + session: AsyncSession | None = None, + user: User | None = None, + ) -> "RoomResp": resp = cls.model_validate(room.model_dump()) stats = RoomPlaylistItemStats(count_active=0, count_total=0) @@ -105,6 +114,10 @@ class RoomResp(RoomBase): resp.difficulty_range = difficulty_range resp.current_playlist_item = resp.playlist[-1] if resp.playlist else None + if "current_user_score" in include and user and session: + resp.current_user_score = await PlaylistAggregateScore.from_db( + room.id, user.id, session + ) return resp @classmethod diff --git a/app/router/room.py b/app/router/room.py index b4d77e2..8c35081 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -116,13 +116,17 @@ async def get_room( room: int, category: str = Query(default=""), db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), redis: Redis = Depends(get_redis), ): # 直接从db获取信息,毕竟都一样 db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is None: raise HTTPException(404, "Room not found") - return await RoomResp.from_db(db_room) + resp = await RoomResp.from_db( + db_room, include=["current_user_score"], session=db, user=current_user + ) + return resp @router.delete("/rooms/{room}", tags=["room"]) From 319599cacc16436ab550d71960955955b39b8688 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 05:49:01 +0000 Subject: [PATCH 053/104] feat(multiplayer,playlist): show host & renect participants --- app/database/__init__.py | 2 ++ app/database/room.py | 33 ++++++++++++++--- app/database/room_participated_user.py | 39 ++++++++++++++++++++ app/router/room.py | 50 ++++++++++++++++++++++---- app/signalr/hub/multiplayer.py | 45 +++++++++++++++++++++++ 5 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 app/database/room_participated_user.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 4501e21..f401e56 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -26,6 +26,7 @@ from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore from .relationship import Relationship, RelationshipResp, RelationshipType from .room import Room, RoomResp +from .room_participated_user import RoomParticipatedUser from .score import ( MultiplayerScores, Score, @@ -69,6 +70,7 @@ __all__ = [ "RelationshipResp", "RelationshipType", "Room", + "RoomParticipatedUser", "RoomResp", "Score", "ScoreAround", diff --git a/app/database/room.py b/app/database/room.py index 7fb18fa..fe63b9d 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -1,6 +1,7 @@ from datetime import UTC, datetime from app.database.playlist_attempts import PlaylistAggregateScore +from app.database.room_participated_user import RoomParticipatedUser from app.models.model import UTCBaseModel from app.models.multiplayer_hub import ServerMultiplayerRoom from app.models.room import ( @@ -15,6 +16,7 @@ from app.models.room import ( from .lazer_user import User, UserResp from .playlists import Playlist, PlaylistResp +from sqlalchemy.ext.asyncio import AsyncAttrs from sqlmodel import ( BigInteger, Column, @@ -23,6 +25,8 @@ from sqlmodel import ( ForeignKey, Relationship, SQLModel, + col, + select, ) from sqlmodel.ext.asyncio.session import AsyncSession @@ -51,10 +55,9 @@ class RoomBase(SQLModel, UTCBaseModel): auto_start_duration: int status: RoomStatus # TODO: channel_id - # recent_participants: list[User] -class Room(RoomBase, table=True): +class Room(AsyncAttrs, RoomBase, table=True): __tablename__ = "rooms" # pyright: ignore[reportAssignmentType] id: int = Field(default=None, primary_key=True, index=True) host_id: int = Field( @@ -80,13 +83,14 @@ class RoomResp(RoomBase): difficulty_range: RoomDifficultyRange | None = None current_playlist_item: PlaylistResp | None = None current_user_score: PlaylistAggregateScore | None = None + recent_participants: list[UserResp] = Field(default_factory=list) @classmethod async def from_db( cls, room: Room, + session: AsyncSession, include: list[str] = [], - session: AsyncSession | None = None, user: User | None = None, ) -> "RoomResp": resp = cls.model_validate(room.model_dump()) @@ -113,8 +117,27 @@ class RoomResp(RoomBase): resp.playlist_item_stats = stats resp.difficulty_range = difficulty_range resp.current_playlist_item = resp.playlist[-1] if resp.playlist else None - - if "current_user_score" in include and user and session: + resp.recent_participants = [] + for recent_participant in await session.exec( + select(RoomParticipatedUser) + .where( + RoomParticipatedUser.room_id == room.id, + col(RoomParticipatedUser.left_at).is_(None), + ) + .limit(8) + .order_by(col(RoomParticipatedUser.joined_at).desc()) + ): + resp.recent_participants.append( + await UserResp.from_db( + await recent_participant.awaitable_attrs.user, + session, + include=["statistics"], + ) + ) + resp.host = await UserResp.from_db( + await room.awaitable_attrs.host, session, include=["statistics"] + ) + if "current_user_score" in include and user: resp.current_user_score = await PlaylistAggregateScore.from_db( room.id, user.id, session ) diff --git a/app/database/room_participated_user.py b/app/database/room_participated_user.py new file mode 100644 index 0000000..18b0aeb --- /dev/null +++ b/app/database/room_participated_user.py @@ -0,0 +1,39 @@ +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlmodel import ( + BigInteger, + Column, + DateTime, + Field, + ForeignKey, + Relationship, + SQLModel, +) + +if TYPE_CHECKING: + from .lazer_user import User + from .room import Room + + +class RoomParticipatedUser(AsyncAttrs, SQLModel, table=True): + __tablename__ = "room_participated_users" # pyright: ignore[reportAssignmentType] + + id: int | None = Field( + default=None, sa_column=Column(BigInteger, primary_key=True, autoincrement=True) + ) + room_id: int = Field(sa_column=Column(ForeignKey("rooms.id"), nullable=False)) + user_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), nullable=False) + ) + joined_at: datetime = Field( + sa_column=Column(DateTime(timezone=True), nullable=False), + default=datetime.now(UTC), + ) + left_at: datetime | None = Field( + sa_column=Column(DateTime(timezone=True), nullable=True), default=None + ) + + room: "Room" = Relationship() + user: "User" = Relationship() diff --git a/app/router/room.py b/app/router/room.py index 8c35081..94d5711 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -10,6 +10,7 @@ from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventRes from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp from app.database.playlists import Playlist, PlaylistResp from app.database.room import Room, RoomBase, RoomResp +from app.database.room_participated_user import RoomParticipatedUser from app.database.score import Score from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher @@ -44,13 +45,13 @@ async def get_all_rooms( for room in db_rooms: if category == "realtime": if room.id in MultiplayerHubs.rooms: - resp_list.append(await RoomResp.from_db(room)) + resp_list.append(await RoomResp.from_db(room, db)) elif category is not None: if category == room.category: - resp_list.append(await RoomResp.from_db(room)) + resp_list.append(await RoomResp.from_db(room, db)) else: if room.id not in MultiplayerHubs.rooms: - resp_list.append(await RoomResp.from_db(room)) + resp_list.append(await RoomResp.from_db(room, db)) return resp_list @@ -73,6 +74,30 @@ class APIUploadedRoom(RoomBase): playlist: list[Playlist] +async def _participate_room( + room_id: int, user_id: int, db_room: Room, session: AsyncSession +): + participated_user = ( + await session.exec( + select(RoomParticipatedUser).where( + RoomParticipatedUser.room_id == room_id, + RoomParticipatedUser.user_id == user_id, + ) + ) + ).first() + if participated_user is None: + participated_user = RoomParticipatedUser( + room_id=room_id, + user_id=user_id, + joined_at=datetime.now(UTC), + ) + session.add(participated_user) + else: + participated_user.left_at = None + participated_user.joined_at = datetime.now(UTC) + db_room.participant_count += 1 + + @router.post("/rooms", tags=["room"], response_model=APICreatedRoom) async def create_room( room: APIUploadedRoom, @@ -91,6 +116,7 @@ async def create_room( db.add(db_room) await db.commit() await db.refresh(db_room) + await _participate_room(db_room.id, user_id, db_room, db) playlist: list[Playlist] = [] # 处理 APIUploadedRoom 里的 playlist 字段 @@ -106,7 +132,7 @@ async def create_room( await db.refresh(db_room) db_room.playlist = playlist await db.refresh(db_room) - created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room)) + created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db)) created_room.error = "" return created_room @@ -143,10 +169,10 @@ async def delete_room(room: int, db: AsyncSession = Depends(get_db)): async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_db)): db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is not None: - db_room.participant_count += 1 + await _participate_room(room, user, db_room, db) await db.commit() await db.refresh(db_room) - resp = await RoomResp.from_db(db_room) + resp = await RoomResp.from_db(db_room, db) await db.refresh(db_room) for item in db_room.playlist: resp.playlist.append(await PlaylistResp.from_db(item, ["beatmap"])) @@ -161,6 +187,16 @@ async def remove_user_from_room( ): db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is not None: + participated_user = ( + await db.exec( + select(RoomParticipatedUser).where( + RoomParticipatedUser.room_id == room, + RoomParticipatedUser.user_id == user, + ) + ) + ).first() + if participated_user is not None: + participated_user.left_at = datetime.now(UTC) db_room.participant_count -= 1 await db.commit() return None @@ -286,7 +322,7 @@ async def get_room_events( room = (await db.exec(select(Room).where(Room.id == room_id))).first() if room is None: raise HTTPException(404, "Room not found") - room_resp = await RoomResp.from_db(room) + room_resp = await RoomResp.from_db(room, db) users = await db.exec(select(User).where(col(User.id).in_(user_ids))) user_resps = [await UserResp.from_db(user, db) for user in users] diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 135d2ac..f7763df 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -10,6 +10,7 @@ from app.database.lazer_user import User from app.database.multiplayer_event import MultiplayerEvent from app.database.playlists import Playlist from app.database.relationship import Relationship, RelationshipType +from app.database.room_participated_user import RoomParticipatedUser from app.dependencies.database import engine, get_redis from app.dependencies.fetcher import get_fetcher from app.exception import InvokeException @@ -253,6 +254,32 @@ class MultiplayerHub(Hub[MultiplayerClientState]): self.add_to_group(client, self.group_id(room_id)) await server_room.match_type_handler.handle_join(user) await self.event_logger.player_joined(room_id, user.user_id) + + async with AsyncSession(engine) as session: + async with session.begin(): + if ( + participated_user := ( + await session.exec( + select(RoomParticipatedUser).where( + RoomParticipatedUser.room_id == room_id, + RoomParticipatedUser.user_id == client.user_id, + ) + ) + ).first() + ) is None: + participated_user = RoomParticipatedUser( + room_id=room_id, + user_id=client.user_id, + ) + session.add(participated_user) + else: + participated_user.left_at = None + participated_user.joined_at = datetime.now(UTC) + + room = await session.get(Room, room_id) + if room is None: + raise InvokeException("Room does not exist in database") + room.participant_count += 1 return room async def ChangeBeatmapAvailability( @@ -846,6 +873,24 @@ class MultiplayerHub(Hub[MultiplayerClientState]): self.group_id(room.room.room_id), "UserLeft", user ) + async with AsyncSession(engine) as session: + async with session.begin(): + participated_user = ( + await session.exec( + select(RoomParticipatedUser).where( + RoomParticipatedUser.room_id == room.room.room_id, + RoomParticipatedUser.user_id == user.user_id, + ) + ) + ).first() + if participated_user is not None: + participated_user.left_at = datetime.now(UTC) + + db_room = await session.get(Room, room.room.room_id) + if db_room is None: + raise InvokeException("Room does not exist in database") + db_room.participant_count -= 1 + target_store = self.state.get(user.user_id) if target_store: target_store.room_id = 0 From e4d883bf8105a28ab694f74606fdc73552fe474a Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 05:52:38 +0000 Subject: [PATCH 054/104] fix(playlist): cannot close playlist manually --- app/router/room.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/router/room.py b/app/router/room.py index 94d5711..05437b2 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -161,7 +161,8 @@ async def delete_room(room: int, db: AsyncSession = Depends(get_db)): if db_room is None: raise HTTPException(404, "Room not found") else: - await db.delete(db_room) + db_room.ends_at = datetime.now(UTC) + await db.commit() return None From 99f2b3db2ac8d8a43ae03431f63a160ecdc225c9 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 05:59:26 +0000 Subject: [PATCH 055/104] fix(playlist): duplicated item in list --- app/database/room.py | 2 +- app/router/room.py | 16 +++------------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/app/database/room.py b/app/database/room.py index fe63b9d..f0234e8 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -67,7 +67,7 @@ class Room(AsyncAttrs, RoomBase, table=True): host: User = Relationship() playlist: list[Playlist] = Relationship( sa_relationship_kwargs={ - "lazy": "joined", + "lazy": "selectin", "cascade": "all, delete-orphan", "overlaps": "room", } diff --git a/app/router/room.py b/app/router/room.py index 05437b2..c3d4aee 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -8,7 +8,7 @@ from app.database.beatmapset import BeatmapsetResp from app.database.lazer_user import User, UserResp from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp -from app.database.playlists import Playlist, PlaylistResp +from app.database.playlists import Playlist, PlaylistBase, PlaylistResp from app.database.room import Room, RoomBase, RoomResp from app.database.room_participated_user import RoomParticipatedUser from app.database.score import Score @@ -71,7 +71,7 @@ class APIUploadedRoom(RoomBase): id: int | None host_id: int | None = None - playlist: list[Playlist] + playlist: list[PlaylistBase] = Field(default_factory=list) async def _participate_room( @@ -118,19 +118,12 @@ async def create_room( await db.refresh(db_room) await _participate_room(db_room.id, user_id, db_room, db) - playlist: list[Playlist] = [] - # 处理 APIUploadedRoom 里的 playlist 字段 for item in room.playlist: - # 确保 room_id 正确赋值 item.id = await Playlist.get_next_id_for_room(db_room.id, db) item.room_id = db_room.id item.owner_id = user_id if user_id else 1 db.add(item) - await db.commit() - await db.refresh(item) - playlist.append(item) - await db.refresh(db_room) - db_room.playlist = playlist + await db.commit() await db.refresh(db_room) created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db)) created_room.error = "" @@ -174,9 +167,6 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ await db.commit() await db.refresh(db_room) resp = await RoomResp.from_db(db_room, db) - await db.refresh(db_room) - for item in db_room.playlist: - resp.playlist.append(await PlaylistResp.from_db(item, ["beatmap"])) return resp else: raise HTTPException(404, "room not found0") From e236c06f0f6c9bd0d2cf03d35e639555949f2393 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 06:28:37 +0000 Subject: [PATCH 056/104] feat(multiplayer,playlist): complete the filter for `/rooms` --- app/database/room.py | 2 +- app/models/room.py | 1 + app/router/room.py | 64 +++++++++++++++++++++++++--------- app/signalr/hub/multiplayer.py | 2 +- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/app/database/room.py b/app/database/room.py index f0234e8..3b59ab0 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -76,7 +76,7 @@ class Room(AsyncAttrs, RoomBase, table=True): class RoomResp(RoomBase): id: int - password: str | None = None + has_password: bool = False host: UserResp | None = None playlist: list[PlaylistResp] = [] playlist_item_stats: RoomPlaylistItemStats | None = None diff --git a/app/models/room.py b/app/models/room.py index 392562a..cc257c2 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -10,6 +10,7 @@ class RoomCategory(str, Enum): SPOTLIGHT = "spotlight" FEATURED_ARTIST = "featured_artist" DAILY_CHALLENGE = "daily_challenge" + REALTIME = "realtime" class MatchType(str, Enum): diff --git a/app/router/room.py b/app/router/room.py index c3d4aee..c13574b 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -13,10 +13,8 @@ from app.database.room import Room, RoomBase, RoomResp from app.database.room_participated_user import RoomParticipatedUser from app.database.score import Score from app.dependencies.database import get_db, get_redis -from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user -from app.fetcher import Fetcher -from app.models.room import RoomStatus +from app.models.room import RoomCategory, RoomStatus from app.signalr.hub import MultiplayerHubs from .api_router import router @@ -24,7 +22,7 @@ from .api_router import router from fastapi import Depends, HTTPException, Query from pydantic import BaseModel, Field from redis.asyncio import Redis -from sqlmodel import col, select +from sqlmodel import col, exists, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -32,26 +30,57 @@ from sqlmodel.ext.asyncio.session import AsyncSession async def get_all_rooms( mode: Literal["open", "ended", "participated", "owned", None] = Query( default="open" - ), # TODO: 对房间根据状态进行筛选 - category: str | None = Query(None), # TODO + ), + category: RoomCategory = Query(RoomCategory.NORMAL), status: RoomStatus | None = Query(None), db: AsyncSession = Depends(get_db), - fetcher: Fetcher = Depends(get_fetcher), - redis: Redis = Depends(get_redis), current_user: User = Depends(get_current_user), ): resp_list: list[RoomResp] = [] db_rooms = (await db.exec(select(Room).where(True))).unique().all() + now = datetime.now(UTC) for room in db_rooms: - if category == "realtime": - if room.id in MultiplayerHubs.rooms: - resp_list.append(await RoomResp.from_db(room, db)) - elif category is not None: - if category == room.category: - resp_list.append(await RoomResp.from_db(room, db)) - else: - if room.id not in MultiplayerHubs.rooms: - resp_list.append(await RoomResp.from_db(room, db)) + if category == RoomCategory.REALTIME and room.id not in MultiplayerHubs.rooms: + continue + elif category != RoomCategory.REALTIME and category != room.category: + continue + + if status is not None and room.status != status: + continue + + if ( + mode == "open" + and room.ends_at is not None + and room.ends_at.replace(tzinfo=UTC) < now + ): + continue + if ( + mode == "participated" + and not ( + await db.exec( + select(exists()).where( + RoomParticipatedUser.room_id == room.id, + RoomParticipatedUser.user_id == current_user.id, + ) + ) + ).first() + ): + continue + if mode == "owned" and room.host_id != current_user.id: + continue + if mode == "ended" and ( + room.ends_at is None + or room.ends_at.replace(tzinfo=UTC) < (now - timedelta(days=30)) + ): + continue + + resp = await RoomResp.from_db(room, db) + if category == RoomCategory.REALTIME: + resp.has_password = bool( + MultiplayerHubs.rooms[room.id].room.settings.password.strip() + ) + resp_list.append(resp) + return resp_list @@ -167,6 +196,7 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ await db.commit() await db.refresh(db_room) resp = await RoomResp.from_db(db_room, db) + return resp else: raise HTTPException(404, "room not found0") diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index f7763df..46f24d9 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -181,7 +181,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): async with session: db_room = Room( name=room.settings.name, - category=RoomCategory.NORMAL, + category=RoomCategory.REALTIME, type=room.settings.match_type, queue_mode=room.settings.queue_mode, auto_skip=room.settings.auto_skip, From e22c49d5db12d631c6e706dbea6a955819d5a1d4 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 08:35:31 +0000 Subject: [PATCH 057/104] chore(multiplayer): unready all users when settings were changed --- app/models/multiplayer_hub.py | 10 ++--- app/signalr/hub/multiplayer.py | 82 ++++++++++++++++++++++++++-------- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 13c7119..b0b4187 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -875,7 +875,7 @@ class ServerMultiplayerRoom: await self.stop_countdown(countdown) if countdown.is_exclusive: - await self.stop_all_countdowns() + await self.stop_all_countdowns(countdown.__class__) countdown.id = await self.get_next_countdown_id() info = CountdownInfo(countdown) self.room.active_countdowns.append(info.countdown) @@ -895,12 +895,10 @@ class ServerMultiplayerRoom: if info.task is not None and not info.task.done(): info.task.cancel() - async def stop_all_countdowns(self): + async def stop_all_countdowns(self, typ: type[MultiplayerCountdown]): for countdown in list(self._tracked_countdown.values()): - await self.stop_countdown(countdown.countdown) - - self._tracked_countdown.clear() - self.room.active_countdowns.clear() + if isinstance(countdown.countdown, typ): + await self.stop_countdown(countdown.countdown) class _MatchServerEvent(SignalRUnionMessage): ... diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 46f24d9..9d6ad8e 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -276,12 +276,32 @@ class MultiplayerHub(Hub[MultiplayerClientState]): participated_user.left_at = None participated_user.joined_at = datetime.now(UTC) - room = await session.get(Room, room_id) - if room is None: + db_room = await session.get(Room, room_id) + if db_room is None: raise InvokeException("Room does not exist in database") - room.participant_count += 1 + db_room.participant_count += 1 return room + async def change_beatmap_availability( + self, + room_id: int, + user: MultiplayerRoomUser, + beatmap_availability: BeatmapAvailability, + ): + availability = user.availability + if ( + availability.state == beatmap_availability.state + and availability.download_progress == beatmap_availability.download_progress + ): + return + user.availability = beatmap_availability + await self.broadcast_group_call( + self.group_id(room_id), + "UserBeatmapAvailabilityChanged", + user.user_id, + beatmap_availability, + ) + async def ChangeBeatmapAvailability( self, client: Client, beatmap_availability: BeatmapAvailability ): @@ -295,19 +315,10 @@ class MultiplayerHub(Hub[MultiplayerClientState]): user = next((u for u in room.users if u.user_id == client.user_id), None) if user is None: raise InvokeException("You are not in this room") - - availability = user.availability - if ( - availability.state == beatmap_availability.state - and availability.download_progress == beatmap_availability.download_progress - ): - return - user.availability = beatmap_availability - await self.broadcast_group_call( - self.group_id(store.room_id), - "UserBeatmapAvailabilityChanged", - user.user_id, - (beatmap_availability), + await self.change_beatmap_availability( + room.room_id, + user, + beatmap_availability, ) async def AddPlaylistItem(self, client: Client, item: PlaylistItem): @@ -365,17 +376,19 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def setting_changed(self, room: ServerMultiplayerRoom, beatmap_changed: bool): + await self.validate_styles(room) + await self.unready_all_users(room, beatmap_changed) await self.broadcast_group_call( self.group_id(room.room.room_id), "SettingsChanged", - (room.room.settings), + room.room.settings, ) async def playlist_added(self, room: ServerMultiplayerRoom, item: PlaylistItem): await self.broadcast_group_call( self.group_id(room.room.room_id), "PlaylistItemAdded", - (item), + item, ) async def playlist_removed(self, room: ServerMultiplayerRoom, item_id: int): @@ -388,10 +401,13 @@ class MultiplayerHub(Hub[MultiplayerClientState]): async def playlist_changed( self, room: ServerMultiplayerRoom, item: PlaylistItem, beatmap_changed: bool ): + if item.id == room.room.settings.playlist_item_id: + await self.validate_styles(room) + await self.unready_all_users(room, beatmap_changed) await self.broadcast_group_call( self.group_id(room.room.room_id), "PlaylistItemChanged", - (item), + item, ) async def ChangeUserStyle( @@ -1066,6 +1082,7 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def ChangeSettings(self, client: Client, settings: MultiplayerRoomSettings): + print(settings) store = self.get_or_create_state(client) if store.room_id == 0: raise InvokeException("You are not in a room") @@ -1202,3 +1219,30 @@ class MultiplayerHub(Hub[MultiplayerClientState]): room.room_id, room.settings.password, ) + + async def unready_all_users( + self, room: ServerMultiplayerRoom, reset_beatmap_availability: bool + ): + await asyncio.gather( + *[ + self.change_user_state( + room, + user, + MultiplayerUserState.IDLE, + ) + for user in room.room.users + if user.state == MultiplayerUserState.READY + ] + ) + if reset_beatmap_availability: + await asyncio.gather( + *[ + self.change_beatmap_availability( + room.room.room_id, + user, + BeatmapAvailability(state=DownloadState.UNKNOWN), + ) + for user in room.room.users + ] + ) + await room.stop_all_countdowns(MatchStartCountdown) From 076b9d901b72f22a349c5b39046bb74f271fbb7c Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 08:36:24 +0000 Subject: [PATCH 058/104] refactor(lounge): improve performance for list rooms --- app/models/room.py | 2 +- app/router/room.py | 70 ++++++++++++++++++++++++---------------------- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/app/models/room.py b/app/models/room.py index cc257c2..3cba32f 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -10,7 +10,7 @@ class RoomCategory(str, Enum): SPOTLIGHT = "spotlight" FEATURED_ARTIST = "featured_artist" DAILY_CHALLENGE = "daily_challenge" - REALTIME = "realtime" + REALTIME = "realtime" # INTERNAL USE ONLY, DO NOT USE IN API class MatchType(str, Enum): diff --git a/app/router/room.py b/app/router/room.py index c13574b..78e08dd 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -22,6 +22,7 @@ from .api_router import router from fastapi import Depends, HTTPException, Query from pydantic import BaseModel, Field from redis.asyncio import Redis +from sqlalchemy.sql.elements import ColumnElement from sqlmodel import col, exists, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -37,48 +38,51 @@ async def get_all_rooms( current_user: User = Depends(get_current_user), ): resp_list: list[RoomResp] = [] - db_rooms = (await db.exec(select(Room).where(True))).unique().all() + where_clauses: list[ColumnElement[bool]] = [col(Room.category) == category] now = datetime.now(UTC) - for room in db_rooms: - if category == RoomCategory.REALTIME and room.id not in MultiplayerHubs.rooms: - continue - elif category != RoomCategory.REALTIME and category != room.category: - continue + if status is not None: + where_clauses.append(col(Room.status) == status) + if mode == "open": + where_clauses.append( + (col(Room.ends_at).is_(None)) + | (col(Room.ends_at) > now.replace(tzinfo=UTC)) + ) + if category == RoomCategory.REALTIME: + where_clauses.append(col(Room.id).in_(MultiplayerHubs.rooms.keys())) + if mode == "participated": + where_clauses.append( + exists().where( + col(RoomParticipatedUser.room_id) == Room.id, + col(RoomParticipatedUser.user_id) == current_user.id, + ) + ) + if mode == "owned": + where_clauses.append(col(Room.host_id) == current_user.id) + if mode == "ended": + where_clauses.append( + (col(Room.ends_at).is_not(None)) + & (col(Room.ends_at) < now.replace(tzinfo=UTC)) + ) - if status is not None and room.status != status: - continue - - if ( - mode == "open" - and room.ends_at is not None - and room.ends_at.replace(tzinfo=UTC) < now - ): - continue - if ( - mode == "participated" - and not ( - await db.exec( - select(exists()).where( - RoomParticipatedUser.room_id == room.id, - RoomParticipatedUser.user_id == current_user.id, - ) + db_rooms = ( + ( + await db.exec( + select(Room).where( + *where_clauses, ) - ).first() - ): - continue - if mode == "owned" and room.host_id != current_user.id: - continue - if mode == "ended" and ( - room.ends_at is None - or room.ends_at.replace(tzinfo=UTC) < (now - timedelta(days=30)) - ): - continue + ) + ) + .unique() + .all() + ) + for room in db_rooms: resp = await RoomResp.from_db(room, db) if category == RoomCategory.REALTIME: resp.has_password = bool( MultiplayerHubs.rooms[room.id].room.settings.password.strip() ) + resp.category = RoomCategory.NORMAL resp_list.append(resp) return resp_list From 832a6fc95dcd5a2d21dd48b51e18feb7b3540d4a Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 11:18:29 +0000 Subject: [PATCH 059/104] feat(daily-challenge): simple implement --- app/database/__init__.py | 3 +- app/database/playlists.py | 2 +- app/database/room.py | 15 +++++ app/dependencies/scheduler.py | 26 +++++++++ app/models/metadata_hub.py | 4 ++ app/router/room.py | 44 +++----------- app/router/score.py | 11 ++++ app/service/__init__.py | 10 ++++ app/service/daily_challenge.py | 103 +++++++++++++++++++++++++++++++++ app/service/room.py | 78 +++++++++++++++++++++++++ app/signalr/hub/metadata.py | 28 ++++++++- main.py | 3 + pyproject.toml | 1 + uv.lock | 37 +++++++++++- 14 files changed, 323 insertions(+), 42 deletions(-) create mode 100644 app/dependencies/scheduler.py create mode 100644 app/service/__init__.py create mode 100644 app/service/daily_challenge.py create mode 100644 app/service/room.py diff --git a/app/database/__init__.py b/app/database/__init__.py index f401e56..7030ae7 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -25,7 +25,7 @@ from .playlist_best_score import PlaylistBestScore from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore from .relationship import Relationship, RelationshipResp, RelationshipType -from .room import Room, RoomResp +from .room import APIUploadedRoom, Room, RoomResp from .room_participated_user import RoomParticipatedUser from .score import ( MultiplayerScores, @@ -48,6 +48,7 @@ from .user_account_history import ( ) __all__ = [ + "APIUploadedRoom", "Beatmap", "Beatmapset", "BeatmapsetResp", diff --git a/app/database/playlists.py b/app/database/playlists.py index 3f7ae40..c177432 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -138,6 +138,6 @@ class PlaylistResp(PlaylistBase): ) -> "PlaylistResp": data = playlist.model_dump() if "beatmap" in include: - data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True) + data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap) resp = cls.model_validate(data) return resp diff --git a/app/database/room.py b/app/database/room.py index 3b59ab0..368a04a 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -160,3 +160,18 @@ class RoomResp(RoomBase): participant_count=len(room.users), ) return resp + + +class APIUploadedRoom(RoomBase): + def to_room(self) -> Room: + """ + 将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。 + """ + room_dict = self.model_dump() + room_dict.pop("playlist", None) + # host_id 已在字段中 + return Room(**room_dict) + + id: int | None + host_id: int | None = None + playlist: list[Playlist] = Field(default_factory=list) diff --git a/app/dependencies/scheduler.py b/app/dependencies/scheduler.py new file mode 100644 index 0000000..fa20396 --- /dev/null +++ b/app/dependencies/scheduler.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import UTC + +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +scheduler: AsyncIOScheduler | None = None + + +def init_scheduler(): + global scheduler + scheduler = AsyncIOScheduler(timezone=UTC) + scheduler.start() + + +def get_scheduler() -> AsyncIOScheduler: + global scheduler + if scheduler is None: + init_scheduler() + return scheduler # pyright: ignore[reportReturnType] + + +def stop_scheduler(): + global scheduler + if scheduler: + scheduler.shutdown() diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index 684ab54..7ef2b7a 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -123,3 +123,7 @@ class OnlineStatus(IntEnum): OFFLINE = 0 # 隐身 DO_NOT_DISTURB = 1 ONLINE = 2 + + +class DailyChallengeInfo(BaseModel): + room_id: int diff --git a/app/router/room.py b/app/router/room.py index 78e08dd..6918364 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from typing import Literal from app.database.beatmap import Beatmap, BeatmapResp @@ -8,13 +8,14 @@ from app.database.beatmapset import BeatmapsetResp from app.database.lazer_user import User, UserResp from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp -from app.database.playlists import Playlist, PlaylistBase, PlaylistResp -from app.database.room import Room, RoomBase, RoomResp +from app.database.playlists import Playlist, PlaylistResp +from app.database.room import APIUploadedRoom, Room, RoomResp from app.database.room_participated_user import RoomParticipatedUser from app.database.score import Score from app.dependencies.database import get_db, get_redis from app.dependencies.user import get_current_user from app.models.room import RoomCategory, RoomStatus +from app.service.room import create_playlist_room_from_api from app.signalr.hub import MultiplayerHubs from .api_router import router @@ -92,21 +93,6 @@ class APICreatedRoom(RoomResp): error: str = "" -class APIUploadedRoom(RoomBase): - def to_room(self) -> Room: - """ - 将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。 - """ - room_dict = self.model_dump() - room_dict.pop("playlist", None) - # host_id 已在字段中 - return Room(**room_dict) - - id: int | None - host_id: int | None = None - playlist: list[PlaylistBase] = Field(default_factory=list) - - async def _participate_room( room_id: int, user_id: int, db_room: Room, session: AsyncSession ): @@ -137,27 +123,11 @@ async def create_room( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - # db_room = Room.from_resp(room) - await db.refresh(current_user) user_id = current_user.id - db_room = room.to_room() - db_room.host_id = current_user.id if current_user.id else 1 - db_room.starts_at = datetime.now(UTC) - db_room.ends_at = db_room.starts_at + timedelta( - minutes=db_room.duration if db_room.duration is not None else 0 - ) - db.add(db_room) - await db.commit() - await db.refresh(db_room) + db_room = await create_playlist_room_from_api(db, room, user_id) await _participate_room(db_room.id, user_id, db_room, db) - - for item in room.playlist: - item.id = await Playlist.get_next_id_for_room(db_room.id, db) - item.room_id = db_room.id - item.owner_id = user_id if user_id else 1 - db.add(item) - await db.commit() - await db.refresh(db_room) + # await db.commit() + # await db.refresh(db_room) created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db)) created_room.error = "" return created_room diff --git a/app/router/score.py b/app/router/score.py index 70664a5..506ebac 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -32,6 +32,7 @@ from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher from app.models.beatmap import BeatmapRankStatus +from app.models.room import RoomCategory from app.models.score import ( INT_TO_MODE, GameMode, @@ -402,6 +403,10 @@ async def index_playlist_scores( current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db), ): + room = await session.get(Room, room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + limit = clamp(limit, 1, 50) scores = ( @@ -426,6 +431,12 @@ async def index_playlist_scores( score.position = await get_position(room_id, playlist_id, score.id, session) if score.user_id == current_user.id: user_score = score + + if room.category == RoomCategory.DAILY_CHALLENGE: + score_resp = [s for s in score_resp if s.passed] + if user_score and not user_score.passed: + user_score = None + resp = IndexedScoreResp( scores=score_resp, user_score=user_score, diff --git a/app/service/__init__.py b/app/service/__init__.py new file mode 100644 index 0000000..cbb83a2 --- /dev/null +++ b/app/service/__init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from .daily_challenge import create_daily_challenge_room +from .room import create_playlist_room, create_playlist_room_from_api + +__all__ = [ + "create_daily_challenge_room", + "create_playlist_room", + "create_playlist_room_from_api", +] diff --git a/app/service/daily_challenge.py b/app/service/daily_challenge.py new file mode 100644 index 0000000..1f92034 --- /dev/null +++ b/app/service/daily_challenge.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import json + +from app.database.playlists import Playlist +from app.database.room import Room +from app.dependencies.database import engine, get_redis +from app.dependencies.scheduler import get_scheduler +from app.log import logger +from app.models.metadata_hub import DailyChallengeInfo +from app.models.mods import APIMod +from app.models.room import RoomCategory +from app.signalr.hub import MetadataHubs + +from .room import create_playlist_room + +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def create_daily_challenge_room( + beatmap: int, ruleset_id: int, required_mods: list[APIMod] = [] +) -> Room: + async with AsyncSession(engine) as session: + today = datetime.now(UTC).date() + return await create_playlist_room( + session=session, + name=str(today), + host_id=3, + playlist=[ + Playlist( + id=0, + room_id=0, + owner_id=3, + ruleset_id=ruleset_id, + beatmap_id=beatmap, + required_mods=required_mods, + ) + ], + category=RoomCategory.DAILY_CHALLENGE, + duration=24 * 60 - 2, # remain 2 minute to apply new daily challenge + ) + + +@get_scheduler().scheduled_job("cron", hour=0, minute=0, second=0, id="daily_challenge") +async def daily_challenge_job(): + today = datetime.now(UTC).date() + redis = get_redis() + key = f"daily_challenge:{today.year}-{today.month}-{today.day}" + if not await redis.exists(key): + return + try: + beatmap = await redis.hget(key, "beatmap") # pyright: ignore[reportGeneralTypeIssues] + ruleset_id = await redis.hget(key, "ruleset_id") # pyright: ignore[reportGeneralTypeIssues] + required_mods = await redis.hget(key, "required_mods") # pyright: ignore[reportGeneralTypeIssues] + + if beatmap is None or ruleset_id is None: + logger.warning( + f"[DailyChallenge] Missing required data for daily challenge {today}." + " Will try again in 5 minutes." + ) + get_scheduler().add_job( + daily_challenge_job, + "date", + run_date=datetime.now(UTC) + timedelta(minutes=5), + ) + return + + beatmap_int = int(beatmap) + ruleset_id_int = int(ruleset_id) + + mods_list = [] + if required_mods: + mods_list = json.loads(required_mods) + + room = await create_daily_challenge_room( + beatmap=beatmap_int, + ruleset_id=ruleset_id_int, + required_mods=mods_list, + ) + await MetadataHubs.broadcast_call( + "DailyChallengeUpdated", DailyChallengeInfo(room_id=room.id) + ) + logger.success( + "[DailyChallenge] Added today's daily challenge: " + f"{beatmap=}, {ruleset_id=}, {required_mods=}" + ) + return + except (ValueError, json.JSONDecodeError) as e: + logger.warning( + f"[DailyChallenge] Error processing daily challenge data: {e}" + " Will try again in 5 minutes." + ) + except Exception as e: + logger.exception( + f"[DailyChallenge] Unexpected error in daily challenge job: {e}" + " Will try again in 5 minutes." + ) + get_scheduler().add_job( + daily_challenge_job, + "date", + run_date=datetime.now(UTC) + timedelta(minutes=5), + ) diff --git a/app/service/room.py b/app/service/room.py new file mode 100644 index 0000000..d11dced --- /dev/null +++ b/app/service/room.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from app.database.beatmap import Beatmap +from app.database.playlists import Playlist +from app.database.room import APIUploadedRoom, Room +from app.dependencies.fetcher import get_fetcher +from app.models.room import MatchType, QueueMode, RoomCategory, RoomStatus + +from sqlalchemy import exists +from sqlmodel import col, select +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def create_playlist_room_from_api( + session: AsyncSession, room: APIUploadedRoom, host_id: int +) -> Room: + db_room = room.to_room() + db_room.host_id = host_id + db_room.starts_at = datetime.now(UTC) + db_room.ends_at = db_room.starts_at + timedelta( + minutes=db_room.duration if db_room.duration is not None else 0 + ) + session.add(db_room) + await session.commit() + await session.refresh(db_room) + await add_playlists_to_room(session, db_room.id, room.playlist, host_id) + await session.refresh(db_room) + return db_room + + +async def create_playlist_room( + session: AsyncSession, + name: str, + host_id: int, + category: RoomCategory = RoomCategory.NORMAL, + duration: int = 30, + max_attempts: int | None = None, + playlist: list[Playlist] = [], +) -> Room: + db_room = Room( + name=name, + category=category, + duration=duration, + starts_at=datetime.now(UTC), + ends_at=datetime.now(UTC) + timedelta(minutes=duration), + participant_count=0, + max_attempts=max_attempts, + type=MatchType.PLAYLISTS, + queue_mode=QueueMode.HOST_ONLY, + auto_skip=False, + auto_start_duration=0, + status=RoomStatus.IDLE, + host_id=host_id, + ) + session.add(db_room) + await session.commit() + await session.refresh(db_room) + await add_playlists_to_room(session, db_room.id, playlist, host_id) + await session.refresh(db_room) + return db_room + + +async def add_playlists_to_room( + session: AsyncSession, room_id: int, playlist: list[Playlist], owner_id: int +): + for item in playlist: + if not ( + await session.exec(select(exists().where(col(Beatmap.id) == item.beatmap))) + ).first(): + fetcher = await get_fetcher() + await Beatmap.get_or_fetch(session, fetcher, item.beatmap_id) + item.id = await Playlist.get_next_id_for_room(room_id, session) + item.room_id = room_id + item.owner_id = owner_id + session.add(item) + await session.commit() diff --git a/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py index 08ee035..ef21c93 100644 --- a/app/signalr/hub/metadata.py +++ b/app/signalr/hub/metadata.py @@ -6,12 +6,19 @@ from datetime import UTC, datetime from typing import override from app.database import Relationship, RelationshipType, User +from app.database.room import Room from app.dependencies.database import engine, get_redis -from app.models.metadata_hub import MetadataClientState, OnlineStatus, UserActivity +from app.models.metadata_hub import ( + DailyChallengeInfo, + MetadataClientState, + OnlineStatus, + UserActivity, +) +from app.models.room import RoomCategory from .hub import Client, Hub -from sqlmodel import select +from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers" @@ -107,6 +114,23 @@ class MetadataHub(Hub[MetadataClientState]): ) ) await asyncio.gather(*tasks) + + daily_challenge_room = ( + await session.exec( + select(Room).where( + col(Room.ends_at) > datetime.now(UTC), + Room.category == RoomCategory.DAILY_CHALLENGE, + ) + ) + ).first() + if daily_challenge_room: + await self.call_noblock( + client, + "DailyChallengeUpdated", + DailyChallengeInfo( + room_id=daily_challenge_room.id, + ), + ) redis = get_redis() await redis.set(f"metadata:online:{user_id}", "") diff --git a/main.py b/main.py index b12f543..31cdd44 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ from datetime import datetime from app.config import settings from app.dependencies.database import create_tables, engine, redis_client from app.dependencies.fetcher import get_fetcher +from app.dependencies.scheduler import init_scheduler, stop_scheduler from app.router import ( api_router, auth_router, @@ -21,8 +22,10 @@ async def lifespan(app: FastAPI): # on startup await create_tables() await get_fetcher() # 初始化 fetcher + init_scheduler() # on shutdown yield + stop_scheduler() await engine.dispose() await redis_client.aclose() diff --git a/pyproject.toml b/pyproject.toml index cd90947..3ab61c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12" dependencies = [ "aiomysql>=0.2.0", "alembic>=1.12.1", + "apscheduler>=3.11.0", "bcrypt>=4.1.2", "cryptography>=41.0.7", "fastapi>=0.104.1", diff --git a/uv.lock b/uv.lock index 3fc7d3c..22a4f1d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [manifest] @@ -57,6 +57,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[package]] +name = "apscheduler" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -493,6 +505,7 @@ source = { virtual = "." } dependencies = [ { name = "aiomysql" }, { name = "alembic" }, + { name = "apscheduler" }, { name = "bcrypt" }, { name = "cryptography" }, { name = "fastapi" }, @@ -522,6 +535,7 @@ dev = [ requires-dist = [ { name = "aiomysql", specifier = ">=0.2.0" }, { name = "alembic", specifier = ">=1.12.1" }, + { name = "apscheduler", specifier = ">=3.11.0" }, { name = "bcrypt", specifier = ">=4.1.2" }, { name = "cryptography", specifier = ">=41.0.7" }, { name = "fastapi", specifier = ">=0.104.1" }, @@ -904,6 +918,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "uvicorn" version = "0.35.0" From 8531e6742363c904a3a187779cf6b6e1f7c75df3 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 12:09:23 +0000 Subject: [PATCH 060/104] feat(redis): add subscriber for pub/sub mode --- app/dependencies/database.py | 4 ++++ app/service/daily_challenge.py | 3 ++- app/service/subscriber.py | 20 ++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 app/service/subscriber.py diff --git a/app/dependencies/database.py b/app/dependencies/database.py index 77b15c3..a6b6e5c 100644 --- a/app/dependencies/database.py +++ b/app/dependencies/database.py @@ -38,3 +38,7 @@ async def create_tables(): # Redis 依赖 def get_redis(): return redis_client + + +def get_redis_pubsub(channel: str | None = None): + return redis_client.pubsub(ignore_subscribe_messages=True, channel=channel) diff --git a/app/service/daily_challenge.py b/app/service/daily_challenge.py index 1f92034..b2a6eb5 100644 --- a/app/service/daily_challenge.py +++ b/app/service/daily_challenge.py @@ -11,7 +11,6 @@ from app.log import logger from app.models.metadata_hub import DailyChallengeInfo from app.models.mods import APIMod from app.models.room import RoomCategory -from app.signalr.hub import MetadataHubs from .room import create_playlist_room @@ -44,6 +43,8 @@ async def create_daily_challenge_room( @get_scheduler().scheduled_job("cron", hour=0, minute=0, second=0, id="daily_challenge") async def daily_challenge_job(): + from app.signalr.hub import MetadataHubs + today = datetime.now(UTC).date() redis = get_redis() key = f"daily_challenge:{today.year}-{today.month}-{today.day}" diff --git a/app/service/subscriber.py b/app/service/subscriber.py new file mode 100644 index 0000000..39478ab --- /dev/null +++ b/app/service/subscriber.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from app.dependencies.database import get_redis_pubsub + + +class RedisSubscriber: + def __init__(self, channel: str): + self.pubsub = get_redis_pubsub(channel) + self.handlers: dict[str, list[Callable[[str, str], Awaitable[Any]]]] = {} + + async def listen(self): + async for message in self.pubsub.listen(): + if message is not None and message["type"] == "message": + method = self.handlers.get(message["channel"]) + if method: + for handler in method: + await handler(message["channel"], message["data"]) From 5fe3f36055fdadb03a4e924e52fa48b152c050cf Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 14:34:46 +0000 Subject: [PATCH 061/104] feat(daily-challenge): complete daily-challenge --- app/database/score.py | 1 + app/dependencies/database.py | 4 +- app/models/metadata_hub.py | 32 ++++++- app/router/score.py | 6 +- app/service/subscriber.py | 20 ---- app/service/subscribers/base.py | 48 ++++++++++ app/service/subscribers/score_processed.py | 87 +++++++++++++++++ app/signalr/hub/metadata.py | 105 +++++++++++++++++++++ app/signalr/hub/multiplayer.py | 1 - 9 files changed, 279 insertions(+), 25 deletions(-) delete mode 100644 app/service/subscriber.py create mode 100644 app/service/subscribers/base.py create mode 100644 app/service/subscribers/score_processed.py diff --git a/app/database/score.py b/app/database/score.py index 37b96a3..289660e 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -696,4 +696,5 @@ async def process_score( await session.refresh(score) await session.refresh(score_token) await session.refresh(user) + await redis.publish("score:processed", score.id) return score diff --git a/app/dependencies/database.py b/app/dependencies/database.py index a6b6e5c..e74af93 100644 --- a/app/dependencies/database.py +++ b/app/dependencies/database.py @@ -40,5 +40,5 @@ def get_redis(): return redis_client -def get_redis_pubsub(channel: str | None = None): - return redis_client.pubsub(ignore_subscribe_messages=True, channel=channel) +def get_redis_pubsub(): + return redis_client.pubsub() diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index 7ef2b7a..8bf237d 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -5,7 +5,9 @@ from typing import ClassVar, Literal from app.models.signalr import SignalRUnionMessage, UserState -from pydantic import BaseModel +from pydantic import BaseModel, Field + +TOTAL_SCORE_DISTRIBUTION_BINS = 13 class _UserActivity(SignalRUnionMessage): ... @@ -96,6 +98,7 @@ UserActivity = ( | ModdingBeatmap | TestingBeatmap | InDailyChallengeLobby + | PlayingDailyChallenge ) @@ -127,3 +130,30 @@ class OnlineStatus(IntEnum): class DailyChallengeInfo(BaseModel): room_id: int + + +class MultiplayerPlaylistItemStats(BaseModel): + playlist_item_id: int = 0 + total_score_distribution: list[int] = Field( + default_factory=list, + min_length=TOTAL_SCORE_DISTRIBUTION_BINS, + max_length=TOTAL_SCORE_DISTRIBUTION_BINS, + ) + cumulative_score: int = 0 + last_processed_score_id: int = 0 + + +class MultiplayerRoomStats(BaseModel): + room_id: int + playlist_item_stats: dict[int, MultiplayerPlaylistItemStats] = Field( + default_factory=dict + ) + + +class MultiplayerRoomScoreSetEvent(BaseModel): + room_id: int + playlist_item_id: int + score_id: int + user_id: int + total_score: int + new_rank: int | None = None diff --git a/app/router/score.py b/app/router/score.py index 506ebac..d826fd0 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -464,9 +464,13 @@ async def show_playlist_score( session: AsyncSession = Depends(get_db), redis: Redis = Depends(get_redis), ): + room = await session.get(Room, room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + start_time = time.time() score_record = None - completed = False + completed = room.category != RoomCategory.REALTIME while time.time() - start_time < READ_SCORE_TIMEOUT: if score_record is None: score_record = ( diff --git a/app/service/subscriber.py b/app/service/subscriber.py deleted file mode 100644 index 39478ab..0000000 --- a/app/service/subscriber.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from typing import Any - -from app.dependencies.database import get_redis_pubsub - - -class RedisSubscriber: - def __init__(self, channel: str): - self.pubsub = get_redis_pubsub(channel) - self.handlers: dict[str, list[Callable[[str, str], Awaitable[Any]]]] = {} - - async def listen(self): - async for message in self.pubsub.listen(): - if message is not None and message["type"] == "message": - method = self.handlers.get(message["channel"]) - if method: - for handler in method: - await handler(message["channel"], message["data"]) diff --git a/app/service/subscribers/base.py b/app/service/subscribers/base.py new file mode 100644 index 0000000..144dfd0 --- /dev/null +++ b/app/service/subscribers/base.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + +from app.dependencies.database import get_redis_pubsub + + +class RedisSubscriber: + def __init__(self): + self.pubsub = get_redis_pubsub() + self.handlers: dict[str, list[Callable[[str, str], Awaitable[Any]]]] = {} + self.task: asyncio.Task | None = None + + async def subscribe(self, channel: str): + await self.pubsub.subscribe(channel) + if channel not in self.handlers: + self.handlers[channel] = [] + + async def unsubscribe(self, channel: str): + if channel in self.handlers: + del self.handlers[channel] + await self.pubsub.unsubscribe(channel) + + async def listen(self): + while True: + message = await self.pubsub.get_message( + ignore_subscribe_messages=True, timeout=None + ) + if message is not None and message["type"] == "message": + method = self.handlers.get(message["channel"]) + if method: + await asyncio.gather( + *[ + handler(message["channel"], message["data"]) + for handler in method + ] + ) + + def start(self): + if self.task is None or self.task.done(): + self.task = asyncio.create_task(self.listen()) + + def stop(self): + if self.task is not None and not self.task.done(): + self.task.cancel() + self.task = None diff --git a/app/service/subscribers/score_processed.py b/app/service/subscribers/score_processed.py new file mode 100644 index 0000000..b1bc5bd --- /dev/null +++ b/app/service/subscribers/score_processed.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.database import PlaylistBestScore, Score +from app.database.playlist_best_score import get_position +from app.dependencies.database import engine +from app.models.metadata_hub import MultiplayerRoomScoreSetEvent + +from .base import RedisSubscriber + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +if TYPE_CHECKING: + from app.signalr.hub import MetadataHub + + +CHANNEL = "score:processed" + + +class ScoreSubscriber(RedisSubscriber): + def __init__(self): + super().__init__() + self.room_subscriber: dict[int, list[int]] = {} + self.metadata_hub: "MetadataHub | None " = None + self.subscribed = False + self.handlers[CHANNEL] = [self._handler] + + async def subscribe_room_score(self, room_id: int, user_id: int): + if room_id not in self.room_subscriber: + await self.subscribe(CHANNEL) + self.start() + self.room_subscriber.setdefault(room_id, []).append(user_id) + + async def unsubscribe_room_score(self, room_id: int, user_id: int): + if room_id in self.room_subscriber: + self.room_subscriber[room_id].remove(user_id) + if not self.room_subscriber[room_id]: + del self.room_subscriber[room_id] + + async def _notify_room_score_processed(self, score_id: int): + if not self.metadata_hub: + return + async with AsyncSession(engine) as session: + score = await session.get(Score, score_id) + if ( + not score + or not score.passed + or score.room_id is None + or score.playlist_item_id is None + ): + return + if not self.room_subscriber.get(score.room_id, []): + return + + new_rank = None + user_best = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.user_id == score.user_id, + PlaylistBestScore.room_id == score.room_id, + ) + ) + ).first() + if user_best and user_best.score_id == score_id: + new_rank = await get_position( + user_best.room_id, + user_best.playlist_id, + user_best.score_id, + session, + ) + + event = MultiplayerRoomScoreSetEvent( + room_id=score.room_id, + playlist_item_id=score.playlist_item_id, + score_id=score_id, + user_id=score.user_id, + total_score=score.total_score, + new_rank=new_rank, + ) + await self.metadata_hub.notify_room_score_processed(event) + + async def _handler(self, channel: str, data: str): + score_id = int(data) + if self.metadata_hub: + await self._notify_room_score_processed(score_id) diff --git a/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py index ef21c93..f81aefa 100644 --- a/app/signalr/hub/metadata.py +++ b/app/signalr/hub/metadata.py @@ -1,20 +1,30 @@ from __future__ import annotations import asyncio +from collections import defaultdict from collections.abc import Coroutine from datetime import UTC, datetime +import math from typing import override +from app.calculator import clamp from app.database import Relationship, RelationshipType, User +from app.database.playlist_best_score import PlaylistBestScore +from app.database.playlists import Playlist from app.database.room import Room from app.dependencies.database import engine, get_redis from app.models.metadata_hub import ( + TOTAL_SCORE_DISTRIBUTION_BINS, DailyChallengeInfo, MetadataClientState, + MultiplayerPlaylistItemStats, + MultiplayerRoomScoreSetEvent, + MultiplayerRoomStats, OnlineStatus, UserActivity, ) from app.models.room import RoomCategory +from app.service.subscribers.score_processed import ScoreSubscriber from .hub import Client, Hub @@ -27,11 +37,33 @@ ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers" class MetadataHub(Hub[MetadataClientState]): def __init__(self) -> None: super().__init__() + self.subscriber = ScoreSubscriber() + self.subscriber.metadata_hub = self + self._daily_challenge_stats: MultiplayerRoomStats | None = None + self._today = datetime.now(UTC).date() + self._lock = asyncio.Lock() + + def get_daily_challenge_stats( + self, daily_challenge_room: int + ) -> MultiplayerRoomStats: + if ( + self._daily_challenge_stats is None + or self._today != datetime.now(UTC).date() + ): + self._daily_challenge_stats = MultiplayerRoomStats( + room_id=daily_challenge_room, + playlist_item_stats={}, + ) + return self._daily_challenge_stats @staticmethod def online_presence_watchers_group() -> str: return ONLINE_PRESENCE_WATCHERS_GROUP + @staticmethod + def room_watcher_group(room_id: int) -> str: + return f"metadata:multiplayer-room-watchers:{room_id}" + def broadcast_tasks( self, user_id: int, store: MetadataClientState | None ) -> set[Coroutine]: @@ -186,3 +218,76 @@ class MetadataHub(Hub[MetadataClientState]): async def EndWatchingUserPresence(self, client: Client) -> None: self.remove_from_group(client, self.online_presence_watchers_group()) + + async def notify_room_score_processed(self, event: MultiplayerRoomScoreSetEvent): + await self.broadcast_group_call( + self.room_watcher_group(event.room_id), "MultiplayerRoomScoreSet", event + ) + + async def BeginWatchingMultiplayerRoom(self, client: Client, room_id: int): + self.add_to_group(client, self.room_watcher_group(room_id)) + await self.subscriber.subscribe_room_score(room_id, client.user_id) + stats = self.get_daily_challenge_stats(room_id) + await self.update_daily_challenge_stats(stats) + return list(stats.playlist_item_stats.values()) + + async def update_daily_challenge_stats(self, stats: MultiplayerRoomStats) -> None: + async with AsyncSession(engine) as session: + playlist_ids = ( + await session.exec( + select(Playlist.id).where( + Playlist.room_id == stats.room_id, + ) + ) + ).all() + for playlist_id in playlist_ids: + item = stats.playlist_item_stats.get(playlist_id, None) + if item is None: + item = MultiplayerPlaylistItemStats( + playlist_item_id=playlist_id, + total_score_distribution=[0] * TOTAL_SCORE_DISTRIBUTION_BINS, + cumulative_score=0, + last_processed_score_id=0, + ) + stats.playlist_item_stats[playlist_id] = item + last_processed_score_id = item.last_processed_score_id + scores = ( + await session.exec( + select(PlaylistBestScore).where( + PlaylistBestScore.room_id == stats.room_id, + PlaylistBestScore.playlist_id == playlist_id, + PlaylistBestScore.score_id > last_processed_score_id, + ) + ) + ).all() + if len(scores) == 0: + continue + + async with self._lock: + if item.last_processed_score_id == last_processed_score_id: + totals = defaultdict(int) + for score in scores: + bin_index = int( + clamp( + math.floor(score.total_score / 100000), + 0, + TOTAL_SCORE_DISTRIBUTION_BINS - 1, + ) + ) + totals[bin_index] += 1 + + item.cumulative_score += sum( + score.total_score for score in scores + ) + + for j in range(TOTAL_SCORE_DISTRIBUTION_BINS): + item.total_score_distribution[j] += totals.get(j, 0) + + if scores: + item.last_processed_score_id = max( + score.score_id for score in scores + ) + + async def EndWatchingMultiplayerRoom(self, client: Client, room_id: int): + self.remove_from_group(client, self.room_watcher_group(room_id)) + await self.subscriber.unsubscribe_room_score(room_id, client.user_id) diff --git a/app/signalr/hub/multiplayer.py b/app/signalr/hub/multiplayer.py index 9d6ad8e..e397031 100644 --- a/app/signalr/hub/multiplayer.py +++ b/app/signalr/hub/multiplayer.py @@ -1082,7 +1082,6 @@ class MultiplayerHub(Hub[MultiplayerClientState]): ) async def ChangeSettings(self, client: Client, settings: MultiplayerRoomSettings): - print(settings) store = self.get_or_create_state(client) if store.room_id == 0: raise InvokeException("You are not in a room") From dcdbac8cb06b6f62a80bf0ac4186e5b99bc93c3e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 14:39:20 +0000 Subject: [PATCH 062/104] chore(daily-challenge): update redis key --- app/service/daily_challenge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/service/daily_challenge.py b/app/service/daily_challenge.py index b2a6eb5..cca07b7 100644 --- a/app/service/daily_challenge.py +++ b/app/service/daily_challenge.py @@ -47,7 +47,7 @@ async def daily_challenge_job(): today = datetime.now(UTC).date() redis = get_redis() - key = f"daily_challenge:{today.year}-{today.month}-{today.day}" + key = f"daily_challenge:{today}" if not await redis.exists(key): return try: From 7f224aee8d6b417b8551fc4f35e7ad7d23515291 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 14:59:51 +0000 Subject: [PATCH 063/104] feat(daily-challenge): create on startup --- app/service/daily_challenge.py | 13 +++++++++++++ main.py | 2 ++ 2 files changed, 15 insertions(+) diff --git a/app/service/daily_challenge.py b/app/service/daily_challenge.py index cca07b7..ea93da7 100644 --- a/app/service/daily_challenge.py +++ b/app/service/daily_challenge.py @@ -14,6 +14,7 @@ from app.models.room import RoomCategory from .room import create_playlist_room +from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -50,6 +51,18 @@ async def daily_challenge_job(): key = f"daily_challenge:{today}" if not await redis.exists(key): return + async with AsyncSession(engine) as session: + room = ( + await session.exec( + select(Room).where( + Room.category == RoomCategory.DAILY_CHALLENGE, + col(Room.ends_at) > datetime.now(UTC), + ) + ) + ).first() + if room: + return + try: beatmap = await redis.hget(key, "beatmap") # pyright: ignore[reportGeneralTypeIssues] ruleset_id = await redis.hget(key, "ruleset_id") # pyright: ignore[reportGeneralTypeIssues] diff --git a/main.py b/main.py index 31cdd44..8569afb 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,7 @@ from app.router import ( fetcher_router, signalr_router, ) +from app.service.daily_challenge import daily_challenge_job from fastapi import FastAPI @@ -23,6 +24,7 @@ async def lifespan(app: FastAPI): await create_tables() await get_fetcher() # 初始化 fetcher init_scheduler() + await daily_challenge_job() # on shutdown yield stop_scheduler() From cc0413ea41160b8138893fb426fbe0a42541edf8 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 15:38:15 +0000 Subject: [PATCH 064/104] fix(daily-challenge): correct the duration --- app/service/daily_challenge.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/service/daily_challenge.py b/app/service/daily_challenge.py index ea93da7..ec7f9d0 100644 --- a/app/service/daily_challenge.py +++ b/app/service/daily_challenge.py @@ -19,7 +19,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession async def create_daily_challenge_room( - beatmap: int, ruleset_id: int, required_mods: list[APIMod] = [] + beatmap: int, ruleset_id: int, duration: int, required_mods: list[APIMod] = [] ) -> Room: async with AsyncSession(engine) as session: today = datetime.now(UTC).date() @@ -38,7 +38,7 @@ async def create_daily_challenge_room( ) ], category=RoomCategory.DAILY_CHALLENGE, - duration=24 * 60 - 2, # remain 2 minute to apply new daily challenge + duration=duration, ) @@ -46,9 +46,9 @@ async def create_daily_challenge_room( async def daily_challenge_job(): from app.signalr.hub import MetadataHubs - today = datetime.now(UTC).date() + now = datetime.now(UTC) redis = get_redis() - key = f"daily_challenge:{today}" + key = f"daily_challenge:{now.date()}" if not await redis.exists(key): return async with AsyncSession(engine) as session: @@ -70,7 +70,7 @@ async def daily_challenge_job(): if beatmap is None or ruleset_id is None: logger.warning( - f"[DailyChallenge] Missing required data for daily challenge {today}." + f"[DailyChallenge] Missing required data for daily challenge {now}." " Will try again in 5 minutes." ) get_scheduler().add_job( @@ -87,10 +87,14 @@ async def daily_challenge_job(): if required_mods: mods_list = json.loads(required_mods) + next_day = (now + timedelta(days=1)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) room = await create_daily_challenge_room( beatmap=beatmap_int, ruleset_id=ruleset_id_int, required_mods=mods_list, + duration=int((next_day - now - timedelta(minutes=2)).total_seconds() / 60), ) await MetadataHubs.broadcast_call( "DailyChallengeUpdated", DailyChallengeInfo(room_id=room.id) From 1330f90b4cb32266835d2bd252fa7bb9d1475278 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 15:46:08 +0000 Subject: [PATCH 065/104] fix(multiplayer): round borin mode doesn't work as expected --- app/models/multiplayer_hub.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index b0b4187..35b94c1 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -467,9 +467,6 @@ class MultiplayerQueue: ordered_active_items.extend(current_set) is_first_set = False - - for idx, item in enumerate(ordered_active_items): - item.playlist_order = idx case _: ordered_active_items = sorted( (item for item in self.room.playlist if not item.expired), From 36b59ac853faf357e63e579f90330b9281a9f283 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 04:30:06 +0000 Subject: [PATCH 066/104] fix(database): fix pydantic warnings --- app/database/beatmap.py | 22 ++++-------- app/database/beatmapset.py | 64 ++++++++++++----------------------- app/models/multiplayer_hub.py | 4 +-- app/models/user.py | 7 ++-- 4 files changed, 33 insertions(+), 64 deletions(-) diff --git a/app/database/beatmap.py b/app/database/beatmap.py index 192ac71..096e9b9 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -7,7 +7,7 @@ from app.models.score import MODE_TO_INT, GameMode from .beatmap_playcounts import BeatmapPlaycounts from .beatmapset import Beatmapset, BeatmapsetResp -from sqlalchemy import DECIMAL, Column, DateTime +from sqlalchemy import Column, DateTime from sqlmodel import VARCHAR, Field, Relationship, SQLModel, col, func, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -27,9 +27,7 @@ class BeatmapBase(SQLModel): url: str mode: GameMode beatmapset_id: int = Field(foreign_key="beatmapsets.id", index=True) - difficulty_rating: float = Field( - default=0.0, sa_column=Column(DECIMAL(precision=10, scale=6)) - ) + difficulty_rating: float = Field(default=0.0) total_length: int user_id: int version: str @@ -41,17 +39,11 @@ class BeatmapBase(SQLModel): # TODO: failtimes, owners # BeatmapExtended - ar: float = Field(default=0.0, sa_column=Column(DECIMAL(precision=10, scale=2))) - cs: float = Field(default=0.0, sa_column=Column(DECIMAL(precision=10, scale=2))) - drain: float = Field( - default=0.0, - sa_column=Column(DECIMAL(precision=10, scale=2)), - ) # hp - accuracy: float = Field( - default=0.0, - sa_column=Column(DECIMAL(precision=10, scale=2)), - ) # od - bpm: float = Field(default=0.0, sa_column=Column(DECIMAL(precision=10, scale=2))) + ar: float = Field(default=0.0) + cs: float = Field(default=0.0) + drain: float = Field(default=0.0) # hp + accuracy: float = Field(default=0.0) # od + bpm: float = Field(default=0.0) count_circles: int = Field(default=0) count_sliders: int = Field(default=0) count_spinners: int = Field(default=0) diff --git a/app/database/beatmapset.py b/app/database/beatmapset.py index 8a95017..df7bc57 100644 --- a/app/database/beatmapset.py +++ b/app/database/beatmapset.py @@ -1,13 +1,13 @@ from datetime import datetime -from typing import TYPE_CHECKING, TypedDict, cast +from typing import TYPE_CHECKING, NotRequired, TypedDict from app.models.beatmap import BeatmapRankStatus, Genre, Language from app.models.score import GameMode from .lazer_user import BASE_INCLUDES, User, UserResp -from pydantic import BaseModel, model_serializer -from sqlalchemy import DECIMAL, JSON, Column, DateTime, Text +from pydantic import BaseModel +from sqlalchemy import JSON, Column, DateTime, Text from sqlalchemy.ext.asyncio import AsyncAttrs from sqlmodel import Field, Relationship, SQLModel, col, func, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -19,41 +19,19 @@ if TYPE_CHECKING: from .favourite_beatmapset import FavouriteBeatmapset -class BeatmapCovers(SQLModel): - cover: str - card: str - list: str - slimcover: str - cover_2_x: str | None = Field(default=None, alias="cover@2x") - card_2_x: str | None = Field(default=None, alias="card@2x") - list_2_x: str | None = Field(default=None, alias="list@2x") - slimcover_2_x: str | None = Field(default=None, alias="slimcover@2x") - - @model_serializer - def _(self) -> dict[str, str | None]: - self = cast(dict[str, str | None] | BeatmapCovers, self) - if isinstance(self, dict): - return { - "cover": self["cover"], - "card": self["card"], - "list": self["list"], - "slimcover": self["slimcover"], - "cover@2x": self.get("cover@2x"), - "card@2x": self.get("card@2x"), - "list@2x": self.get("list@2x"), - "slimcover@2x": self.get("slimcover@2x"), - } - else: - return { - "cover": self.cover, - "card": self.card, - "list": self.list, - "slimcover": self.slimcover, - "cover@2x": self.cover_2_x, - "card@2x": self.card_2_x, - "list@2x": self.list_2_x, - "slimcover@2x": self.slimcover_2_x, - } +BeatmapCovers = TypedDict( + "BeatmapCovers", + { + "cover": str, + "card": str, + "list": str, + "slimcover": str, + "cover@2x": NotRequired[str | None], + "card@2x": NotRequired[str | None], + "list@2x": NotRequired[str | None], + "slimcover@2x": NotRequired[str | None], + }, +) class BeatmapHype(BaseModel): @@ -75,12 +53,12 @@ class BeatmapNomination(TypedDict): beatmapset_id: int reset: bool user_id: int - rulesets: list[GameMode] | None + rulesets: NotRequired[list[GameMode] | None] -class BeatmapDescription(SQLModel): - bbcode: str | None = None - description: str | None = None +class BeatmapDescription(TypedDict): + bbcode: NotRequired[str | None] + description: NotRequired[str | None] class BeatmapTranslationText(BaseModel): @@ -122,7 +100,7 @@ class BeatmapsetBase(SQLModel): track_id: int | None = Field(default=None) # feature artist? # BeatmapsetExtended - bpm: float = Field(default=0.0, sa_column=Column(DECIMAL(10, 2))) + bpm: float = Field(default=0.0) can_be_hyped: bool = Field(default=False) discussion_locked: bool = Field(default=False) last_updated: datetime = Field(sa_column=Column(DateTime)) diff --git a/app/models/multiplayer_hub.py b/app/models/multiplayer_hub.py index 35b94c1..90613de 100644 --- a/app/models/multiplayer_hub.py +++ b/app/models/multiplayer_hub.py @@ -528,9 +528,7 @@ class MultiplayerQueue: item.validate_playlist_item_mods() item.owner_id = user.user_id - item.star_rating = float( - beatmap.difficulty_rating - ) # FIXME: beatmap use decimal + item.star_rating = beatmap.difficulty_rating await Playlist.add_to_db(item, self.room.room_id, session) self.room.playlist.append(item) await self.hub.playlist_added(self.server_room, item) diff --git a/app/models/user.py b/app/models/user.py index 3b522e8..a564238 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime from enum import Enum +from typing import NotRequired, TypedDict from .model import UTCBaseModel @@ -83,9 +84,9 @@ class RankHistory(BaseModel): data: list[int] -class Page(BaseModel): - html: str = "" - raw: str = "" +class Page(TypedDict): + html: NotRequired[str] + raw: NotRequired[str] class BeatmapsetType(str, Enum): From cf45070c2c68e4881d06995660b1c77668db65e5 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 04:33:03 +0000 Subject: [PATCH 067/104] refactor(database): use alembic to maintain the whole database --- app/dependencies/database.py | 5 - main.py | 3 +- migrations/versions/.gitkeep | 0 ...8ebf_beatmapset_support_favourite_count.py | 40 - ...02_relationship_fix_unique_relationship.py | 54 - ...d0c1b2cefe91_playlist_index_playlist_id.py | 89 -- migrations/versions/fdb3822a30ba_init.py | 1069 +++++++++++++++++ 7 files changed, 1070 insertions(+), 190 deletions(-) delete mode 100644 migrations/versions/.gitkeep delete mode 100644 migrations/versions/1178d0758ebf_beatmapset_support_favourite_count.py delete mode 100644 migrations/versions/58a11441d302_relationship_fix_unique_relationship.py delete mode 100644 migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py create mode 100644 migrations/versions/fdb3822a30ba_init.py diff --git a/app/dependencies/database.py b/app/dependencies/database.py index e74af93..1525bfb 100644 --- a/app/dependencies/database.py +++ b/app/dependencies/database.py @@ -30,11 +30,6 @@ async def get_db(): yield session -async def create_tables(): - async with engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.create_all) - - # Redis 依赖 def get_redis(): return redis_client diff --git a/main.py b/main.py index 8569afb..fe27c14 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager from datetime import datetime from app.config import settings -from app.dependencies.database import create_tables, engine, redis_client +from app.dependencies.database import engine, redis_client from app.dependencies.fetcher import get_fetcher from app.dependencies.scheduler import init_scheduler, stop_scheduler from app.router import ( @@ -21,7 +21,6 @@ from fastapi import FastAPI @asynccontextmanager async def lifespan(app: FastAPI): # on startup - await create_tables() await get_fetcher() # 初始化 fetcher init_scheduler() await daily_challenge_job() diff --git a/migrations/versions/.gitkeep b/migrations/versions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/migrations/versions/1178d0758ebf_beatmapset_support_favourite_count.py b/migrations/versions/1178d0758ebf_beatmapset_support_favourite_count.py deleted file mode 100644 index 84bae15..0000000 --- a/migrations/versions/1178d0758ebf_beatmapset_support_favourite_count.py +++ /dev/null @@ -1,40 +0,0 @@ -"""beatmapset: support favourite count - -Revision ID: 1178d0758ebf -Revises: -Create Date: 2025-08-01 04:05:09.882800 - -""" - -from __future__ import annotations - -from collections.abc import Sequence - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision: str = "1178d0758ebf" -down_revision: str | Sequence[str] | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column("beatmapsets", "favourite_count") - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.add_column( - "beatmapsets", - sa.Column( - "favourite_count", mysql.INTEGER(), autoincrement=False, nullable=False - ), - ) - # ### end Alembic commands ### diff --git a/migrations/versions/58a11441d302_relationship_fix_unique_relationship.py b/migrations/versions/58a11441d302_relationship_fix_unique_relationship.py deleted file mode 100644 index e383621..0000000 --- a/migrations/versions/58a11441d302_relationship_fix_unique_relationship.py +++ /dev/null @@ -1,54 +0,0 @@ -"""relationship: fix unique relationship - -Revision ID: 58a11441d302 -Revises: 1178d0758ebf -Create Date: 2025-08-01 04:23:02.498166 - -""" - -from __future__ import annotations - -from collections.abc import Sequence - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision: str = "58a11441d302" -down_revision: str | Sequence[str] | None = "1178d0758ebf" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.add_column( - "relationship", - sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), - ) - op.drop_constraint("PRIMARY", "relationship", type_="primary") - op.create_primary_key("pk_relationship", "relationship", ["id"]) - op.alter_column( - "relationship", "user_id", existing_type=mysql.BIGINT(), nullable=True - ) - op.alter_column( - "relationship", "target_id", existing_type=mysql.BIGINT(), nullable=True - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint("pk_relationship", "relationship", type_="primary") - op.create_primary_key("PRIMARY", "relationship", ["user_id", "target_id"]) - op.alter_column( - "relationship", "target_id", existing_type=mysql.BIGINT(), nullable=False - ) - op.alter_column( - "relationship", "user_id", existing_type=mysql.BIGINT(), nullable=False - ) - op.drop_column("relationship", "id") - # ### end Alembic commands ### diff --git a/migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py b/migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py deleted file mode 100644 index 74f2e56..0000000 --- a/migrations/versions/d0c1b2cefe91_playlist_index_playlist_id.py +++ /dev/null @@ -1,89 +0,0 @@ -"""playlist: index playlist id - -Revision ID: d0c1b2cefe91 -Revises: 58a11441d302 -Create Date: 2025-08-06 06:02:10.512616 - -""" - -from __future__ import annotations - -from collections.abc import Sequence - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = "d0c1b2cefe91" -down_revision: str | Sequence[str] | None = "58a11441d302" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_index( - op.f("ix_room_playlists_id"), "room_playlists", ["id"], unique=False - ) - op.create_table( - "playlist_best_scores", - sa.Column("user_id", sa.BigInteger(), nullable=True), - sa.Column("score_id", sa.BigInteger(), nullable=False), - sa.Column("room_id", sa.Integer(), nullable=False), - sa.Column("playlist_id", sa.Integer(), nullable=False), - sa.Column("total_score", sa.BigInteger(), nullable=True), - sa.ForeignKeyConstraint( - ["playlist_id"], - ["room_playlists.id"], - ), - sa.ForeignKeyConstraint( - ["room_id"], - ["rooms.id"], - ), - sa.ForeignKeyConstraint( - ["score_id"], - ["scores.id"], - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["lazer_users.id"], - ), - sa.PrimaryKeyConstraint("score_id"), - ) - op.create_index( - op.f("ix_playlist_best_scores_playlist_id"), - "playlist_best_scores", - ["playlist_id"], - unique=False, - ) - op.create_index( - op.f("ix_playlist_best_scores_room_id"), - "playlist_best_scores", - ["room_id"], - unique=False, - ) - op.create_index( - op.f("ix_playlist_best_scores_user_id"), - "playlist_best_scores", - ["user_id"], - unique=False, - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index( - op.f("ix_playlist_best_scores_user_id"), table_name="playlist_best_scores" - ) - op.drop_index( - op.f("ix_playlist_best_scores_room_id"), table_name="playlist_best_scores" - ) - op.drop_index( - op.f("ix_playlist_best_scores_playlist_id"), table_name="playlist_best_scores" - ) - op.drop_table("playlist_best_scores") - op.drop_index(op.f("ix_room_playlists_id"), table_name="room_playlists") - # ### end Alembic commands ### diff --git a/migrations/versions/fdb3822a30ba_init.py b/migrations/versions/fdb3822a30ba_init.py new file mode 100644 index 0000000..55ace06 --- /dev/null +++ b/migrations/versions/fdb3822a30ba_init.py @@ -0,0 +1,1069 @@ +"""init + +Revision ID: fdb3822a30ba +Revises: +Create Date: 2025-08-10 04:30:58.443568 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = "fdb3822a30ba" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "beatmapsets", + sa.Column("artist", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("artist_unicode", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("covers", sa.JSON(), nullable=True), + sa.Column("creator", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("nsfw", sa.Boolean(), nullable=False), + sa.Column("play_count", sa.Integer(), nullable=False), + sa.Column("preview_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("source", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("spotlight", sa.Boolean(), nullable=False), + sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("title_unicode", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("video", sa.Boolean(), nullable=False), + sa.Column("current_nominations", sa.JSON(), nullable=True), + sa.Column("description", sa.JSON(), nullable=True), + sa.Column("pack_tags", sa.JSON(), nullable=True), + sa.Column("ratings", sa.JSON(), nullable=True), + sa.Column("track_id", sa.Integer(), nullable=True), + sa.Column("bpm", sa.Float(), nullable=False), + sa.Column("can_be_hyped", sa.Boolean(), nullable=False), + sa.Column("discussion_locked", sa.Boolean(), nullable=False), + sa.Column("last_updated", sa.DateTime(), nullable=True), + sa.Column("ranked_date", sa.DateTime(), nullable=True), + sa.Column("storyboard", sa.Boolean(), nullable=False), + sa.Column("submitted_date", sa.DateTime(), nullable=True), + sa.Column("tags", sa.Text(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "beatmap_status", + sa.Enum( + "GRAVEYARD", + "WIP", + "PENDING", + "RANKED", + "APPROVED", + "QUALIFIED", + "LOVED", + name="beatmaprankstatus", + ), + nullable=False, + ), + sa.Column( + "beatmap_genre", + sa.Enum( + "ANY", + "UNSPECIFIED", + "VIDEO_GAME", + "ANIME", + "ROCK", + "POP", + "OTHER", + "NOVELTY", + "HIP_HOP", + "ELECTRONIC", + "METAL", + "CLASSICAL", + "FOLK", + "JAZZ", + name="genre", + ), + nullable=False, + ), + sa.Column( + "beatmap_language", + sa.Enum( + "ANY", + "UNSPECIFIED", + "ENGLISH", + "JAPANESE", + "CHINESE", + "INSTRUMENTAL", + "KOREAN", + "FRENCH", + "GERMAN", + "SWEDISH", + "ITALIAN", + "SPANISH", + "RUSSIAN", + "POLISH", + "OTHER", + name="language", + ), + nullable=False, + ), + sa.Column("nominations_required", sa.Integer(), nullable=False), + sa.Column("nominations_current", sa.Integer(), nullable=False), + sa.Column("hype_current", sa.Integer(), nullable=False), + sa.Column("hype_required", sa.Integer(), nullable=False), + sa.Column( + "availability_info", sqlmodel.sql.sqltypes.AutoString(), nullable=True + ), + sa.Column("download_disabled", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_beatmapsets_artist"), "beatmapsets", ["artist"], unique=False + ) + op.create_index( + op.f("ix_beatmapsets_artist_unicode"), + "beatmapsets", + ["artist_unicode"], + unique=False, + ) + op.create_index(op.f("ix_beatmapsets_id"), "beatmapsets", ["id"], unique=False) + op.create_table( + "lazer_users", + sa.Column("avatar_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column( + "country_code", sqlmodel.sql.sqltypes.AutoString(length=2), nullable=False + ), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_bot", sa.Boolean(), nullable=False), + sa.Column("is_supporter", sa.Boolean(), nullable=False), + sa.Column("last_visit", sa.DateTime(timezone=True), nullable=True), + sa.Column("pm_friends_only", sa.Boolean(), nullable=False), + sa.Column("profile_colour", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column( + "username", sqlmodel.sql.sqltypes.AutoString(length=32), nullable=False + ), + sa.Column("page", sa.JSON(), nullable=True), + sa.Column("previous_usernames", sa.JSON(), nullable=True), + sa.Column("support_level", sa.Integer(), nullable=False), + sa.Column("badges", sa.JSON(), nullable=True), + sa.Column("is_restricted", sa.Boolean(), nullable=False), + sa.Column("cover", sa.JSON(), nullable=True), + sa.Column("beatmap_playcounts_count", sa.Integer(), nullable=False), + sa.Column( + "playmode", + sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + nullable=False, + ), + sa.Column("discord", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("has_supported", sa.Boolean(), nullable=False), + sa.Column("interests", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("join_date", sa.DateTime(), nullable=False), + sa.Column("location", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("max_blocks", sa.Integer(), nullable=False), + sa.Column("max_friends", sa.Integer(), nullable=False), + sa.Column("occupation", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("playstyle", sa.JSON(), nullable=True), + sa.Column("profile_hue", sa.Integer(), nullable=True), + sa.Column("profile_order", sa.JSON(), nullable=True), + sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("title_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("twitter", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("website", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("comments_count", sa.Integer(), nullable=False), + sa.Column("post_count", sa.Integer(), nullable=False), + sa.Column("is_admin", sa.Boolean(), nullable=False), + sa.Column("is_gmt", sa.Boolean(), nullable=False), + sa.Column("is_qat", sa.Boolean(), nullable=False), + sa.Column("is_bng", sa.Boolean(), nullable=False), + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column( + "email", sqlmodel.sql.sqltypes.AutoString(length=254), nullable=False + ), + sa.Column("priv", sa.Integer(), nullable=False), + sa.Column( + "pw_bcrypt", sqlmodel.sql.sqltypes.AutoString(length=60), nullable=False + ), + sa.Column("silence_end_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("donor_end_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_lazer_users_country_code"), + "lazer_users", + ["country_code"], + unique=False, + ) + op.create_index(op.f("ix_lazer_users_email"), "lazer_users", ["email"], unique=True) + op.create_index(op.f("ix_lazer_users_id"), "lazer_users", ["id"], unique=False) + op.create_index( + op.f("ix_lazer_users_username"), "lazer_users", ["username"], unique=True + ) + op.create_table( + "teams", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column( + "short_name", sqlmodel.sql.sqltypes.AutoString(length=10), nullable=False + ), + sa.Column( + "flag_url", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True + ), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_teams_id"), "teams", ["id"], unique=False) + op.create_table( + "beatmaps", + sa.Column("url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column( + "mode", + sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + nullable=False, + ), + sa.Column("difficulty_rating", sa.Float(), nullable=False), + sa.Column("total_length", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("version", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("checksum", sa.VARCHAR(length=32), nullable=True), + sa.Column("current_user_playcount", sa.Integer(), nullable=False), + sa.Column("max_combo", sa.Integer(), nullable=False), + sa.Column("ar", sa.Float(), nullable=False), + sa.Column("cs", sa.Float(), nullable=False), + sa.Column("drain", sa.Float(), nullable=False), + sa.Column("accuracy", sa.Float(), nullable=False), + sa.Column("bpm", sa.Float(), nullable=False), + sa.Column("count_circles", sa.Integer(), nullable=False), + sa.Column("count_sliders", sa.Integer(), nullable=False), + sa.Column("count_spinners", sa.Integer(), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.Column("hit_length", sa.Integer(), nullable=False), + sa.Column("last_updated", sa.DateTime(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("beatmapset_id", sa.Integer(), nullable=False), + sa.Column( + "beatmap_status", + sa.Enum( + "GRAVEYARD", + "WIP", + "PENDING", + "RANKED", + "APPROVED", + "QUALIFIED", + "LOVED", + name="beatmaprankstatus", + ), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["beatmapset_id"], + ["beatmapsets.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_beatmaps_beatmapset_id"), "beatmaps", ["beatmapset_id"], unique=False + ) + op.create_index( + op.f("ix_beatmaps_checksum"), "beatmaps", ["checksum"], unique=False + ) + op.create_index(op.f("ix_beatmaps_id"), "beatmaps", ["id"], unique=False) + op.create_table( + "daily_challenge_stats", + sa.Column("daily_streak_best", sa.Integer(), nullable=False), + sa.Column("daily_streak_current", sa.Integer(), nullable=False), + sa.Column("last_update", sa.DateTime(), nullable=True), + sa.Column("last_weekly_streak", sa.DateTime(), nullable=True), + sa.Column("playcount", sa.Integer(), nullable=False), + sa.Column("top_10p_placements", sa.Integer(), nullable=False), + sa.Column("top_50p_placements", sa.Integer(), nullable=False), + sa.Column("weekly_streak_best", sa.Integer(), nullable=False), + sa.Column("weekly_streak_current", sa.Integer(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("user_id"), + ) + op.create_index( + op.f("ix_daily_challenge_stats_user_id"), + "daily_challenge_stats", + ["user_id"], + unique=True, + ) + op.create_table( + "favourite_beatmapset", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("beatmapset_id", sa.Integer(), nullable=True), + sa.Column("date", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["beatmapset_id"], + ["beatmapsets.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_favourite_beatmapset_beatmapset_id"), + "favourite_beatmapset", + ["beatmapset_id"], + unique=False, + ) + op.create_index( + op.f("ix_favourite_beatmapset_user_id"), + "favourite_beatmapset", + ["user_id"], + unique=False, + ) + op.create_table( + "lazer_user_achievements", + sa.Column("achievement_id", sa.Integer(), nullable=False), + sa.Column("achieved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("achievement_id", "id"), + ) + op.create_index( + op.f("ix_lazer_user_achievements_id"), + "lazer_user_achievements", + ["id"], + unique=False, + ) + op.create_table( + "lazer_user_statistics", + sa.Column( + "mode", + sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + nullable=False, + ), + sa.Column("count_100", sa.BigInteger(), nullable=True), + sa.Column("count_300", sa.BigInteger(), nullable=True), + sa.Column("count_50", sa.BigInteger(), nullable=True), + sa.Column("count_miss", sa.BigInteger(), nullable=True), + sa.Column("global_rank", sa.Integer(), nullable=True), + sa.Column("country_rank", sa.Integer(), nullable=True), + sa.Column("pp", sa.Float(), nullable=False), + sa.Column("ranked_score", sa.Integer(), nullable=False), + sa.Column("hit_accuracy", sa.Float(), nullable=False), + sa.Column("total_score", sa.BigInteger(), nullable=True), + sa.Column("total_hits", sa.BigInteger(), nullable=True), + sa.Column("maximum_combo", sa.Integer(), nullable=False), + sa.Column("play_count", sa.Integer(), nullable=False), + sa.Column("play_time", sa.BigInteger(), nullable=True), + sa.Column("replays_watched_by_others", sa.Integer(), nullable=False), + sa.Column("is_ranked", sa.Boolean(), nullable=False), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("grade_ss", sa.Integer(), nullable=False), + sa.Column("grade_ssh", sa.Integer(), nullable=False), + sa.Column("grade_s", sa.Integer(), nullable=False), + sa.Column("grade_sh", sa.Integer(), nullable=False), + sa.Column("grade_a", sa.Integer(), nullable=False), + sa.Column("level_current", sa.Integer(), nullable=False), + sa.Column("level_progress", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_lazer_user_statistics_user_id"), + "lazer_user_statistics", + ["user_id"], + unique=False, + ) + op.create_table( + "monthly_playcounts", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("year", sa.Integer(), nullable=False), + sa.Column("month", sa.Integer(), nullable=False), + sa.Column("playcount", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_monthly_playcounts_month"), + "monthly_playcounts", + ["month"], + unique=False, + ) + op.create_index( + op.f("ix_monthly_playcounts_user_id"), + "monthly_playcounts", + ["user_id"], + unique=False, + ) + op.create_index( + op.f("ix_monthly_playcounts_year"), "monthly_playcounts", ["year"], unique=False + ) + op.create_table( + "oauth_tokens", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column( + "access_token", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False + ), + sa.Column( + "refresh_token", + sqlmodel.sql.sqltypes.AutoString(length=500), + nullable=False, + ), + sa.Column( + "token_type", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False + ), + sa.Column( + "scope", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False + ), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("access_token"), + sa.UniqueConstraint("refresh_token"), + ) + op.create_index(op.f("ix_oauth_tokens_id"), "oauth_tokens", ["id"], unique=False) + op.create_index( + op.f("ix_oauth_tokens_user_id"), "oauth_tokens", ["user_id"], unique=False + ) + op.create_table( + "relationship", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("target_id", sa.BigInteger(), nullable=True), + sa.Column( + "type", sa.Enum("FOLLOW", "BLOCK", name="relationshiptype"), nullable=False + ), + sa.ForeignKeyConstraint( + ["target_id"], + ["lazer_users.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_relationship_target_id"), "relationship", ["target_id"], unique=False + ) + op.create_index( + op.f("ix_relationship_user_id"), "relationship", ["user_id"], unique=False + ) + op.create_table( + "rooms", + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column( + "category", + sa.Enum( + "NORMAL", + "SPOTLIGHT", + "FEATURED_ARTIST", + "DAILY_CHALLENGE", + "REALTIME", + name="roomcategory", + ), + nullable=False, + ), + sa.Column("duration", sa.Integer(), nullable=True), + sa.Column("starts_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("participant_count", sa.Integer(), nullable=False), + sa.Column("max_attempts", sa.Integer(), nullable=True), + sa.Column( + "type", + sa.Enum("PLAYLISTS", "HEAD_TO_HEAD", "TEAM_VERSUS", name="matchtype"), + nullable=False, + ), + sa.Column( + "queue_mode", + sa.Enum( + "HOST_ONLY", "ALL_PLAYERS", "ALL_PLAYERS_ROUND_ROBIN", name="queuemode" + ), + nullable=False, + ), + sa.Column("auto_skip", sa.Boolean(), nullable=False), + sa.Column("auto_start_duration", sa.Integer(), nullable=False), + sa.Column( + "status", sa.Enum("IDLE", "PLAYING", name="roomstatus"), nullable=False + ), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("host_id", sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint( + ["host_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_rooms_category"), "rooms", ["category"], unique=False) + op.create_index(op.f("ix_rooms_host_id"), "rooms", ["host_id"], unique=False) + op.create_index(op.f("ix_rooms_id"), "rooms", ["id"], unique=False) + op.create_index(op.f("ix_rooms_name"), "rooms", ["name"], unique=False) + op.create_table( + "team_members", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("team_id", sa.Integer(), nullable=False), + sa.Column("joined_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["team_id"], + ["teams.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_team_members_id"), "team_members", ["id"], unique=False) + op.create_table( + "user_account_history", + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("length", sa.Integer(), nullable=False), + sa.Column("permanent", sa.Boolean(), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column( + "type", + sa.Enum( + "NOTE", + "RESTRICTION", + "SLIENCE", + "TOURNAMENT_BAN", + name="useraccounthistorytype", + ), + nullable=False, + ), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_user_account_history_id"), "user_account_history", ["id"], unique=False + ) + op.create_index( + op.f("ix_user_account_history_user_id"), + "user_account_history", + ["user_id"], + unique=False, + ) + op.create_table( + "beatmap_playcounts", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("beatmap_id", sa.Integer(), nullable=False), + sa.Column("playcount", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["beatmap_id"], + ["beatmaps.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_beatmap_playcounts_beatmap_id"), + "beatmap_playcounts", + ["beatmap_id"], + unique=False, + ) + op.create_index( + op.f("ix_beatmap_playcounts_user_id"), + "beatmap_playcounts", + ["user_id"], + unique=False, + ) + op.create_table( + "item_attempts_count", + sa.Column("room_id", sa.Integer(), nullable=False), + sa.Column("attempts", sa.Integer(), nullable=False), + sa.Column("completed", sa.Integer(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("accuracy", sa.Float(), nullable=False), + sa.Column("pp", sa.Float(), nullable=False), + sa.Column("total_score", sa.Integer(), nullable=False), + sa.Column("id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["room_id"], + ["rooms.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_item_attempts_count_room_id"), + "item_attempts_count", + ["room_id"], + unique=False, + ) + op.create_index( + op.f("ix_item_attempts_count_user_id"), + "item_attempts_count", + ["user_id"], + unique=False, + ) + op.create_table( + "multiplayer_events", + sa.Column("playlist_item_id", sa.Integer(), nullable=True), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("event_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("room_id", sa.Integer(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("event_detail", sa.JSON(), nullable=True), + sa.ForeignKeyConstraint( + ["room_id"], + ["rooms.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_multiplayer_events_event_type"), + "multiplayer_events", + ["event_type"], + unique=False, + ) + op.create_index( + op.f("ix_multiplayer_events_id"), "multiplayer_events", ["id"], unique=False + ) + op.create_index( + op.f("ix_multiplayer_events_room_id"), + "multiplayer_events", + ["room_id"], + unique=False, + ) + op.create_index( + op.f("ix_multiplayer_events_user_id"), + "multiplayer_events", + ["user_id"], + unique=False, + ) + op.create_table( + "room_participated_users", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("room_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("left_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["room_id"], + ["rooms.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "room_playlists", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("owner_id", sa.BigInteger(), nullable=True), + sa.Column("ruleset_id", sa.Integer(), nullable=False), + sa.Column("expired", sa.Boolean(), nullable=False), + sa.Column("playlist_order", sa.Integer(), nullable=False), + sa.Column("played_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("allowed_mods", sa.JSON(), nullable=True), + sa.Column("required_mods", sa.JSON(), nullable=True), + sa.Column("beatmap_id", sa.Integer(), nullable=False), + sa.Column("freestyle", sa.Boolean(), nullable=False), + sa.Column("db_id", sa.Integer(), nullable=False), + sa.Column("room_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["beatmap_id"], + ["beatmaps.id"], + ), + sa.ForeignKeyConstraint( + ["owner_id"], + ["lazer_users.id"], + ), + sa.ForeignKeyConstraint( + ["room_id"], + ["rooms.id"], + ), + sa.PrimaryKeyConstraint("db_id"), + ) + op.create_index( + op.f("ix_room_playlists_db_id"), "room_playlists", ["db_id"], unique=False + ) + op.create_index( + op.f("ix_room_playlists_id"), "room_playlists", ["id"], unique=False + ) + op.create_table( + "score_tokens", + sa.Column("score_id", sa.BigInteger(), nullable=True), + sa.Column( + "ruleset_id", + sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + nullable=False, + ), + sa.Column("playlist_item_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("beatmap_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["beatmap_id"], + ["beatmaps.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "idx_user_playlist", + "score_tokens", + ["user_id", "playlist_item_id"], + unique=False, + ) + op.create_index(op.f("ix_score_tokens_id"), "score_tokens", ["id"], unique=False) + op.create_table( + "scores", + sa.Column("accuracy", sa.Float(), nullable=False), + sa.Column( + "map_md5", sqlmodel.sql.sqltypes.AutoString(length=32), nullable=False + ), + sa.Column("build_id", sa.Integer(), nullable=True), + sa.Column("classic_total_score", sa.BigInteger(), nullable=True), + sa.Column("ended_at", sa.DateTime(), nullable=True), + sa.Column("has_replay", sa.Boolean(), nullable=False), + sa.Column("max_combo", sa.Integer(), nullable=False), + sa.Column("mods", sa.JSON(), nullable=True), + sa.Column("passed", sa.Boolean(), nullable=False), + sa.Column("playlist_item_id", sa.Integer(), nullable=True), + sa.Column("pp", sa.Float(), nullable=False), + sa.Column("preserve", sa.Boolean(), nullable=False), + sa.Column( + "rank", + sa.Enum("X", "XH", "S", "SH", "A", "B", "C", "D", "F", name="rank"), + nullable=False, + ), + sa.Column("room_id", sa.Integer(), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("total_score", sa.BigInteger(), nullable=True), + sa.Column("total_score_without_mods", sa.BigInteger(), nullable=True), + sa.Column("type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("beatmap_id", sa.Integer(), nullable=False), + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("n300", sa.Integer(), nullable=False), + sa.Column("n100", sa.Integer(), nullable=False), + sa.Column("n50", sa.Integer(), nullable=False), + sa.Column("nmiss", sa.Integer(), nullable=False), + sa.Column("ngeki", sa.Integer(), nullable=False), + sa.Column("nkatu", sa.Integer(), nullable=False), + sa.Column("nlarge_tick_miss", sa.Integer(), nullable=True), + sa.Column("nlarge_tick_hit", sa.Integer(), nullable=True), + sa.Column("nslider_tail_hit", sa.Integer(), nullable=True), + sa.Column("nsmall_tick_hit", sa.Integer(), nullable=True), + sa.Column( + "gamemode", + sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["beatmap_id"], + ["beatmaps.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_scores_beatmap_id"), "scores", ["beatmap_id"], unique=False + ) + op.create_index(op.f("ix_scores_gamemode"), "scores", ["gamemode"], unique=False) + op.create_index(op.f("ix_scores_map_md5"), "scores", ["map_md5"], unique=False) + op.create_index(op.f("ix_scores_user_id"), "scores", ["user_id"], unique=False) + op.create_table( + "best_scores", + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("score_id", sa.BigInteger(), nullable=False), + sa.Column("beatmap_id", sa.Integer(), nullable=False), + sa.Column( + "gamemode", + sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + nullable=False, + ), + sa.Column("pp", sa.Float(), nullable=True), + sa.Column("acc", sa.Float(), nullable=True), + sa.ForeignKeyConstraint( + ["beatmap_id"], + ["beatmaps.id"], + ), + sa.ForeignKeyConstraint( + ["score_id"], + ["scores.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("score_id"), + ) + op.create_index( + op.f("ix_best_scores_beatmap_id"), "best_scores", ["beatmap_id"], unique=False + ) + op.create_index( + op.f("ix_best_scores_gamemode"), "best_scores", ["gamemode"], unique=False + ) + op.create_index( + op.f("ix_best_scores_user_id"), "best_scores", ["user_id"], unique=False + ) + op.create_table( + "playlist_best_scores", + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("score_id", sa.BigInteger(), nullable=False), + sa.Column("room_id", sa.Integer(), nullable=False), + sa.Column("playlist_id", sa.Integer(), nullable=False), + sa.Column("total_score", sa.BigInteger(), nullable=True), + sa.Column("attempts", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["playlist_id"], + ["room_playlists.id"], + ), + sa.ForeignKeyConstraint( + ["room_id"], + ["rooms.id"], + ), + sa.ForeignKeyConstraint( + ["score_id"], + ["scores.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("score_id"), + ) + op.create_index( + op.f("ix_playlist_best_scores_playlist_id"), + "playlist_best_scores", + ["playlist_id"], + unique=False, + ) + op.create_index( + op.f("ix_playlist_best_scores_room_id"), + "playlist_best_scores", + ["room_id"], + unique=False, + ) + op.create_index( + op.f("ix_playlist_best_scores_user_id"), + "playlist_best_scores", + ["user_id"], + unique=False, + ) + op.create_table( + "total_score_best_scores", + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("score_id", sa.BigInteger(), nullable=False), + sa.Column("beatmap_id", sa.Integer(), nullable=False), + sa.Column( + "gamemode", + sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + nullable=False, + ), + sa.Column("total_score", sa.BigInteger(), nullable=True), + sa.Column("mods", sa.JSON(), nullable=True), + sa.Column( + "rank", + sa.Enum("X", "XH", "S", "SH", "A", "B", "C", "D", "F", name="rank"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["beatmap_id"], + ["beatmaps.id"], + ), + sa.ForeignKeyConstraint( + ["score_id"], + ["scores.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("score_id"), + ) + op.create_index( + op.f("ix_total_score_best_scores_beatmap_id"), + "total_score_best_scores", + ["beatmap_id"], + unique=False, + ) + op.create_index( + op.f("ix_total_score_best_scores_gamemode"), + "total_score_best_scores", + ["gamemode"], + unique=False, + ) + op.create_index( + op.f("ix_total_score_best_scores_user_id"), + "total_score_best_scores", + ["user_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_total_score_best_scores_user_id"), table_name="total_score_best_scores" + ) + op.drop_index( + op.f("ix_total_score_best_scores_gamemode"), + table_name="total_score_best_scores", + ) + op.drop_index( + op.f("ix_total_score_best_scores_beatmap_id"), + table_name="total_score_best_scores", + ) + op.drop_table("total_score_best_scores") + op.drop_index( + op.f("ix_playlist_best_scores_user_id"), table_name="playlist_best_scores" + ) + op.drop_index( + op.f("ix_playlist_best_scores_room_id"), table_name="playlist_best_scores" + ) + op.drop_index( + op.f("ix_playlist_best_scores_playlist_id"), table_name="playlist_best_scores" + ) + op.drop_table("playlist_best_scores") + op.drop_index(op.f("ix_best_scores_user_id"), table_name="best_scores") + op.drop_index(op.f("ix_best_scores_gamemode"), table_name="best_scores") + op.drop_index(op.f("ix_best_scores_beatmap_id"), table_name="best_scores") + op.drop_table("best_scores") + op.drop_index(op.f("ix_scores_user_id"), table_name="scores") + op.drop_index(op.f("ix_scores_map_md5"), table_name="scores") + op.drop_index(op.f("ix_scores_gamemode"), table_name="scores") + op.drop_index(op.f("ix_scores_beatmap_id"), table_name="scores") + op.drop_table("scores") + op.drop_index(op.f("ix_score_tokens_id"), table_name="score_tokens") + op.drop_index("idx_user_playlist", table_name="score_tokens") + op.drop_table("score_tokens") + op.drop_index(op.f("ix_room_playlists_id"), table_name="room_playlists") + op.drop_index(op.f("ix_room_playlists_db_id"), table_name="room_playlists") + op.drop_table("room_playlists") + op.drop_table("room_participated_users") + op.drop_index( + op.f("ix_multiplayer_events_user_id"), table_name="multiplayer_events" + ) + op.drop_index( + op.f("ix_multiplayer_events_room_id"), table_name="multiplayer_events" + ) + op.drop_index(op.f("ix_multiplayer_events_id"), table_name="multiplayer_events") + op.drop_index( + op.f("ix_multiplayer_events_event_type"), table_name="multiplayer_events" + ) + op.drop_table("multiplayer_events") + op.drop_index( + op.f("ix_item_attempts_count_user_id"), table_name="item_attempts_count" + ) + op.drop_index( + op.f("ix_item_attempts_count_room_id"), table_name="item_attempts_count" + ) + op.drop_table("item_attempts_count") + op.drop_index( + op.f("ix_beatmap_playcounts_user_id"), table_name="beatmap_playcounts" + ) + op.drop_index( + op.f("ix_beatmap_playcounts_beatmap_id"), table_name="beatmap_playcounts" + ) + op.drop_table("beatmap_playcounts") + op.drop_index( + op.f("ix_user_account_history_user_id"), table_name="user_account_history" + ) + op.drop_index(op.f("ix_user_account_history_id"), table_name="user_account_history") + op.drop_table("user_account_history") + op.drop_index(op.f("ix_team_members_id"), table_name="team_members") + op.drop_table("team_members") + op.drop_index(op.f("ix_rooms_name"), table_name="rooms") + op.drop_index(op.f("ix_rooms_id"), table_name="rooms") + op.drop_index(op.f("ix_rooms_host_id"), table_name="rooms") + op.drop_index(op.f("ix_rooms_category"), table_name="rooms") + op.drop_table("rooms") + op.drop_index(op.f("ix_relationship_user_id"), table_name="relationship") + op.drop_index(op.f("ix_relationship_target_id"), table_name="relationship") + op.drop_table("relationship") + op.drop_index(op.f("ix_oauth_tokens_user_id"), table_name="oauth_tokens") + op.drop_index(op.f("ix_oauth_tokens_id"), table_name="oauth_tokens") + op.drop_table("oauth_tokens") + op.drop_index(op.f("ix_monthly_playcounts_year"), table_name="monthly_playcounts") + op.drop_index( + op.f("ix_monthly_playcounts_user_id"), table_name="monthly_playcounts" + ) + op.drop_index(op.f("ix_monthly_playcounts_month"), table_name="monthly_playcounts") + op.drop_table("monthly_playcounts") + op.drop_index( + op.f("ix_lazer_user_statistics_user_id"), table_name="lazer_user_statistics" + ) + op.drop_table("lazer_user_statistics") + op.drop_index( + op.f("ix_lazer_user_achievements_id"), table_name="lazer_user_achievements" + ) + op.drop_table("lazer_user_achievements") + op.drop_index( + op.f("ix_favourite_beatmapset_user_id"), table_name="favourite_beatmapset" + ) + op.drop_index( + op.f("ix_favourite_beatmapset_beatmapset_id"), table_name="favourite_beatmapset" + ) + op.drop_table("favourite_beatmapset") + op.drop_index( + op.f("ix_daily_challenge_stats_user_id"), table_name="daily_challenge_stats" + ) + op.drop_table("daily_challenge_stats") + op.drop_index(op.f("ix_beatmaps_id"), table_name="beatmaps") + op.drop_index(op.f("ix_beatmaps_checksum"), table_name="beatmaps") + op.drop_index(op.f("ix_beatmaps_beatmapset_id"), table_name="beatmaps") + op.drop_table("beatmaps") + op.drop_index(op.f("ix_teams_id"), table_name="teams") + op.drop_table("teams") + op.drop_index(op.f("ix_lazer_users_username"), table_name="lazer_users") + op.drop_index(op.f("ix_lazer_users_id"), table_name="lazer_users") + op.drop_index(op.f("ix_lazer_users_email"), table_name="lazer_users") + op.drop_index(op.f("ix_lazer_users_country_code"), table_name="lazer_users") + op.drop_table("lazer_users") + op.drop_index(op.f("ix_beatmapsets_id"), table_name="beatmapsets") + op.drop_index(op.f("ix_beatmapsets_artist_unicode"), table_name="beatmapsets") + op.drop_index(op.f("ix_beatmapsets_artist"), table_name="beatmapsets") + op.drop_table("beatmapsets") + # ### end Alembic commands ### From b0a7278daf8f92f373ee63b37454da7a1e2e0e77 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 04:39:04 +0000 Subject: [PATCH 068/104] chore(all): remove unused files --- .idea/.gitignore | 3 - .idea/inspectionProfiles/Project_Default.xml | 17 - .../inspectionProfiles/profiles_settings.xml | 6 - .idea/misc.xml | 10 - .idea/modules.xml | 8 - .idea/osu_lazer_api.iml | 14 - .idea/vcs.xml | 6 - DATA_SYNC_GUIDE.md | 140 ----- create_sample_data.py | 242 --------- migrations_old/add_lazer_rank_fields.sql | 16 - migrations_old/add_missing_fields.sql | 421 --------------- migrations_old/base.sql | 486 ------------------ migrations_old/custom_beatmaps.sql | 209 -------- migrations_old/migrations.sql | 477 ----------------- migrations_old/sync_legacy_data.sql | 337 ------------ osu_api_example.py | 64 --- quick_sync.py | 128 ----- remove_ansi.py | 52 -- sync_data.py | 236 --------- test_api.py | 256 --------- test_lazer.py | 133 ----- test_password.py | 55 -- 22 files changed, 3316 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/osu_lazer_api.iml delete mode 100644 .idea/vcs.xml delete mode 100644 DATA_SYNC_GUIDE.md delete mode 100644 create_sample_data.py delete mode 100644 migrations_old/add_lazer_rank_fields.sql delete mode 100644 migrations_old/add_missing_fields.sql delete mode 100644 migrations_old/base.sql delete mode 100644 migrations_old/custom_beatmaps.sql delete mode 100644 migrations_old/migrations.sql delete mode 100644 migrations_old/sync_legacy_data.sql delete mode 100644 osu_api_example.py delete mode 100644 quick_sync.py delete mode 100644 remove_ansi.py delete mode 100644 sync_data.py delete mode 100644 test_api.py delete mode 100644 test_lazer.py delete mode 100644 test_password.py diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 50d9d22..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# 默认忽略的文件 -/shelf/ -/workspace.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 86f861d..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 20fc29e..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index c311805..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 5fd5691..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/osu_lazer_api.iml b/.idea/osu_lazer_api.iml deleted file mode 100644 index 32e115a..0000000 --- a/.idea/osu_lazer_api.iml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index c8397c9..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/DATA_SYNC_GUIDE.md b/DATA_SYNC_GUIDE.md deleted file mode 100644 index f6ac41d..0000000 --- a/DATA_SYNC_GUIDE.md +++ /dev/null @@ -1,140 +0,0 @@ -# Lazer API 数据同步指南 - -本指南将帮助您将现有的 bancho.py 数据库数据同步到新的 Lazer API 专用表中。 - -## 文件说明 - -1. **`migrations_old/add_missing_fields.sql`** - 创建 Lazer API 专用表结构 -2. **`migrations_old/sync_legacy_data.sql`** - 数据同步脚本 -3. **`sync_data.py`** - 交互式数据同步工具 -4. **`quick_sync.py`** - 快速同步脚本(使用项目配置) - -## 使用方法 - -### 方法一:快速同步(推荐) - -如果您已经配置好了项目的数据库连接,可以直接使用快速同步脚本: - -```bash -python quick_sync.py -``` - -此脚本会: -1. 自动读取项目配置中的数据库连接信息 -2. 创建 Lazer API 专用表结构 -3. 同步现有数据到新表 - -### 方法二:交互式同步 - -如果需要使用不同的数据库连接配置: - -```bash -python sync_data.py -``` - -此脚本会: -1. 交互式地询问数据库连接信息 -2. 检查必要表是否存在 -3. 显示详细的同步过程和结果 - -### 方法三:手动执行 SQL - -如果您熟悉 SQL 操作,可以手动执行: - -```bash -# 1. 创建表结构 -mysql -u username -p database_name < migrations_old/add_missing_fields.sql - -# 2. 同步数据 -mysql -u username -p database_name < migrations_old/sync_legacy_data.sql -``` - -## 同步内容 - -### 创建的新表 - -- `lazer_user_profiles` - 用户扩展资料 -- `lazer_user_countries` - 用户国家信息 -- `lazer_user_kudosu` - 用户 Kudosu 统计 -- `lazer_user_counts` - 用户各项计数统计 -- `lazer_user_statistics` - 用户游戏统计(按模式) -- `lazer_user_achievements` - 用户成就 -- `lazer_oauth_tokens` - OAuth 访问令牌 -- 其他相关表... - -### 同步的数据 - -1. **用户基本信息** - - 从 `users` 表同步基本资料 - - 自动转换时间戳格式 - - 设置合理的默认值 - -2. **游戏统计** - - 从 `stats` 表同步各模式的游戏数据 - - 计算命中精度和其他衍生统计 - -3. **用户成就** - - 从 `user_achievements` 表同步成就数据(如果存在) - -## 注意事项 - -1. **安全性** - - 脚本只会创建新表和插入数据 - - 不会修改或删除现有的原始表数据 - - 使用 `ON DUPLICATE KEY UPDATE` 避免重复插入 - -2. **兼容性** - - 兼容现有的 bancho.py 数据库结构 - - 支持标准的 osu! 数据格式 - -3. **性能** - - 大量数据可能需要较长时间 - - 建议在维护窗口期间执行 - -## 故障排除 - -### 常见错误 - -1. **"Unknown column" 错误** - ``` - ERROR 1054: Unknown column 'users.is_active' in 'field list' - ``` - **解决方案**: 确保先执行了 `add_missing_fields.sql` 创建表结构 - -2. **"Table doesn't exist" 错误** - ``` - ERROR 1146: Table 'database.users' doesn't exist - ``` - **解决方案**: 确认数据库中存在 bancho.py 的原始表 - -3. **连接错误** - ``` - ERROR 2003: Can't connect to MySQL server - ``` - **解决方案**: 检查数据库连接配置和权限 - -### 验证同步结果 - -同步完成后,可以执行以下查询验证结果: - -```sql --- 检查同步的用户数量 -SELECT COUNT(*) FROM lazer_user_profiles; - --- 查看样本数据 -SELECT - u.id, u.name, - lup.playmode, lup.is_supporter, - lus.pp, lus.play_count -FROM users u -LEFT JOIN lazer_user_profiles lup ON u.id = lup.user_id -LEFT JOIN lazer_user_statistics lus ON u.id = lus.user_id AND lus.mode = 'osu' -LIMIT 5; -``` - -## 支持 - -如果遇到问题,请: -1. 检查日志文件 `data_sync.log` -2. 确认数据库权限 -3. 验证原始表数据完整性 diff --git a/create_sample_data.py b/create_sample_data.py deleted file mode 100644 index 5dcd79a..0000000 --- a/create_sample_data.py +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env python3 -""" -osu! API 模拟服务器的示例数据填充脚本 -""" - -from __future__ import annotations - -import asyncio -from datetime import datetime -import random - -from app.auth import get_password_hash -from app.database import ( - User, -) -from app.database.beatmap import Beatmap -from app.database.beatmapset import Beatmapset -from app.database.score import Score -from app.dependencies.database import create_tables, engine -from app.models.beatmap import BeatmapRankStatus, Genre, Language -from app.models.mods import APIMod -from app.models.score import GameMode, Rank - -from sqlmodel import select -from sqlmodel.ext.asyncio.session import AsyncSession - - -async def create_sample_user(): - """创建示例用户数据""" - async with AsyncSession(engine) as session: - async with session.begin(): - # 检查用户是否已存在 - result = await session.exec(select(User).where(User.name == "Googujiang")) - result2 = await session.exec( - select(User).where(User.name == "MingxuanGame") - ) - existing_user = result.first() - existing_user2 = result2.first() - if existing_user is not None and existing_user2 is not None: - print("示例用户已存在,跳过创建") - return - - # 当前时间戳 - # current_timestamp = int(time.time()) - join_timestamp = int(datetime(2019, 11, 29, 17, 23, 13).timestamp()) - last_visit_timestamp = int(datetime(2025, 7, 18, 16, 31, 29).timestamp()) - - # 创建用户 - user = User( - name="Googujiang", - safe_name="googujiang", # 安全用户名(小写) - email="googujiang@example.com", - priv=1, # 默认权限 - pw_bcrypt=get_password_hash("password123"), # 使用新的哈希方式 - country="JP", - silence_end=0, - donor_end=0, - creation_time=join_timestamp, - latest_activity=last_visit_timestamp, - clan_id=0, - clan_priv=0, - preferred_mode=0, # 0 = osu! - play_style=0, - custom_badge_name=None, - custom_badge_icon=None, - userpage_content="「世界に忘れられた」", - api_key=None, - ) - user2 = User( - name="MingxuanGame", - safe_name="mingxuangame", # 安全用户名(小写) - email="mingxuangame@example.com", - priv=1, # 默认权限 - pw_bcrypt=get_password_hash("password123"), # 使用新的哈希方式 - country="US", - silence_end=0, - donor_end=0, - creation_time=join_timestamp, - latest_activity=last_visit_timestamp, - clan_id=0, - clan_priv=0, - preferred_mode=0, # 0 = osu! - play_style=0, - custom_badge_name=None, - custom_badge_icon=None, - userpage_content="For love and fun!", - api_key=None, - ) - - session.add(user) - session.add(user2) - print(f"成功创建示例用户: {user.name} (ID: {user.id})") - print(f"安全用户名: {user.safe_name}") - print(f"邮箱: {user.email}") - print(f"国家: {user.country}") - print(f"成功创建示例用户: {user2.name} (ID: {user2.id})") - print(f"安全用户名: {user2.safe_name}") - print(f"邮箱: {user2.email}") - print(f"国家: {user2.country}") - - -async def create_sample_beatmap_data(): - """创建示例谱面数据""" - async with AsyncSession(engine) as session: - async with session.begin(): - user_id = random.randint(1, 1000) - # 检查谱面集是否已存在 - statement = select(Beatmapset).where(Beatmapset.id == 1) - result = await session.exec(statement) - existing_beatmapset = result.first() - if existing_beatmapset: - print("示例谱面集已存在,跳过创建") - return existing_beatmapset - - # 创建谱面集 - beatmapset = Beatmapset( - id=1, - artist="Example Artist", - artist_unicode="Example Artist", - covers=None, - creator="Googujiang", - favourite_count=0, - hype_current=0, - hype_required=0, - nsfw=False, - play_count=0, - preview_url="", - source="", - spotlight=False, - title="Example Song", - title_unicode="Example Song", - user_id=user_id, - video=False, - availability_info=None, - download_disabled=False, - bpm=180.0, - can_be_hyped=False, - discussion_locked=False, - last_updated=datetime.now(), - ranked_date=datetime.now(), - storyboard=False, - submitted_date=datetime.now(), - current_nominations=[], - beatmap_status=BeatmapRankStatus.RANKED, - beatmap_genre=Genre.ANY, # 使用整数表示Genre枚举 - beatmap_language=Language.ANY, # 使用整数表示Language枚举 - nominations_required=0, - nominations_current=0, - pack_tags=[], - ratings=[], - ) - session.add(beatmapset) - - # 创建谱面 - beatmap = Beatmap( - id=1, - url="", - mode=GameMode.OSU, - beatmapset_id=1, - difficulty_rating=5.5, - beatmap_status=BeatmapRankStatus.RANKED, - total_length=195, - user_id=user_id, - version="Example Difficulty", - checksum="example_checksum", - current_user_playcount=0, - max_combo=1200, - ar=9.0, - cs=4.0, - drain=5.0, - accuracy=8.0, - bpm=180.0, - count_circles=1000, - count_sliders=200, - count_spinners=1, - deleted_at=None, - hit_length=180, - last_updated=datetime.now(), - passcount=10, - playcount=50, - ) - session.add(beatmap) - - # 创建成绩 - score = Score( - id=1, - accuracy=0.9876, - map_md5="example_checksum", - user_id=1, - best_id=1, - build_id=None, - classic_total_score=1234567, - ended_at=datetime.now(), - has_replay=True, - max_combo=1100, - mods=[ - APIMod(acronym="HD", settings={}), - APIMod(acronym="DT", settings={}), - ], - passed=True, - playlist_item_id=None, - pp=250.5, - preserve=True, - rank=Rank.S, - room_id=None, - gamemode=GameMode.OSU, - started_at=datetime.now(), - total_score=1234567, - type="solo_score", - position=None, - beatmap_id=1, - n300=950, - n100=30, - n50=20, - nmiss=5, - ngeki=150, - nkatu=50, - nlarge_tick_miss=None, - nslider_tail_hit=None, - ) - session.add(score) - - print(f"成功创建示例谱面集: {beatmapset.title} (ID: {beatmapset.id})") - print(f"成功创建示例谱面: {beatmap.version} (ID: {beatmap.id})") - print(f"成功创建示例成绩: ID {score.id}") - return beatmapset - - -async def main(): - print("开始创建示例数据...") - await create_tables() - await create_sample_user() - await create_sample_beatmap_data() - print("示例数据创建完成!") - # print(f"用户名: {user.name}") - # print("密码: password123") - # print("现在您可以使用这些凭据来测试API了。") - await engine.dispose() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/migrations_old/add_lazer_rank_fields.sql b/migrations_old/add_lazer_rank_fields.sql deleted file mode 100644 index d811c90..0000000 --- a/migrations_old/add_lazer_rank_fields.sql +++ /dev/null @@ -1,16 +0,0 @@ --- 创建迁移日志表(如果不存在) -CREATE TABLE IF NOT EXISTS `migration_logs` ( - `id` INT AUTO_INCREMENT PRIMARY KEY, - `version` VARCHAR(50) NOT NULL, - `description` VARCHAR(255) NOT NULL, - `timestamp` DATETIME NOT NULL -); - --- 向 lazer_user_statistics 表添加缺失的字段 -ALTER TABLE `lazer_user_statistics` -ADD COLUMN IF NOT EXISTS `rank_highest` INT NULL COMMENT '最高排名' AFTER `grade_a`, -ADD COLUMN IF NOT EXISTS `rank_highest_updated_at` DATETIME NULL COMMENT '最高排名更新时间' AFTER `rank_highest`; - --- 更新日志 -INSERT INTO `migration_logs` (`version`, `description`, `timestamp`) -VALUES ('20250719', '向 lazer_user_statistics 表添加缺失的字段', NOW()); diff --git a/migrations_old/add_missing_fields.sql b/migrations_old/add_missing_fields.sql deleted file mode 100644 index 464e5fa..0000000 --- a/migrations_old/add_missing_fields.sql +++ /dev/null @@ -1,421 +0,0 @@ --- Lazer API 专用数据表创建脚本 --- 基于真实 osu! API 返回数据设计的表结构 --- 完全不修改 bancho.py 原有表结构,创建全新的 lazer 专用表 - --- ============================================ --- Lazer API 专用扩展表 --- ============================================ - --- Lazer 用户扩展信息表 -CREATE TABLE IF NOT EXISTS lazer_user_profiles ( - user_id INT PRIMARY KEY COMMENT '关联 users.id', - - -- 基本状态字段 - is_active TINYINT(1) DEFAULT 1 COMMENT '用户是否激活', - is_bot TINYINT(1) DEFAULT 0 COMMENT '是否为机器人账户', - is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否已删除', - is_online TINYINT(1) DEFAULT 1 COMMENT '是否在线', - is_supporter TINYINT(1) DEFAULT 0 COMMENT '是否为支持者', - is_restricted TINYINT(1) DEFAULT 0 COMMENT '是否被限制', - session_verified TINYINT(1) DEFAULT 0 COMMENT '会话是否已验证', - has_supported TINYINT(1) DEFAULT 0 COMMENT '是否曾经支持过', - pm_friends_only TINYINT(1) DEFAULT 0 COMMENT '是否只接受好友私信', - - -- 基本资料字段 - default_group VARCHAR(50) DEFAULT 'default' COMMENT '默认用户组', - last_visit DATETIME NULL COMMENT '最后访问时间', - join_date DATETIME NULL COMMENT '加入日期', - profile_colour VARCHAR(7) NULL COMMENT '个人资料颜色', - profile_hue INT NULL COMMENT '个人资料色调', - - -- 社交媒体和个人资料字段 - avatar_url VARCHAR(500) NULL COMMENT '头像URL', - cover_url VARCHAR(500) NULL COMMENT '封面URL', - discord VARCHAR(100) NULL COMMENT 'Discord用户名', - twitter VARCHAR(100) NULL COMMENT 'Twitter用户名', - website VARCHAR(500) NULL COMMENT '个人网站', - title VARCHAR(100) NULL COMMENT '用户称号', - title_url VARCHAR(500) NULL COMMENT '称号链接', - interests TEXT NULL COMMENT '兴趣爱好', - location VARCHAR(100) NULL COMMENT '地理位置', - occupation VARCHAR(100) NULL COMMENT '职业', - - -- 游戏相关字段 - playmode VARCHAR(10) DEFAULT 'osu' COMMENT '主要游戏模式', - support_level INT DEFAULT 0 COMMENT '支持者等级', - max_blocks INT DEFAULT 100 COMMENT '最大屏蔽数量', - max_friends INT DEFAULT 500 COMMENT '最大好友数量', - post_count INT DEFAULT 0 COMMENT '帖子数量', - - -- 页面内容 - page_html TEXT NULL COMMENT '个人页面HTML', - page_raw TEXT NULL COMMENT '个人页面原始内容', - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Lazer API 用户扩展资料表'; - --- 用户封面信息表 -CREATE TABLE IF NOT EXISTS lazer_user_covers ( - user_id INT PRIMARY KEY COMMENT '关联 users.id', - custom_url VARCHAR(500) NULL COMMENT '自定义封面URL', - url VARCHAR(500) NULL COMMENT '封面URL', - cover_id INT NULL COMMENT '封面ID', - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户封面信息表'; - --- 用户国家信息表 -CREATE TABLE IF NOT EXISTS lazer_user_countries ( - user_id INT PRIMARY KEY COMMENT '关联 users.id', - code VARCHAR(2) NOT NULL COMMENT '国家代码', - name VARCHAR(100) NOT NULL COMMENT '国家名称', - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户国家信息表'; - --- 用户 Kudosu 表 -CREATE TABLE IF NOT EXISTS lazer_user_kudosu ( - user_id INT PRIMARY KEY COMMENT '关联 users.id', - available INT DEFAULT 0 COMMENT '可用 Kudosu', - total INT DEFAULT 0 COMMENT '总 Kudosu', - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 Kudosu 表'; - --- 用户统计计数表 -CREATE TABLE IF NOT EXISTS lazer_user_counts ( - user_id INT PRIMARY KEY COMMENT '关联 users.id', - - -- 统计计数字段 - beatmap_playcounts_count INT DEFAULT 0 COMMENT '谱面游玩次数统计', - comments_count INT DEFAULT 0 COMMENT '评论数量', - favourite_beatmapset_count INT DEFAULT 0 COMMENT '收藏谱面集数量', - follower_count INT DEFAULT 0 COMMENT '关注者数量', - graveyard_beatmapset_count INT DEFAULT 0 COMMENT '坟场谱面集数量', - guest_beatmapset_count INT DEFAULT 0 COMMENT '客串谱面集数量', - loved_beatmapset_count INT DEFAULT 0 COMMENT '被喜爱谱面集数量', - mapping_follower_count INT DEFAULT 0 COMMENT '作图关注者数量', - nominated_beatmapset_count INT DEFAULT 0 COMMENT '提名谱面集数量', - pending_beatmapset_count INT DEFAULT 0 COMMENT '待审核谱面集数量', - ranked_beatmapset_count INT DEFAULT 0 COMMENT 'Ranked谱面集数量', - ranked_and_approved_beatmapset_count INT DEFAULT 0 COMMENT 'Ranked+Approved谱面集数量', - unranked_beatmapset_count INT DEFAULT 0 COMMENT '未Ranked谱面集数量', - scores_best_count INT DEFAULT 0 COMMENT '最佳成绩数量', - scores_first_count INT DEFAULT 0 COMMENT '第一名成绩数量', - scores_pinned_count INT DEFAULT 0 COMMENT '置顶成绩数量', - scores_recent_count INT DEFAULT 0 COMMENT '最近成绩数量', - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Lazer API 用户统计计数表'; - --- 用户游戏风格表 (替代 playstyle JSON) -CREATE TABLE IF NOT EXISTS lazer_user_playstyles ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - style VARCHAR(50) NOT NULL COMMENT '游戏风格: mouse, keyboard, tablet, touch', - - INDEX idx_user_id (user_id), - UNIQUE KEY unique_user_style (user_id, style), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户游戏风格表'; - --- 用户个人资料显示顺序表 (替代 profile_order JSON) -CREATE TABLE IF NOT EXISTS lazer_user_profile_sections ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - section_name VARCHAR(50) NOT NULL COMMENT '部分名称', - display_order INT DEFAULT 0 COMMENT '显示顺序', - - INDEX idx_user_id (user_id), - INDEX idx_order (user_id, display_order), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户个人资料显示顺序表'; - --- 用户账户历史表 (替代 account_history JSON) -CREATE TABLE IF NOT EXISTS lazer_user_account_history ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - event_type VARCHAR(50) NOT NULL COMMENT '事件类型', - description TEXT COMMENT '事件描述', - length INT COMMENT '持续时间(秒)', - permanent TINYINT(1) DEFAULT 0 COMMENT '是否永久', - event_time DATETIME NOT NULL COMMENT '事件时间', - - INDEX idx_user_id (user_id), - INDEX idx_event_time (event_time), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户账户历史表'; - --- 用户历史用户名表 (替代 previous_usernames JSON) -CREATE TABLE IF NOT EXISTS lazer_user_previous_usernames ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - username VARCHAR(32) NOT NULL COMMENT '历史用户名', - changed_at DATETIME NOT NULL COMMENT '更改时间', - - INDEX idx_user_id (user_id), - INDEX idx_changed_at (changed_at), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户历史用户名表'; - --- 用户月度游戏次数表 (替代 monthly_playcounts JSON) -CREATE TABLE IF NOT EXISTS lazer_user_monthly_playcounts ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - start_date DATE NOT NULL COMMENT '月份开始日期', - play_count INT DEFAULT 0 COMMENT '游戏次数', - - INDEX idx_user_id (user_id), - INDEX idx_start_date (start_date), - UNIQUE KEY unique_user_month (user_id, start_date), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户月度游戏次数表'; - --- 用户最高排名表 (rank_highest) -CREATE TABLE IF NOT EXISTS lazer_user_rank_highest ( - user_id INT PRIMARY KEY COMMENT '关联 users.id', - rank_position INT NOT NULL COMMENT '最高排名位置', - updated_at DATETIME NOT NULL COMMENT '更新时间', - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户最高排名表'; - --- ============================================ --- OAuth 令牌表 (Lazer API 专用) --- ============================================ -CREATE TABLE IF NOT EXISTS lazer_oauth_tokens ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - access_token VARCHAR(255) NOT NULL, - refresh_token VARCHAR(255) NOT NULL, - expires_at DATETIME NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_user_id (user_id), - INDEX idx_access_token (access_token), - INDEX idx_refresh_token (refresh_token), - INDEX idx_expires_at (expires_at), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Lazer API OAuth访问令牌表'; - --- ============================================ --- 用户统计数据表 (基于真实 API 数据结构) --- ============================================ - --- 用户主要统计表 (statistics 字段) -CREATE TABLE IF NOT EXISTS lazer_user_statistics ( - user_id INT NOT NULL, - mode VARCHAR(10) NOT NULL DEFAULT 'osu' COMMENT '游戏模式: osu, taiko, fruits, mania', - - -- 基本命中统计 - count_100 INT DEFAULT 0 COMMENT '100分命中数', - count_300 INT DEFAULT 0 COMMENT '300分命中数', - count_50 INT DEFAULT 0 COMMENT '50分命中数', - count_miss INT DEFAULT 0 COMMENT 'Miss数', - - -- 等级信息 - level_current INT DEFAULT 1 COMMENT '当前等级', - level_progress INT DEFAULT 0 COMMENT '等级进度', - - -- 排名信息 - global_rank INT NULL COMMENT '全球排名', - global_rank_exp INT NULL COMMENT '全球排名(实验性)', - country_rank INT NULL COMMENT '国家/地区排名', - - -- PP 和分数 - pp DECIMAL(10,2) DEFAULT 0.00 COMMENT 'Performance Points', - pp_exp DECIMAL(10,2) DEFAULT 0.00 COMMENT 'PP(实验性)', - ranked_score BIGINT DEFAULT 0 COMMENT 'Ranked分数', - hit_accuracy DECIMAL(5,2) DEFAULT 0.00 COMMENT '命中精度', - total_score BIGINT DEFAULT 0 COMMENT '总分数', - total_hits BIGINT DEFAULT 0 COMMENT '总命中数', - maximum_combo INT DEFAULT 0 COMMENT '最大连击', - - -- 游戏统计 - play_count INT DEFAULT 0 COMMENT '游戏次数', - play_time INT DEFAULT 0 COMMENT '游戏时间(秒)', - replays_watched_by_others INT DEFAULT 0 COMMENT '被观看的Replay次数', - is_ranked TINYINT(1) DEFAULT 0 COMMENT '是否有排名', - - -- 成绩等级计数 (grade_counts) - grade_ss INT DEFAULT 0 COMMENT 'SS等级数', - grade_ssh INT DEFAULT 0 COMMENT 'SSH等级数', - grade_s INT DEFAULT 0 COMMENT 'S等级数', - grade_sh INT DEFAULT 0 COMMENT 'SH等级数', - grade_a INT DEFAULT 0 COMMENT 'A等级数', - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - PRIMARY KEY (user_id, mode), - INDEX idx_mode (mode), - INDEX idx_global_rank (global_rank), - INDEX idx_country_rank (country_rank), - INDEX idx_pp (pp), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Lazer API 用户游戏统计表'; - --- 每日挑战用户统计表 (daily_challenge_user_stats) -CREATE TABLE IF NOT EXISTS lazer_daily_challenge_stats ( - user_id INT PRIMARY KEY COMMENT '关联 users.id', - daily_streak_best INT DEFAULT 0 COMMENT '最佳每日连击', - daily_streak_current INT DEFAULT 0 COMMENT '当前每日连击', - last_update DATE NULL COMMENT '最后更新日期', - last_weekly_streak DATE NULL COMMENT '最后周连击日期', - playcount INT DEFAULT 0 COMMENT '游戏次数', - top_10p_placements INT DEFAULT 0 COMMENT 'Top 10% 位置数', - top_50p_placements INT DEFAULT 0 COMMENT 'Top 50% 位置数', - weekly_streak_best INT DEFAULT 0 COMMENT '最佳周连击', - weekly_streak_current INT DEFAULT 0 COMMENT '当前周连击', - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每日挑战用户统计表'; - --- 用户团队信息表 (team 字段) -CREATE TABLE IF NOT EXISTS lazer_user_teams ( - user_id INT PRIMARY KEY COMMENT '关联 users.id', - team_id INT NOT NULL COMMENT '团队ID', - team_name VARCHAR(100) NOT NULL COMMENT '团队名称', - team_short_name VARCHAR(10) NOT NULL COMMENT '团队简称', - flag_url VARCHAR(500) NULL COMMENT '团队旗帜URL', - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户团队信息表'; - --- 用户成就表 (user_achievements) -CREATE TABLE IF NOT EXISTS lazer_user_achievements ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - achievement_id INT NOT NULL COMMENT '成就ID', - achieved_at DATETIME NOT NULL COMMENT '获得时间', - - INDEX idx_user_id (user_id), - INDEX idx_achievement_id (achievement_id), - INDEX idx_achieved_at (achieved_at), - UNIQUE KEY unique_user_achievement (user_id, achievement_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户成就表'; - --- 用户排名历史表 (rank_history) -CREATE TABLE IF NOT EXISTS lazer_user_rank_history ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - mode VARCHAR(10) NOT NULL DEFAULT 'osu' COMMENT '游戏模式', - day_offset INT NOT NULL COMMENT '天数偏移量(从某个基准日期开始)', - rank_position INT NOT NULL COMMENT '排名位置', - - INDEX idx_user_mode (user_id, mode), - INDEX idx_day_offset (day_offset), - UNIQUE KEY unique_user_mode_day (user_id, mode, day_offset), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户排名历史表'; - --- Replay 观看次数表 (replays_watched_counts) -CREATE TABLE IF NOT EXISTS lazer_user_replays_watched ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - start_date DATE NOT NULL COMMENT '开始日期', - count INT DEFAULT 0 COMMENT '观看次数', - - INDEX idx_user_id (user_id), - INDEX idx_start_date (start_date), - UNIQUE KEY unique_user_date (user_id, start_date), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户Replay观看次数表'; - --- 用户徽章表 (badges) -CREATE TABLE IF NOT EXISTS lazer_user_badges ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - badge_id INT NOT NULL COMMENT '徽章ID', - awarded_at DATETIME NULL COMMENT '授予时间', - description TEXT NULL COMMENT '徽章描述', - image_url VARCHAR(500) NULL COMMENT '徽章图片URL', - url VARCHAR(500) NULL COMMENT '徽章链接', - - INDEX idx_user_id (user_id), - INDEX idx_badge_id (badge_id), - UNIQUE KEY unique_user_badge (user_id, badge_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户徽章表'; - --- 用户组表 (groups) -CREATE TABLE IF NOT EXISTS lazer_user_groups ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - group_id INT NOT NULL COMMENT '用户组ID', - group_name VARCHAR(100) NOT NULL COMMENT '用户组名称', - group_identifier VARCHAR(50) NULL COMMENT '用户组标识符', - colour VARCHAR(7) NULL COMMENT '用户组颜色', - is_probationary TINYINT(1) DEFAULT 0 COMMENT '是否为试用期', - has_listing TINYINT(1) DEFAULT 1 COMMENT '是否显示在列表中', - has_playmodes TINYINT(1) DEFAULT 0 COMMENT '是否有游戏模式', - - INDEX idx_user_id (user_id), - INDEX idx_group_id (group_id), - UNIQUE KEY unique_user_group (user_id, group_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户组表'; - --- 锦标赛横幅表 (active_tournament_banners) -CREATE TABLE IF NOT EXISTS lazer_user_tournament_banners ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - tournament_id INT NOT NULL COMMENT '锦标赛ID', - image_url VARCHAR(500) NOT NULL COMMENT '横幅图片URL', - is_active TINYINT(1) DEFAULT 1 COMMENT '是否为当前活跃横幅', - - INDEX idx_user_id (user_id), - INDEX idx_tournament_id (tournament_id), - INDEX idx_is_active (is_active), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户锦标赛横幅表'; - --- ============================================ --- 占位表 (未来功能扩展用) --- ============================================ - --- 当前赛季统计占位表 -CREATE TABLE IF NOT EXISTS lazer_current_season_stats ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '关联 users.id', - season_id VARCHAR(50) NOT NULL COMMENT '赛季ID', - data_placeholder TEXT COMMENT '赛季数据占位', - - INDEX idx_user_id (user_id), - INDEX idx_season_id (season_id), - UNIQUE KEY unique_user_season (user_id, season_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='当前赛季统计占位表'; - --- 其他功能占位表 -CREATE TABLE IF NOT EXISTS lazer_feature_placeholder ( - id INT AUTO_INCREMENT PRIMARY KEY, - feature_type VARCHAR(50) NOT NULL COMMENT '功能类型', - entity_id INT NOT NULL COMMENT '实体ID', - data_placeholder TEXT COMMENT '功能数据占位', - - INDEX idx_feature_type (feature_type), - INDEX idx_entity_id (entity_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='功能扩展占位表'; diff --git a/migrations_old/base.sql b/migrations_old/base.sql deleted file mode 100644 index 665723f..0000000 --- a/migrations_old/base.sql +++ /dev/null @@ -1,486 +0,0 @@ -create table achievements -( - id int auto_increment - primary key, - file varchar(128) not null, - name varchar(128) charset utf8 not null, - `desc` varchar(256) charset utf8 not null, - cond varchar(64) not null, - constraint achievements_desc_uindex - unique (`desc`), - constraint achievements_file_uindex - unique (file), - constraint achievements_name_uindex - unique (name) -); - -create table channels -( - id int auto_increment - primary key, - name varchar(32) not null, - topic varchar(256) not null, - read_priv int default 1 not null, - write_priv int default 2 not null, - auto_join tinyint(1) default 0 not null, - constraint channels_name_uindex - unique (name) -); -create index channels_auto_join_index - on channels (auto_join); - -create table clans -( - id int auto_increment - primary key, - name varchar(16) charset utf8 not null, - tag varchar(6) charset utf8 not null, - owner int not null, - created_at datetime not null, - constraint clans_name_uindex - unique (name), - constraint clans_owner_uindex - unique (owner), - constraint clans_tag_uindex - unique (tag) -); - -create table client_hashes -( - userid int not null, - osupath char(32) not null, - adapters char(32) not null, - uninstall_id char(32) not null, - disk_serial char(32) not null, - latest_time datetime not null, - occurrences int default 0 not null, - primary key (userid, osupath, adapters, uninstall_id, disk_serial) -); - -create table comments -( - id int auto_increment - primary key, - target_id int not null comment 'replay, map, or set id', - target_type enum('replay', 'map', 'song') not null, - userid int not null, - time int not null, - comment varchar(80) charset utf8 not null, - colour char(6) null comment 'rgb hex string' -); - -create table favourites -( - userid int not null, - setid int not null, - created_at int default 0 not null, - primary key (userid, setid) -); - -create table ingame_logins -( - id int auto_increment - primary key, - userid int not null, - ip varchar(45) not null comment 'maxlen for ipv6', - osu_ver date not null, - osu_stream varchar(11) not null, - datetime datetime not null -); - -create table relationships -( - user1 int not null, - user2 int not null, - type enum('friend', 'block') not null, - primary key (user1, user2) -); - -create table logs -( - id int auto_increment - primary key, - `from` int not null comment 'both from and to are playerids', - `to` int not null, - `action` varchar(32) not null, - msg varchar(2048) charset utf8 null, - time datetime not null on update CURRENT_TIMESTAMP -); - -create table mail -( - id int auto_increment - primary key, - from_id int not null, - to_id int not null, - msg varchar(2048) charset utf8 not null, - time int null, - `read` tinyint(1) default 0 not null -); - -create table maps -( - server enum('osu!', 'private') default 'osu!' not null, - id int not null, - set_id int not null, - status int not null, - md5 char(32) not null, - artist varchar(128) charset utf8 not null, - title varchar(128) charset utf8 not null, - version varchar(128) charset utf8 not null, - creator varchar(19) charset utf8 not null, - filename varchar(256) charset utf8 not null, - last_update datetime not null, - total_length int not null, - max_combo int not null, - frozen tinyint(1) default 0 not null, - plays int default 0 not null, - passes int default 0 not null, - mode tinyint(1) default 0 not null, - bpm float(12,2) default 0.00 not null, - cs float(4,2) default 0.00 not null, - ar float(4,2) default 0.00 not null, - od float(4,2) default 0.00 not null, - hp float(4,2) default 0.00 not null, - diff float(6,3) default 0.000 not null, - primary key (server, id), - constraint maps_id_uindex - unique (id), - constraint maps_md5_uindex - unique (md5) -); -create index maps_set_id_index - on maps (set_id); -create index maps_status_index - on maps (status); -create index maps_filename_index - on maps (filename); -create index maps_plays_index - on maps (plays); -create index maps_mode_index - on maps (mode); -create index maps_frozen_index - on maps (frozen); - -create table mapsets -( - server enum('osu!', 'private') default 'osu!' not null, - id int not null, - last_osuapi_check datetime default CURRENT_TIMESTAMP not null, - primary key (server, id), - constraint nmapsets_id_uindex - unique (id) -); - -create table map_requests -( - id int auto_increment - primary key, - map_id int not null, - player_id int not null, - datetime datetime not null, - active tinyint(1) not null -); - -create table performance_reports -( - scoreid bigint(20) unsigned not null, - mod_mode enum('vanilla', 'relax', 'autopilot') default 'vanilla' not null, - os varchar(64) not null, - fullscreen tinyint(1) not null, - fps_cap varchar(16) not null, - compatibility tinyint(1) not null, - version varchar(16) not null, - start_time int not null, - end_time int not null, - frame_count int not null, - spike_frames int not null, - aim_rate int not null, - completion tinyint(1) not null, - identifier varchar(128) null comment 'really don''t know much about this yet', - average_frametime int not null, - primary key (scoreid, mod_mode) -); - -create table ratings -( - userid int not null, - map_md5 char(32) not null, - rating tinyint(2) not null, - primary key (userid, map_md5) -); - -create table scores -( - id bigint unsigned auto_increment - primary key, - map_md5 char(32) not null, - score int not null, - pp float(7,3) not null, - acc float(6,3) not null, - max_combo int not null, - mods int not null, - n300 int not null, - n100 int not null, - n50 int not null, - nmiss int not null, - ngeki int not null, - nkatu int not null, - grade varchar(2) default 'N' not null, - status tinyint not null, - mode tinyint not null, - play_time datetime not null, - time_elapsed int not null, - client_flags int not null, - userid int not null, - perfect tinyint(1) not null, - online_checksum char(32) not null -); -create index scores_map_md5_index - on scores (map_md5); -create index scores_score_index - on scores (score); -create index scores_pp_index - on scores (pp); -create index scores_mods_index - on scores (mods); -create index scores_status_index - on scores (status); -create index scores_mode_index - on scores (mode); -create index scores_play_time_index - on scores (play_time); -create index scores_userid_index - on scores (userid); -create index scores_online_checksum_index - on scores (online_checksum); -create index scores_fetch_leaderboard_generic_index - on scores (map_md5, status, mode); - -create table startups -( - id int auto_increment - primary key, - ver_major tinyint not null, - ver_minor tinyint not null, - ver_micro tinyint not null, - datetime datetime not null -); - -create table stats -( - id int auto_increment, - mode tinyint(1) not null, - tscore bigint unsigned default 0 not null, - rscore bigint unsigned default 0 not null, - pp int unsigned default 0 not null, - plays int unsigned default 0 not null, - playtime int unsigned default 0 not null, - acc float(6,3) default 0.000 not null, - max_combo int unsigned default 0 not null, - total_hits int unsigned default 0 not null, - replay_views int unsigned default 0 not null, - xh_count int unsigned default 0 not null, - x_count int unsigned default 0 not null, - sh_count int unsigned default 0 not null, - s_count int unsigned default 0 not null, - a_count int unsigned default 0 not null, - primary key (id, mode) -); -create index stats_mode_index - on stats (mode); -create index stats_pp_index - on stats (pp); -create index stats_tscore_index - on stats (tscore); -create index stats_rscore_index - on stats (rscore); - -create table tourney_pool_maps -( - map_id int not null, - pool_id int not null, - mods int not null, - slot tinyint not null, - primary key (map_id, pool_id) -); -create index tourney_pool_maps_mods_slot_index - on tourney_pool_maps (mods, slot); -create index tourney_pool_maps_tourney_pools_id_fk - on tourney_pool_maps (pool_id); - -create table tourney_pools -( - id int auto_increment - primary key, - name varchar(16) not null, - created_at datetime not null, - created_by int not null -); - -create index tourney_pools_users_id_fk - on tourney_pools (created_by); - -create table user_achievements -( - userid int not null, - achid int not null, - primary key (userid, achid) -); -create index user_achievements_achid_index - on user_achievements (achid); -create index user_achievements_userid_index - on user_achievements (userid); - -create table users -( - id int auto_increment - primary key, - name varchar(32) charset utf8 not null, - safe_name varchar(32) charset utf8 not null, - email varchar(254) not null, - priv int default 1 not null, - pw_bcrypt char(60) not null, - country char(2) default 'xx' not null, - silence_end int default 0 not null, - donor_end int default 0 not null, - creation_time int default 0 not null, - latest_activity int default 0 not null, - clan_id int default 0 not null, - clan_priv tinyint(1) default 0 not null, - preferred_mode int default 0 not null, - play_style int default 0 not null, - custom_badge_name varchar(16) charset utf8 null, - custom_badge_icon varchar(64) null, - userpage_content varchar(2048) charset utf8 null, - api_key char(36) null, - constraint users_api_key_uindex - unique (api_key), - constraint users_email_uindex - unique (email), - constraint users_name_uindex - unique (name), - constraint users_safe_name_uindex - unique (safe_name) -); -create index users_priv_index - on users (priv); -create index users_clan_id_index - on users (clan_id); -create index users_clan_priv_index - on users (clan_priv); -create index users_country_index - on users (country); - -insert into users (id, name, safe_name, priv, country, silence_end, email, pw_bcrypt, creation_time, latest_activity) -values (1, 'BanchoBot', 'banchobot', 1, 'ca', 0, 'bot@akatsuki.pw', - '_______________________my_cool_bcrypt_______________________', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()); - -INSERT INTO stats (id, mode) VALUES (1, 0); # vn!std -INSERT INTO stats (id, mode) VALUES (1, 1); # vn!taiko -INSERT INTO stats (id, mode) VALUES (1, 2); # vn!catch -INSERT INTO stats (id, mode) VALUES (1, 3); # vn!mania -INSERT INTO stats (id, mode) VALUES (1, 4); # rx!std -INSERT INTO stats (id, mode) VALUES (1, 5); # rx!taiko -INSERT INTO stats (id, mode) VALUES (1, 6); # rx!catch -INSERT INTO stats (id, mode) VALUES (1, 8); # ap!std - - -# userid 2 is reserved for ppy in osu!, and the -# client will not allow users to pm this id. -# If you want this, simply remove these two lines. -alter table users auto_increment = 3; -alter table stats auto_increment = 3; - -insert into channels (name, topic, read_priv, write_priv, auto_join) -values ('#osu', 'General discussion.', 1, 2, true), - ('#announce', 'Exemplary performance and public announcements.', 1, 24576, true), - ('#lobby', 'Multiplayer lobby discussion room.', 1, 2, false), - ('#supporter', 'General discussion for supporters.', 48, 48, false), - ('#staff', 'General discussion for staff members.', 28672, 28672, true), - ('#admin', 'General discussion for administrators.', 24576, 24576, true), - ('#dev', 'General discussion for developers.', 16384, 16384, true); - -insert into achievements (id, file, name, `desc`, cond) values (1, 'osu-skill-pass-1', 'Rising Star', 'Can''t go forward without the first steps.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (2, 'osu-skill-pass-2', 'Constellation Prize', 'Definitely not a consolation prize. Now things start getting hard!', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (3, 'osu-skill-pass-3', 'Building Confidence', 'Oh, you''ve SO got this.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (4, 'osu-skill-pass-4', 'Insanity Approaches', 'You''re not twitching, you''re just ready.', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (5, 'osu-skill-pass-5', 'These Clarion Skies', 'Everything seems so clear now.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (6, 'osu-skill-pass-6', 'Above and Beyond', 'A cut above the rest.', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (7, 'osu-skill-pass-7', 'Supremacy', 'All marvel before your prowess.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (8, 'osu-skill-pass-8', 'Absolution', 'My god, you''re full of stars!', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (9, 'osu-skill-pass-9', 'Event Horizon', 'No force dares to pull you under.', '(score.mods & 1 == 0) and 9 <= score.sr < 10 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (10, 'osu-skill-pass-10', 'Phantasm', 'Fevered is your passion, extraordinary is your skill.', '(score.mods & 1 == 0) and 10 <= score.sr < 11 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (11, 'osu-skill-fc-1', 'Totality', 'All the notes. Every single one.', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (12, 'osu-skill-fc-2', 'Business As Usual', 'Two to go, please.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (13, 'osu-skill-fc-3', 'Building Steam', 'Hey, this isn''t so bad.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (14, 'osu-skill-fc-4', 'Moving Forward', 'Bet you feel good about that.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (15, 'osu-skill-fc-5', 'Paradigm Shift', 'Surprisingly difficult.', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (16, 'osu-skill-fc-6', 'Anguish Quelled', 'Don''t choke.', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (17, 'osu-skill-fc-7', 'Never Give Up', 'Excellence is its own reward.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (18, 'osu-skill-fc-8', 'Aberration', 'They said it couldn''t be done. They were wrong.', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (19, 'osu-skill-fc-9', 'Chosen', 'Reign among the Prometheans, where you belong.', 'score.perfect and 9 <= score.sr < 10 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (20, 'osu-skill-fc-10', 'Unfathomable', 'You have no equal.', 'score.perfect and 10 <= score.sr < 11 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (21, 'osu-combo-500', '500 Combo', '500 big ones! You''re moving up in the world!', '500 <= score.max_combo < 750 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (22, 'osu-combo-750', '750 Combo', '750 notes back to back? Woah.', '750 <= score.max_combo < 1000 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (23, 'osu-combo-1000', '1000 Combo', 'A thousand reasons why you rock at this game.', '1000 <= score.max_combo < 2000 and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (24, 'osu-combo-2000', '2000 Combo', 'Nothing can stop you now.', '2000 <= score.max_combo and mode_vn == 0'); -insert into achievements (id, file, name, `desc`, cond) values (25, 'taiko-skill-pass-1', 'My First Don', 'Marching to the beat of your own drum. Literally.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (26, 'taiko-skill-pass-2', 'Katsu Katsu Katsu', 'Hora! Izuko!', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (27, 'taiko-skill-pass-3', 'Not Even Trying', 'Muzukashii? Not even.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (28, 'taiko-skill-pass-4', 'Face Your Demons', 'The first trials are now behind you, but are you a match for the Oni?', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (29, 'taiko-skill-pass-5', 'The Demon Within', 'No rest for the wicked.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (30, 'taiko-skill-pass-6', 'Drumbreaker', 'Too strong.', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (31, 'taiko-skill-pass-7', 'The Godfather', 'You are the Don of Dons.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (32, 'taiko-skill-pass-8', 'Rhythm Incarnate', 'Feel the beat. Become the beat.', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (33, 'taiko-skill-fc-1', 'Keeping Time', 'Don, then katsu. Don, then katsu..', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (34, 'taiko-skill-fc-2', 'To Your Own Beat', 'Straight and steady.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (35, 'taiko-skill-fc-3', 'Big Drums', 'Bigger scores to match.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (36, 'taiko-skill-fc-4', 'Adversity Overcome', 'Difficult? Not for you.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (37, 'taiko-skill-fc-5', 'Demonslayer', 'An Oni felled forevermore.', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (38, 'taiko-skill-fc-6', 'Rhythm''s Call', 'Heralding true skill.', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (39, 'taiko-skill-fc-7', 'Time Everlasting', 'Not a single beat escapes you.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (40, 'taiko-skill-fc-8', 'The Drummer''s Throne', 'Percussive brilliance befitting royalty alone.', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 1'); -insert into achievements (id, file, name, `desc`, cond) values (41, 'fruits-skill-pass-1', 'A Slice Of Life', 'Hey, this fruit catching business isn''t bad.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (42, 'fruits-skill-pass-2', 'Dashing Ever Forward', 'Fast is how you do it.', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (43, 'fruits-skill-pass-3', 'Zesty Disposition', 'No scurvy for you, not with that much fruit.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (44, 'fruits-skill-pass-4', 'Hyperdash ON!', 'Time and distance is no obstacle to you.', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (45, 'fruits-skill-pass-5', 'It''s Raining Fruit', 'And you can catch them all.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (46, 'fruits-skill-pass-6', 'Fruit Ninja', 'Legendary techniques.', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (47, 'fruits-skill-pass-7', 'Dreamcatcher', 'No fruit, only dreams now.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (48, 'fruits-skill-pass-8', 'Lord of the Catch', 'Your kingdom kneels before you.', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (49, 'fruits-skill-fc-1', 'Sweet And Sour', 'Apples and oranges, literally.', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (50, 'fruits-skill-fc-2', 'Reaching The Core', 'The seeds of future success.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (51, 'fruits-skill-fc-3', 'Clean Platter', 'Clean only of failure. It is completely full, otherwise.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (52, 'fruits-skill-fc-4', 'Between The Rain', 'No umbrella needed.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (53, 'fruits-skill-fc-5', 'Addicted', 'That was an overdose?', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (54, 'fruits-skill-fc-6', 'Quickening', 'A dash above normal limits.', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (55, 'fruits-skill-fc-7', 'Supersonic', 'Faster than is reasonably necessary.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (56, 'fruits-skill-fc-8', 'Dashing Scarlet', 'Speed beyond mortal reckoning.', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 2'); -insert into achievements (id, file, name, `desc`, cond) values (57, 'mania-skill-pass-1', 'First Steps', 'It isn''t 9-to-5, but 1-to-9. Keys, that is.', '(score.mods & 1 == 0) and 1 <= score.sr < 2 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (58, 'mania-skill-pass-2', 'No Normal Player', 'Not anymore, at least.', '(score.mods & 1 == 0) and 2 <= score.sr < 3 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (59, 'mania-skill-pass-3', 'Impulse Drive', 'Not quite hyperspeed, but getting close.', '(score.mods & 1 == 0) and 3 <= score.sr < 4 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (60, 'mania-skill-pass-4', 'Hyperspeed', 'Woah.', '(score.mods & 1 == 0) and 4 <= score.sr < 5 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (61, 'mania-skill-pass-5', 'Ever Onwards', 'Another challenge is just around the corner.', '(score.mods & 1 == 0) and 5 <= score.sr < 6 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (62, 'mania-skill-pass-6', 'Another Surpassed', 'Is there no limit to your skills?', '(score.mods & 1 == 0) and 6 <= score.sr < 7 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (63, 'mania-skill-pass-7', 'Extra Credit', 'See me after class.', '(score.mods & 1 == 0) and 7 <= score.sr < 8 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (64, 'mania-skill-pass-8', 'Maniac', 'There''s just no stopping you.', '(score.mods & 1 == 0) and 8 <= score.sr < 9 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (65, 'mania-skill-fc-1', 'Keystruck', 'The beginning of a new story', 'score.perfect and 1 <= score.sr < 2 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (66, 'mania-skill-fc-2', 'Keying In', 'Finding your groove.', 'score.perfect and 2 <= score.sr < 3 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (67, 'mania-skill-fc-3', 'Hyperflow', 'You can *feel* the rhythm.', 'score.perfect and 3 <= score.sr < 4 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (68, 'mania-skill-fc-4', 'Breakthrough', 'Many skills mastered, rolled into one.', 'score.perfect and 4 <= score.sr < 5 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (69, 'mania-skill-fc-5', 'Everything Extra', 'Giving your all is giving everything you have.', 'score.perfect and 5 <= score.sr < 6 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (70, 'mania-skill-fc-6', 'Level Breaker', 'Finesse beyond reason', 'score.perfect and 6 <= score.sr < 7 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (71, 'mania-skill-fc-7', 'Step Up', 'A precipice rarely seen.', 'score.perfect and 7 <= score.sr < 8 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (72, 'mania-skill-fc-8', 'Behind The Veil', 'Supernatural!', 'score.perfect and 8 <= score.sr < 9 and mode_vn == 3'); -insert into achievements (id, file, name, `desc`, cond) values (73, 'all-intro-suddendeath', 'Finality', 'High stakes, no regrets.', 'score.mods == 32'); -insert into achievements (id, file, name, `desc`, cond) values (74, 'all-intro-hidden', 'Blindsight', 'I can see just perfectly', 'score.mods & 8'); -insert into achievements (id, file, name, `desc`, cond) values (75, 'all-intro-perfect', 'Perfectionist', 'Accept nothing but the best.', 'score.mods & 16384'); -insert into achievements (id, file, name, `desc`, cond) values (76, 'all-intro-hardrock', 'Rock Around The Clock', "You can\'t stop the rock.", 'score.mods & 16'); -insert into achievements (id, file, name, `desc`, cond) values (77, 'all-intro-doubletime', 'Time And A Half', "Having a right ol\' time. One and a half of them, almost.", 'score.mods & 64'); -insert into achievements (id, file, name, `desc`, cond) values (78, 'all-intro-flashlight', 'Are You Afraid Of The Dark?', "Harder than it looks, probably because it\'s hard to look.", 'score.mods & 1024'); -insert into achievements (id, file, name, `desc`, cond) values (79, 'all-intro-easy', 'Dial It Right Back', 'Sometimes you just want to take it easy.', 'score.mods & 2'); -insert into achievements (id, file, name, `desc`, cond) values (80, 'all-intro-nofail', 'Risk Averse', 'Safety nets are fun!', 'score.mods & 1'); -insert into achievements (id, file, name, `desc`, cond) values (81, 'all-intro-nightcore', 'Sweet Rave Party', 'Founded in the fine tradition of changing things that were just fine as they were.', 'score.mods & 512'); -insert into achievements (id, file, name, `desc`, cond) values (82, 'all-intro-halftime', 'Slowboat', 'You got there. Eventually.', 'score.mods & 256'); -insert into achievements (id, file, name, `desc`, cond) values (83, 'all-intro-spunout', 'Burned Out', 'One cannot always spin to win.', 'score.mods & 4096'); diff --git a/migrations_old/custom_beatmaps.sql b/migrations_old/custom_beatmaps.sql deleted file mode 100644 index b7cd122..0000000 --- a/migrations_old/custom_beatmaps.sql +++ /dev/null @@ -1,209 +0,0 @@ --- 自定义谱面系统迁移 --- 创建自定义谱面表,与官方谱面不冲突 - --- 自定义谱面集表 -CREATE TABLE custom_mapsets ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - creator_id INT NOT NULL, - title VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - artist VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - source VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '', - tags TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, - description TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, - status ENUM('pending', 'approved', 'rejected', 'loved') DEFAULT 'pending', - upload_date DATETIME DEFAULT CURRENT_TIMESTAMP, - last_update DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - osz_filename VARCHAR(255) NOT NULL, - osz_hash CHAR(32) NOT NULL, - download_count INT DEFAULT 0, - favourite_count INT DEFAULT 0, - UNIQUE KEY idx_custom_mapsets_id (id), - KEY idx_custom_mapsets_creator (creator_id), - KEY idx_custom_mapsets_status (status), - KEY idx_custom_mapsets_upload_date (upload_date), - UNIQUE KEY idx_custom_mapsets_osz_hash (osz_hash) -); - --- 自定义谱面难度表 -CREATE TABLE custom_maps ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - mapset_id BIGINT NOT NULL, - md5 CHAR(32) NOT NULL, - difficulty_name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - filename VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - mode TINYINT DEFAULT 0 NOT NULL COMMENT '0=osu!, 1=taiko, 2=catch, 3=mania', - status ENUM('pending', 'approved', 'rejected', 'loved') DEFAULT 'pending', - - -- osu!文件基本信息 - audio_filename VARCHAR(255) DEFAULT '', - audio_lead_in INT DEFAULT 0, - preview_time INT DEFAULT -1, - countdown TINYINT DEFAULT 1, - sample_set VARCHAR(16) DEFAULT 'Normal', - stack_leniency DECIMAL(3,2) DEFAULT 0.70, - letterbox_in_breaks BOOLEAN DEFAULT FALSE, - story_fire_in_front BOOLEAN DEFAULT TRUE, - use_skin_sprites BOOLEAN DEFAULT FALSE, - always_show_playfield BOOLEAN DEFAULT FALSE, - overlay_position VARCHAR(16) DEFAULT 'NoChange', - skin_preference VARCHAR(255) DEFAULT '', - epilepsy_warning BOOLEAN DEFAULT FALSE, - countdown_offset INT DEFAULT 0, - special_style BOOLEAN DEFAULT FALSE, - widescreen_storyboard BOOLEAN DEFAULT FALSE, - samples_match_playback_rate BOOLEAN DEFAULT FALSE, - - -- 编辑器信息 - distance_spacing DECIMAL(6,3) DEFAULT 1.000, - beat_divisor TINYINT DEFAULT 4, - grid_size TINYINT DEFAULT 4, - timeline_zoom DECIMAL(6,3) DEFAULT 1.000, - - -- 谱面元数据 - title_unicode VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '', - artist_unicode VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '', - creator VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - version VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - source VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '', - tags TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, - beatmap_id BIGINT DEFAULT 0, - beatmapset_id BIGINT DEFAULT 0, - - -- 难度设定 - hp_drain_rate DECIMAL(3,1) DEFAULT 5.0, - circle_size DECIMAL(3,1) DEFAULT 5.0, - overall_difficulty DECIMAL(3,1) DEFAULT 5.0, - approach_rate DECIMAL(3,1) DEFAULT 5.0, - slider_multiplier DECIMAL(6,3) DEFAULT 1.400, - slider_tick_rate DECIMAL(3,1) DEFAULT 1.0, - - -- 计算得出的信息 - total_length INT DEFAULT 0 COMMENT '总长度(秒)', - hit_length INT DEFAULT 0 COMMENT '击打长度(秒)', - max_combo INT DEFAULT 0, - bpm DECIMAL(8,3) DEFAULT 0.000, - star_rating DECIMAL(6,3) DEFAULT 0.000, - aim_difficulty DECIMAL(6,3) DEFAULT 0.000, - speed_difficulty DECIMAL(6,3) DEFAULT 0.000, - - -- 统计信息 - plays INT DEFAULT 0, - passes INT DEFAULT 0, - - -- 时间戳 - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - UNIQUE KEY idx_custom_maps_id (id), - UNIQUE KEY idx_custom_maps_md5 (md5), - KEY idx_custom_maps_mapset (mapset_id), - KEY idx_custom_maps_mode (mode), - KEY idx_custom_maps_status (status), - KEY idx_custom_maps_creator (creator), - KEY idx_custom_maps_star_rating (star_rating), - KEY idx_custom_maps_plays (plays), - - FOREIGN KEY (mapset_id) REFERENCES custom_mapsets(id) ON DELETE CASCADE -); - --- 自定义谱面书签表 -CREATE TABLE custom_map_bookmarks ( - user_id INT NOT NULL, - mapset_id BIGINT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_id, mapset_id), - FOREIGN KEY (mapset_id) REFERENCES custom_mapsets(id) ON DELETE CASCADE -); - --- 自定义谱面评分表 -CREATE TABLE custom_map_ratings ( - user_id INT NOT NULL, - map_id BIGINT NOT NULL, - rating TINYINT NOT NULL CHECK (rating >= 1 AND rating <= 10), - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (user_id, map_id), - FOREIGN KEY (map_id) REFERENCES custom_maps(id) ON DELETE CASCADE -); - --- 自定义谱面成绩表 (继承原scores表结构) -CREATE TABLE custom_scores ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - map_id BIGINT NOT NULL, - map_md5 CHAR(32) NOT NULL, - user_id INT NOT NULL, - score INT NOT NULL, - pp FLOAT(7,3) NOT NULL, - acc FLOAT(6,3) NOT NULL, - max_combo INT NOT NULL, - mods INT NOT NULL, - n300 INT NOT NULL, - n100 INT NOT NULL, - n50 INT NOT NULL, - nmiss INT NOT NULL, - ngeki INT NOT NULL, - nkatu INT NOT NULL, - grade VARCHAR(2) DEFAULT 'N' NOT NULL, - status TINYINT NOT NULL COMMENT '0=failed, 1=submitted, 2=best', - mode TINYINT NOT NULL, - play_time DATETIME NOT NULL, - time_elapsed INT NOT NULL, - client_flags INT NOT NULL, - perfect BOOLEAN NOT NULL, - online_checksum CHAR(32) NOT NULL, - - KEY idx_custom_scores_map_id (map_id), - KEY idx_custom_scores_map_md5 (map_md5), - KEY idx_custom_scores_user_id (user_id), - KEY idx_custom_scores_score (score), - KEY idx_custom_scores_pp (pp), - KEY idx_custom_scores_mods (mods), - KEY idx_custom_scores_status (status), - KEY idx_custom_scores_mode (mode), - KEY idx_custom_scores_play_time (play_time), - KEY idx_custom_scores_online_checksum (online_checksum), - KEY idx_custom_scores_leaderboard (map_md5, status, mode), - - FOREIGN KEY (map_id) REFERENCES custom_maps(id) ON DELETE CASCADE -); - --- 自定义谱面文件存储表 (用于存储.osu文件内容等) -CREATE TABLE custom_map_files ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - map_id BIGINT NOT NULL, - file_type ENUM('osu', 'audio', 'image', 'video', 'storyboard') NOT NULL, - filename VARCHAR(255) NOT NULL, - file_hash CHAR(32) NOT NULL, - file_size INT NOT NULL, - mime_type VARCHAR(100) DEFAULT '', - storage_path VARCHAR(500) NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - - UNIQUE KEY idx_custom_map_files_id (id), - KEY idx_custom_map_files_map_id (map_id), - KEY idx_custom_map_files_type (file_type), - KEY idx_custom_map_files_hash (file_hash), - - FOREIGN KEY (map_id) REFERENCES custom_maps(id) ON DELETE CASCADE -); - --- 为自定义谱面创建专门的ID生成器,避免与官方ID冲突 --- 自定义谱面ID从1000000开始 -ALTER TABLE custom_mapsets AUTO_INCREMENT = 3000000; -ALTER TABLE custom_maps AUTO_INCREMENT = 3000000; - --- 创建触发器来同步mapset信息到maps表 -DELIMITER $$ - -CREATE TRIGGER update_custom_mapset_on_map_change -AFTER UPDATE ON custom_maps -FOR EACH ROW -BEGIN - IF NEW.status != OLD.status THEN - UPDATE custom_mapsets - SET last_update = CURRENT_TIMESTAMP - WHERE id = NEW.mapset_id; - END IF; -END$$ - -DELIMITER ; diff --git a/migrations_old/migrations.sql b/migrations_old/migrations.sql deleted file mode 100644 index 8cb522d..0000000 --- a/migrations_old/migrations.sql +++ /dev/null @@ -1,477 +0,0 @@ -# This file contains any sql updates, along with the -# version they are required from. Touching this without -# at least reading utils/updater.py is certainly a bad idea :) - -# v3.0.6 -alter table users change name_safe safe_name varchar(32) not null; -alter table users drop key users_name_safe_uindex; -alter table users add constraint users_safe_name_uindex unique (safe_name); -alter table users change pw_hash pw_bcrypt char(60) not null; -insert into channels (name, topic, read_priv, write_priv, auto_join) values - ('#supporter', 'General discussion for p2w gamers.', 48, 48, false), - ('#staff', 'General discussion for the cool kids.', 28672, 28672, true), - ('#admin', 'General discussion for the cool.', 24576, 24576, true), - ('#dev', 'General discussion for the.', 16384, 16384, true); - -# v3.0.8 -alter table users modify safe_name varchar(32) charset utf8 not null; -alter table users modify name varchar(32) charset utf8 not null; -alter table mail modify msg varchar(2048) charset utf8 not null; -alter table logs modify msg varchar(2048) charset utf8 not null; -drop table if exists comments; -create table comments -( - id int auto_increment - primary key, - target_id int not null comment 'replay, map, or set id', - target_type enum('replay', 'map', 'song') not null, - userid int not null, - time int not null, - comment varchar(80) charset utf8 not null, - colour char(6) null comment 'rgb hex string' -); - -# v3.0.9 -alter table stats modify tscore_vn_std int unsigned default 0 not null; -alter table stats modify tscore_vn_taiko int unsigned default 0 not null; -alter table stats modify tscore_vn_catch int unsigned default 0 not null; -alter table stats modify tscore_vn_mania int unsigned default 0 not null; -alter table stats modify tscore_rx_std int unsigned default 0 not null; -alter table stats modify tscore_rx_taiko int unsigned default 0 not null; -alter table stats modify tscore_rx_catch int unsigned default 0 not null; -alter table stats modify tscore_ap_std int unsigned default 0 not null; -alter table stats modify rscore_vn_std int unsigned default 0 not null; -alter table stats modify rscore_vn_taiko int unsigned default 0 not null; -alter table stats modify rscore_vn_catch int unsigned default 0 not null; -alter table stats modify rscore_vn_mania int unsigned default 0 not null; -alter table stats modify rscore_rx_std int unsigned default 0 not null; -alter table stats modify rscore_rx_taiko int unsigned default 0 not null; -alter table stats modify rscore_rx_catch int unsigned default 0 not null; -alter table stats modify rscore_ap_std int unsigned default 0 not null; -alter table stats modify pp_vn_std smallint unsigned default 0 not null; -alter table stats modify pp_vn_taiko smallint unsigned default 0 not null; -alter table stats modify pp_vn_catch smallint unsigned default 0 not null; -alter table stats modify pp_vn_mania smallint unsigned default 0 not null; -alter table stats modify pp_rx_std smallint unsigned default 0 not null; -alter table stats modify pp_rx_taiko smallint unsigned default 0 not null; -alter table stats modify pp_rx_catch smallint unsigned default 0 not null; -alter table stats modify pp_ap_std smallint unsigned default 0 not null; -alter table stats modify plays_vn_std int unsigned default 0 not null; -alter table stats modify plays_vn_taiko int unsigned default 0 not null; -alter table stats modify plays_vn_catch int unsigned default 0 not null; -alter table stats modify plays_vn_mania int unsigned default 0 not null; -alter table stats modify plays_rx_std int unsigned default 0 not null; -alter table stats modify plays_rx_taiko int unsigned default 0 not null; -alter table stats modify plays_rx_catch int unsigned default 0 not null; -alter table stats modify plays_ap_std int unsigned default 0 not null; -alter table stats modify playtime_vn_std int unsigned default 0 not null; -alter table stats modify playtime_vn_taiko int unsigned default 0 not null; -alter table stats modify playtime_vn_catch int unsigned default 0 not null; -alter table stats modify playtime_vn_mania int unsigned default 0 not null; -alter table stats modify playtime_rx_std int unsigned default 0 not null; -alter table stats modify playtime_rx_taiko int unsigned default 0 not null; -alter table stats modify playtime_rx_catch int unsigned default 0 not null; -alter table stats modify playtime_ap_std int unsigned default 0 not null; -alter table stats modify maxcombo_vn_std int unsigned default 0 not null; -alter table stats modify maxcombo_vn_taiko int unsigned default 0 not null; -alter table stats modify maxcombo_vn_catch int unsigned default 0 not null; -alter table stats modify maxcombo_vn_mania int unsigned default 0 not null; -alter table stats modify maxcombo_rx_std int unsigned default 0 not null; -alter table stats modify maxcombo_rx_taiko int unsigned default 0 not null; -alter table stats modify maxcombo_rx_catch int unsigned default 0 not null; -alter table stats modify maxcombo_ap_std int unsigned default 0 not null; - -# v3.0.10 -update channels set write_priv = 24576 where name = '#announce'; - -# v3.1.0 -alter table maps modify bpm float(12,2) default 0.00 not null; -alter table stats modify tscore_vn_std bigint unsigned default 0 not null; -alter table stats modify tscore_vn_taiko bigint unsigned default 0 not null; -alter table stats modify tscore_vn_catch bigint unsigned default 0 not null; -alter table stats modify tscore_vn_mania bigint unsigned default 0 not null; -alter table stats modify tscore_rx_std bigint unsigned default 0 not null; -alter table stats modify tscore_rx_taiko bigint unsigned default 0 not null; -alter table stats modify tscore_rx_catch bigint unsigned default 0 not null; -alter table stats modify tscore_ap_std bigint unsigned default 0 not null; -alter table stats modify rscore_vn_std bigint unsigned default 0 not null; -alter table stats modify rscore_vn_taiko bigint unsigned default 0 not null; -alter table stats modify rscore_vn_catch bigint unsigned default 0 not null; -alter table stats modify rscore_vn_mania bigint unsigned default 0 not null; -alter table stats modify rscore_rx_std bigint unsigned default 0 not null; -alter table stats modify rscore_rx_taiko bigint unsigned default 0 not null; -alter table stats modify rscore_rx_catch bigint unsigned default 0 not null; -alter table stats modify rscore_ap_std bigint unsigned default 0 not null; -alter table stats modify pp_vn_std int unsigned default 0 not null; -alter table stats modify pp_vn_taiko int unsigned default 0 not null; -alter table stats modify pp_vn_catch int unsigned default 0 not null; -alter table stats modify pp_vn_mania int unsigned default 0 not null; -alter table stats modify pp_rx_std int unsigned default 0 not null; -alter table stats modify pp_rx_taiko int unsigned default 0 not null; -alter table stats modify pp_rx_catch int unsigned default 0 not null; -alter table stats modify pp_ap_std int unsigned default 0 not null; - -# v3.1.2 -create table clans -( - id int auto_increment - primary key, - name varchar(16) not null, - tag varchar(6) not null, - owner int not null, - created_at datetime not null, - constraint clans_name_uindex - unique (name), - constraint clans_owner_uindex - unique (owner), - constraint clans_tag_uindex - unique (tag) -); -alter table users add clan_id int default 0 not null; -alter table users add clan_rank tinyint(1) default 0 not null; -create table achievements -( - id int auto_increment - primary key, - file varchar(128) not null, - name varchar(128) not null, - `desc` varchar(256) not null, - cond varchar(64) not null, - mode tinyint(1) not null, - constraint achievements_desc_uindex - unique (`desc`), - constraint achievements_file_uindex - unique (file), - constraint achievements_name_uindex - unique (name) -); -create table user_achievements -( - userid int not null, - achid int not null, - primary key (userid, achid) -); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (1, 'osu-skill-pass-1', 'Rising Star', 'Can''t go forward without the first steps.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (2, 'osu-skill-pass-2', 'Constellation Prize', 'Definitely not a consolation prize. Now things start getting hard!', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (3, 'osu-skill-pass-3', 'Building Confidence', 'Oh, you''ve SO got this.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (4, 'osu-skill-pass-4', 'Insanity Approaches', 'You''re not twitching, you''re just ready.', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (5, 'osu-skill-pass-5', 'These Clarion Skies', 'Everything seems so clear now.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (6, 'osu-skill-pass-6', 'Above and Beyond', 'A cut above the rest.', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (7, 'osu-skill-pass-7', 'Supremacy', 'All marvel before your prowess.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (8, 'osu-skill-pass-8', 'Absolution', 'My god, you''re full of stars!', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (9, 'osu-skill-pass-9', 'Event Horizon', 'No force dares to pull you under.', '(score.mods & 259 == 0) and 10 >= score.sr > 9', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (10, 'osu-skill-pass-10', 'Phantasm', 'Fevered is your passion, extraordinary is your skill.', '(score.mods & 259 == 0) and 11 >= score.sr > 10', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (11, 'osu-skill-fc-1', 'Totality', 'All the notes. Every single one.', 'score.perfect and 2 >= score.sr > 1', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (12, 'osu-skill-fc-2', 'Business As Usual', 'Two to go, please.', 'score.perfect and 3 >= score.sr > 2', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (13, 'osu-skill-fc-3', 'Building Steam', 'Hey, this isn''t so bad.', 'score.perfect and 4 >= score.sr > 3', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (14, 'osu-skill-fc-4', 'Moving Forward', 'Bet you feel good about that.', 'score.perfect and 5 >= score.sr > 4', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (15, 'osu-skill-fc-5', 'Paradigm Shift', 'Surprisingly difficult.', 'score.perfect and 6 >= score.sr > 5', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (16, 'osu-skill-fc-6', 'Anguish Quelled', 'Don''t choke.', 'score.perfect and 7 >= score.sr > 6', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (17, 'osu-skill-fc-7', 'Never Give Up', 'Excellence is its own reward.', 'score.perfect and 8 >= score.sr > 7', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (18, 'osu-skill-fc-8', 'Aberration', 'They said it couldn''t be done. They were wrong.', 'score.perfect and 9 >= score.sr > 8', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (19, 'osu-skill-fc-9', 'Chosen', 'Reign among the Prometheans, where you belong.', 'score.perfect and 10 >= score.sr > 9', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (20, 'osu-skill-fc-10', 'Unfathomable', 'You have no equal.', 'score.perfect and 11 >= score.sr > 10', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (21, 'osu-combo-500', '500 Combo', '500 big ones! You''re moving up in the world!', '750 >= score.max_combo > 500', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (22, 'osu-combo-750', '750 Combo', '750 notes back to back? Woah.', '1000 >= score.max_combo > 750', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (23, 'osu-combo-1000', '1000 Combo', 'A thousand reasons why you rock at this game.', '2000 >= score.max_combo > 1000', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (24, 'osu-combo-2000', '2000 Combo', 'Nothing can stop you now.', 'score.max_combo >= 2000', 0); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (25, 'taiko-skill-pass-1', 'My First Don', 'Marching to the beat of your own drum. Literally.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (26, 'taiko-skill-pass-2', 'Katsu Katsu Katsu', 'Hora! Izuko!', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (27, 'taiko-skill-pass-3', 'Not Even Trying', 'Muzukashii? Not even.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (28, 'taiko-skill-pass-4', 'Face Your Demons', 'The first trials are now behind you, but are you a match for the Oni?', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (29, 'taiko-skill-pass-5', 'The Demon Within', 'No rest for the wicked.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (30, 'taiko-skill-pass-6', 'Drumbreaker', 'Too strong.', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (31, 'taiko-skill-pass-7', 'The Godfather', 'You are the Don of Dons.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (32, 'taiko-skill-pass-8', 'Rhythm Incarnate', 'Feel the beat. Become the beat.', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (33, 'taiko-skill-fc-1', 'Keeping Time', 'Don, then katsu. Don, then katsu..', 'score.perfect and 2 >= score.sr > 1', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (34, 'taiko-skill-fc-2', 'To Your Own Beat', 'Straight and steady.', 'score.perfect and 3 >= score.sr > 2', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (35, 'taiko-skill-fc-3', 'Big Drums', 'Bigger scores to match.', 'score.perfect and 4 >= score.sr > 3', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (36, 'taiko-skill-fc-4', 'Adversity Overcome', 'Difficult? Not for you.', 'score.perfect and 5 >= score.sr > 4', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (37, 'taiko-skill-fc-5', 'Demonslayer', 'An Oni felled forevermore.', 'score.perfect and 6 >= score.sr > 5', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (38, 'taiko-skill-fc-6', 'Rhythm''s Call', 'Heralding true skill.', 'score.perfect and 7 >= score.sr > 6', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (39, 'taiko-skill-fc-7', 'Time Everlasting', 'Not a single beat escapes you.', 'score.perfect and 8 >= score.sr > 7', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (40, 'taiko-skill-fc-8', 'The Drummer''s Throne', 'Percussive brilliance befitting royalty alone.', 'score.perfect and 9 >= score.sr > 8', 1); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (41, 'fruits-skill-pass-1', 'A Slice Of Life', 'Hey, this fruit catching business isn''t bad.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (42, 'fruits-skill-pass-2', 'Dashing Ever Forward', 'Fast is how you do it.', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (43, 'fruits-skill-pass-3', 'Zesty Disposition', 'No scurvy for you, not with that much fruit.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (44, 'fruits-skill-pass-4', 'Hyperdash ON!', 'Time and distance is no obstacle to you.', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (45, 'fruits-skill-pass-5', 'It''s Raining Fruit', 'And you can catch them all.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (46, 'fruits-skill-pass-6', 'Fruit Ninja', 'Legendary techniques.', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (47, 'fruits-skill-pass-7', 'Dreamcatcher', 'No fruit, only dreams now.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (48, 'fruits-skill-pass-8', 'Lord of the Catch', 'Your kingdom kneels before you.', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (49, 'fruits-skill-fc-1', 'Sweet And Sour', 'Apples and oranges, literally.', 'score.perfect and 2 >= score.sr > 1', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (50, 'fruits-skill-fc-2', 'Reaching The Core', 'The seeds of future success.', 'score.perfect and 3 >= score.sr > 2', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (51, 'fruits-skill-fc-3', 'Clean Platter', 'Clean only of failure. It is completely full, otherwise.', 'score.perfect and 4 >= score.sr > 3', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (52, 'fruits-skill-fc-4', 'Between The Rain', 'No umbrella needed.', 'score.perfect and 5 >= score.sr > 4', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (53, 'fruits-skill-fc-5', 'Addicted', 'That was an overdose?', 'score.perfect and 6 >= score.sr > 5', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (54, 'fruits-skill-fc-6', 'Quickening', 'A dash above normal limits.', 'score.perfect and 7 >= score.sr > 6', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (55, 'fruits-skill-fc-7', 'Supersonic', 'Faster than is reasonably necessary.', 'score.perfect and 8 >= score.sr > 7', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (56, 'fruits-skill-fc-8', 'Dashing Scarlet', 'Speed beyond mortal reckoning.', 'score.perfect and 9 >= score.sr > 8', 2); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (57, 'mania-skill-pass-1', 'First Steps', 'It isn''t 9-to-5, but 1-to-9. Keys, that is.', '(score.mods & 259 == 0) and 2 >= score.sr > 1', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (58, 'mania-skill-pass-2', 'No Normal Player', 'Not anymore, at least.', '(score.mods & 259 == 0) and 3 >= score.sr > 2', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (59, 'mania-skill-pass-3', 'Impulse Drive', 'Not quite hyperspeed, but getting close.', '(score.mods & 259 == 0) and 4 >= score.sr > 3', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (60, 'mania-skill-pass-4', 'Hyperspeed', 'Woah.', '(score.mods & 259 == 0) and 5 >= score.sr > 4', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (61, 'mania-skill-pass-5', 'Ever Onwards', 'Another challenge is just around the corner.', '(score.mods & 259 == 0) and 6 >= score.sr > 5', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (62, 'mania-skill-pass-6', 'Another Surpassed', 'Is there no limit to your skills?', '(score.mods & 259 == 0) and 7 >= score.sr > 6', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (63, 'mania-skill-pass-7', 'Extra Credit', 'See me after class.', '(score.mods & 259 == 0) and 8 >= score.sr > 7', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (64, 'mania-skill-pass-8', 'Maniac', 'There''s just no stopping you.', '(score.mods & 259 == 0) and 9 >= score.sr > 8', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (65, 'mania-skill-fc-1', 'Keystruck', 'The beginning of a new story', 'score.perfect and (score.mods & 259 == 0) and 2 >= score.sr > 1', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (66, 'mania-skill-fc-2', 'Keying In', 'Finding your groove.', 'score.perfect and 3 >= score.sr > 2', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (67, 'mania-skill-fc-3', 'Hyperflow', 'You can *feel* the rhythm.', 'score.perfect and 4 >= score.sr > 3', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (68, 'mania-skill-fc-4', 'Breakthrough', 'Many skills mastered, rolled into one.', 'score.perfect and 5 >= score.sr > 4', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (69, 'mania-skill-fc-5', 'Everything Extra', 'Giving your all is giving everything you have.', 'score.perfect and 6 >= score.sr > 5', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (70, 'mania-skill-fc-6', 'Level Breaker', 'Finesse beyond reason', 'score.perfect and 7 >= score.sr > 6', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (71, 'mania-skill-fc-7', 'Step Up', 'A precipice rarely seen.', 'score.perfect and 8 >= score.sr > 7', 3); -insert into achievements (`id`, `file`, `name`, `desc`, `cond`, `mode`) values (72, 'mania-skill-fc-8', 'Behind The Veil', 'Supernatural!', 'score.perfect and 9 >= score.sr > 8', 3); - -# v3.1.3 -alter table clans modify name varchar(16) charset utf8 not null; -alter table clans modify tag varchar(6) charset utf8 not null; -alter table achievements modify name varchar(128) charset utf8 not null; -alter table achievements modify `desc` varchar(256) charset utf8 not null; -alter table maps modify artist varchar(128) charset utf8 not null; -alter table maps modify title varchar(128) charset utf8 not null; -alter table maps modify version varchar(128) charset utf8 not null; -alter table maps modify creator varchar(19) charset utf8 not null comment 'not 100%% certain on len'; -alter table tourney_pools drop foreign key tourney_pools_users_id_fk; -alter table tourney_pool_maps drop foreign key tourney_pool_maps_tourney_pools_id_fk; -alter table stats drop foreign key stats_users_id_fk; -alter table ratings drop foreign key ratings_maps_md5_fk; -alter table ratings drop foreign key ratings_users_id_fk; -alter table logs modify `from` int not null comment 'both from and to are playerids'; - -# v3.1.9 -alter table scores_rx modify id bigint(20) unsigned auto_increment; -update scores_rx set id = id + (6148914691236517205 - 1); -select @max_rx := MAX(id) + 1 from scores_rx; -set @s = CONCAT('alter table scores_rx auto_increment = ', @max_rx); -prepare stmt from @s; -execute stmt; -deallocate PREPARE stmt; -alter table scores_ap modify id bigint(20) unsigned auto_increment; -update scores_ap set id = id + (12297829382473034410 - 1); -select @max_ap := MAX(id) + 1 from scores_ap; -set @s = CONCAT('alter table scores_ap auto_increment = ', @max_ap); -prepare stmt from @s; -execute stmt; -deallocate PREPARE stmt; -alter table performance_reports modify scoreid bigint(20) unsigned auto_increment; - -# v3.2.0 -create table map_requests -( - id int auto_increment - primary key, - map_id int not null, - player_id int not null, - datetime datetime not null, - active tinyint(1) not null -); - -# v3.2.1 -update scores_rx set id = id - 3074457345618258603; -update scores_ap set id = id - 6148914691236517206; - -# v3.2.2 -alter table maps add max_combo int not null after total_length; -alter table users change clan_rank clan_priv tinyint(1) default 0 not null; - -# v3.2.3 -alter table users add api_key char(36) default NULL null; -create unique index users_api_key_uindex on users (api_key); - -# v3.2.4 -update achievements set file = replace(file, 'ctb', 'fruits') where mode = 2; - -# v3.2.5 -update achievements set cond = '(score.mods & 1 == 0) and 1 <= score.sr < 2' where file in ('osu-skill-pass-1', 'taiko-skill-pass-1', 'fruits-skill-pass-1', 'mania-skill-pass-1'); -update achievements set cond = '(score.mods & 1 == 0) and 2 <= score.sr < 3' where file in ('osu-skill-pass-2', 'taiko-skill-pass-2', 'fruits-skill-pass-2', 'mania-skill-pass-2'); -update achievements set cond = '(score.mods & 1 == 0) and 3 <= score.sr < 4' where file in ('osu-skill-pass-3', 'taiko-skill-pass-3', 'fruits-skill-pass-3', 'mania-skill-pass-3'); -update achievements set cond = '(score.mods & 1 == 0) and 4 <= score.sr < 5' where file in ('osu-skill-pass-4', 'taiko-skill-pass-4', 'fruits-skill-pass-4', 'mania-skill-pass-4'); -update achievements set cond = '(score.mods & 1 == 0) and 5 <= score.sr < 6' where file in ('osu-skill-pass-5', 'taiko-skill-pass-5', 'fruits-skill-pass-5', 'mania-skill-pass-5'); -update achievements set cond = '(score.mods & 1 == 0) and 6 <= score.sr < 7' where file in ('osu-skill-pass-6', 'taiko-skill-pass-6', 'fruits-skill-pass-6', 'mania-skill-pass-6'); -update achievements set cond = '(score.mods & 1 == 0) and 7 <= score.sr < 8' where file in ('osu-skill-pass-7', 'taiko-skill-pass-7', 'fruits-skill-pass-7', 'mania-skill-pass-7'); -update achievements set cond = '(score.mods & 1 == 0) and 8 <= score.sr < 9' where file in ('osu-skill-pass-8', 'taiko-skill-pass-8', 'fruits-skill-pass-8', 'mania-skill-pass-8'); -update achievements set cond = '(score.mods & 1 == 0) and 9 <= score.sr < 10' where file = 'osu-skill-pass-9'; -update achievements set cond = '(score.mods & 1 == 0) and 10 <= score.sr < 11' where file = 'osu-skill-pass-10'; - -update achievements set cond = 'score.perfect and 1 <= score.sr < 2' where file in ('osu-skill-fc-1', 'taiko-skill-fc-1', 'fruits-skill-fc-1', 'mania-skill-fc-1'); -update achievements set cond = 'score.perfect and 2 <= score.sr < 3' where file in ('osu-skill-fc-2', 'taiko-skill-fc-2', 'fruits-skill-fc-2', 'mania-skill-fc-2'); -update achievements set cond = 'score.perfect and 3 <= score.sr < 4' where file in ('osu-skill-fc-3', 'taiko-skill-fc-3', 'fruits-skill-fc-3', 'mania-skill-fc-3'); -update achievements set cond = 'score.perfect and 4 <= score.sr < 5' where file in ('osu-skill-fc-4', 'taiko-skill-fc-4', 'fruits-skill-fc-4', 'mania-skill-fc-4'); -update achievements set cond = 'score.perfect and 5 <= score.sr < 6' where file in ('osu-skill-fc-5', 'taiko-skill-fc-5', 'fruits-skill-fc-5', 'mania-skill-fc-5'); -update achievements set cond = 'score.perfect and 6 <= score.sr < 7' where file in ('osu-skill-fc-6', 'taiko-skill-fc-6', 'fruits-skill-fc-6', 'mania-skill-fc-6'); -update achievements set cond = 'score.perfect and 7 <= score.sr < 8' where file in ('osu-skill-fc-7', 'taiko-skill-fc-7', 'fruits-skill-fc-7', 'mania-skill-fc-7'); -update achievements set cond = 'score.perfect and 8 <= score.sr < 9' where file in ('osu-skill-fc-8', 'taiko-skill-fc-8', 'fruits-skill-fc-8', 'mania-skill-fc-8'); -update achievements set cond = 'score.perfect and 9 <= score.sr < 10' where file = 'osu-skill-fc-9'; -update achievements set cond = 'score.perfect and 10 <= score.sr < 11' where file = 'osu-skill-fc-10'; - -update achievements set cond = '500 <= score.max_combo < 750' where file = 'osu-combo-500'; -update achievements set cond = '750 <= score.max_combo < 1000' where file = 'osu-combo-750'; -update achievements set cond = '1000 <= score.max_combo < 2000' where file = 'osu-combo-1000'; -update achievements set cond = '2000 <= score.max_combo' where file = 'osu-combo-2000'; - -# v3.2.6 -alter table stats change maxcombo_vn_std max_combo_vn_std int unsigned default 0 not null; -alter table stats change maxcombo_vn_taiko max_combo_vn_taiko int unsigned default 0 not null; -alter table stats change maxcombo_vn_catch max_combo_vn_catch int unsigned default 0 not null; -alter table stats change maxcombo_vn_mania max_combo_vn_mania int unsigned default 0 not null; -alter table stats change maxcombo_rx_std max_combo_rx_std int unsigned default 0 not null; -alter table stats change maxcombo_rx_taiko max_combo_rx_taiko int unsigned default 0 not null; -alter table stats change maxcombo_rx_catch max_combo_rx_catch int unsigned default 0 not null; -alter table stats change maxcombo_ap_std max_combo_ap_std int unsigned default 0 not null; - -# v3.2.7 -drop table if exists user_hashes; - -# v3.3.0 -rename table friendships to relationships; -alter table relationships add type enum('friend', 'block') not null; - -# v3.3.1 -create table ingame_logins -( - id int auto_increment - primary key, - userid int not null, - ip varchar(45) not null comment 'maxlen for ipv6', - osu_ver date not null, - osu_stream varchar(11) not null, - datetime datetime not null -); - -# v3.3.7 -update achievements set cond = CONCAT(cond, ' and mode_vn == 0') where mode = 0; -update achievements set cond = CONCAT(cond, ' and mode_vn == 1') where mode = 1; -update achievements set cond = CONCAT(cond, ' and mode_vn == 2') where mode = 2; -update achievements set cond = CONCAT(cond, ' and mode_vn == 3') where mode = 3; -alter table achievements drop column mode; - -# v3.3.8 -create table mapsets -( - server enum('osu!', 'gulag') default 'osu!' not null, - id int not null, - last_osuapi_check datetime default CURRENT_TIMESTAMP not null, - primary key (server, id), - constraint nmapsets_id_uindex - unique (id) -); - -# v3.4.1 -alter table maps add filename varchar(256) charset utf8 not null after creator; - -# v3.5.2 -alter table scores_vn add online_checksum char(32) not null; -alter table scores_rx add online_checksum char(32) not null; -alter table scores_ap add online_checksum char(32) not null; - -# v4.1.1 -alter table stats add total_hits int unsigned default 0 not null after max_combo; - -# v4.1.2 -alter table stats add replay_views int unsigned default 0 not null after total_hits; - -# v4.1.3 -alter table users add preferred_mode int default 0 not null after latest_activity; -alter table users add play_style int default 0 not null after preferred_mode; -alter table users add custom_badge_name varchar(16) charset utf8 null after play_style; -alter table users add custom_badge_icon varchar(64) null after custom_badge_name; -alter table users add userpage_content varchar(2048) charset utf8 null after custom_badge_icon; - -# v4.2.0 -# please refer to tools/migrate_v420 for further v4.2.0 migrations -update stats set mode = 8 where mode = 7; - -# v4.3.1 -alter table maps change server server enum('osu!', 'private') default 'osu!' not null; -alter table mapsets change server server enum('osu!', 'private') default 'osu!' not null; - -# v4.4.2 -insert into achievements (id, file, name, `desc`, cond) values (73, 'all-intro-suddendeath', 'Finality', 'High stakes, no regrets.', 'score.mods == 32'); -insert into achievements (id, file, name, `desc`, cond) values (74, 'all-intro-hidden', 'Blindsight', 'I can see just perfectly', 'score.mods & 8'); -insert into achievements (id, file, name, `desc`, cond) values (75, 'all-intro-perfect', 'Perfectionist', 'Accept nothing but the best.', 'score.mods & 16384'); -insert into achievements (id, file, name, `desc`, cond) values (76, 'all-intro-hardrock', 'Rock Around The Clock', "You can\'t stop the rock.", 'score.mods & 16'); -insert into achievements (id, file, name, `desc`, cond) values (77, 'all-intro-doubletime', 'Time And A Half', "Having a right ol\' time. One and a half of them, almost.", 'score.mods & 64'); -insert into achievements (id, file, name, `desc`, cond) values (78, 'all-intro-flashlight', 'Are You Afraid Of The Dark?', "Harder than it looks, probably because it\'s hard to look.", 'score.mods & 1024'); -insert into achievements (id, file, name, `desc`, cond) values (79, 'all-intro-easy', 'Dial It Right Back', 'Sometimes you just want to take it easy.', 'score.mods & 2'); -insert into achievements (id, file, name, `desc`, cond) values (80, 'all-intro-nofail', 'Risk Averse', 'Safety nets are fun!', 'score.mods & 1'); -insert into achievements (id, file, name, `desc`, cond) values (81, 'all-intro-nightcore', 'Sweet Rave Party', 'Founded in the fine tradition of changing things that were just fine as they were.', 'score.mods & 512'); -insert into achievements (id, file, name, `desc`, cond) values (82, 'all-intro-halftime', 'Slowboat', 'You got there. Eventually.', 'score.mods & 256'); -insert into achievements (id, file, name, `desc`, cond) values (83, 'all-intro-spunout', 'Burned Out', 'One cannot always spin to win.', 'score.mods & 4096'); - -# v4.4.3 -alter table favourites add created_at int default 0 not null; - -# v4.7.1 -lock tables maps write; -alter table maps drop primary key; -alter table maps add primary key (id); -alter table maps modify column server enum('osu!', 'private') not null default 'osu!' after id; -unlock tables; - -# v5.0.1 -create index channels_auto_join_index - on channels (auto_join); - -create index maps_set_id_index - on maps (set_id); -create index maps_status_index - on maps (status); -create index maps_filename_index - on maps (filename); -create index maps_plays_index - on maps (plays); -create index maps_mode_index - on maps (mode); -create index maps_frozen_index - on maps (frozen); - -create index scores_map_md5_index - on scores (map_md5); -create index scores_score_index - on scores (score); -create index scores_pp_index - on scores (pp); -create index scores_mods_index - on scores (mods); -create index scores_status_index - on scores (status); -create index scores_mode_index - on scores (mode); -create index scores_play_time_index - on scores (play_time); -create index scores_userid_index - on scores (userid); -create index scores_online_checksum_index - on scores (online_checksum); - -create index stats_mode_index - on stats (mode); -create index stats_pp_index - on stats (pp); -create index stats_tscore_index - on stats (tscore); -create index stats_rscore_index - on stats (rscore); - -create index tourney_pool_maps_mods_slot_index - on tourney_pool_maps (mods, slot); - -create index user_achievements_achid_index - on user_achievements (achid); -create index user_achievements_userid_index - on user_achievements (userid); - -create index users_priv_index - on users (priv); -create index users_clan_id_index - on users (clan_id); -create index users_clan_priv_index - on users (clan_priv); -create index users_country_index - on users (country); - -# v5.2.2 -create index scores_fetch_leaderboard_generic_index - on scores (map_md5, status, mode); diff --git a/migrations_old/sync_legacy_data.sql b/migrations_old/sync_legacy_data.sql deleted file mode 100644 index fca085e..0000000 --- a/migrations_old/sync_legacy_data.sql +++ /dev/null @@ -1,337 +0,0 @@ --- Lazer API 数据同步脚本 --- 从现有的 bancho.py 表结构同步数据到新的 lazer 专用表 --- 执行此脚本前请确保已执行 add_missing_fields.sql - --- ============================================ --- 同步用户基本资料数据 --- ============================================ - --- 同步用户扩展资料 -INSERT INTO lazer_user_profiles ( - user_id, - is_active, - is_bot, - is_deleted, - is_online, - is_supporter, - is_restricted, - session_verified, - has_supported, - pm_friends_only, - default_group, - last_visit, - join_date, - profile_colour, - profile_hue, - avatar_url, - cover_url, - discord, - twitter, - website, - title, - title_url, - interests, - location, - occupation, - playmode, - support_level, - max_blocks, - max_friends, - post_count, - page_html, - page_raw -) -SELECT - u.id as user_id, - -- 基本状态字段 (使用默认值,因为原表没有这些字段) - 1 as is_active, - CASE WHEN u.name = 'BanchoBot' THEN 1 ELSE 0 END as is_bot, - 0 as is_deleted, - 1 as is_online, - CASE WHEN u.donor_end > UNIX_TIMESTAMP() THEN 1 ELSE 0 END as is_supporter, - CASE WHEN (u.priv & 1) = 0 THEN 1 ELSE 0 END as is_restricted, - 0 as session_verified, - CASE WHEN u.donor_end > 0 THEN 1 ELSE 0 END as has_supported, - 0 as pm_friends_only, - - -- 基本资料字段 - 'default' as default_group, - CASE WHEN u.latest_activity > 0 THEN FROM_UNIXTIME(u.latest_activity) ELSE NULL END as last_visit, - CASE WHEN u.creation_time > 0 THEN FROM_UNIXTIME(u.creation_time) ELSE NULL END as join_date, - NULL as profile_colour, - NULL as profile_hue, - - -- 社交媒体和个人资料字段 (使用默认值) - CONCAT('https://a.ppy.sh/', u.id) as avatar_url, - CONCAT('https://assets.ppy.sh/user-profile-covers/banners/', u.id, '.jpg') as cover_url, - NULL as discord, - NULL as twitter, - NULL as website, - u.custom_badge_name as title, - NULL as title_url, - NULL as interests, - CASE WHEN u.country != 'xx' THEN u.country ELSE NULL END as location, - NULL as occupation, - - -- 游戏相关字段 - CASE u.preferred_mode - WHEN 0 THEN 'osu' - WHEN 1 THEN 'taiko' - WHEN 2 THEN 'fruits' - WHEN 3 THEN 'mania' - ELSE 'osu' - END as playmode, - CASE WHEN u.donor_end > UNIX_TIMESTAMP() THEN 1 ELSE 0 END as support_level, - 100 as max_blocks, - 500 as max_friends, - 0 as post_count, - - -- 页面内容 - u.userpage_content as page_html, - u.userpage_content as page_raw - -FROM users u -ON DUPLICATE KEY UPDATE - last_visit = VALUES(last_visit), - join_date = VALUES(join_date), - is_supporter = VALUES(is_supporter), - is_restricted = VALUES(is_restricted), - has_supported = VALUES(has_supported), - title = VALUES(title), - location = VALUES(location), - playmode = VALUES(playmode), - support_level = VALUES(support_level), - page_html = VALUES(page_html), - page_raw = VALUES(page_raw); - --- 同步用户国家信息 -INSERT INTO lazer_user_countries ( - user_id, - code, - name -) -SELECT - u.id as user_id, - UPPER(u.country) as code, - CASE UPPER(u.country) - WHEN 'CN' THEN 'China' - WHEN 'US' THEN 'United States' - WHEN 'JP' THEN 'Japan' - WHEN 'KR' THEN 'South Korea' - WHEN 'CA' THEN 'Canada' - WHEN 'GB' THEN 'United Kingdom' - WHEN 'DE' THEN 'Germany' - WHEN 'FR' THEN 'France' - WHEN 'AU' THEN 'Australia' - WHEN 'RU' THEN 'Russia' - ELSE 'Unknown' - END as name -FROM users u -WHERE u.country IS NOT NULL AND u.country != 'xx' -ON DUPLICATE KEY UPDATE - code = VALUES(code), - name = VALUES(name); - --- 同步用户 Kudosu (使用默认值) -INSERT INTO lazer_user_kudosu ( - user_id, - available, - total -) -SELECT - u.id as user_id, - 0 as available, - 0 as total -FROM users u -ON DUPLICATE KEY UPDATE - available = VALUES(available), - total = VALUES(total); - --- 同步用户统计计数 (使用默认值) -INSERT INTO lazer_user_counts ( - user_id, - beatmap_playcounts_count, - comments_count, - favourite_beatmapset_count, - follower_count, - graveyard_beatmapset_count, - guest_beatmapset_count, - loved_beatmapset_count, - mapping_follower_count, - nominated_beatmapset_count, - pending_beatmapset_count, - ranked_beatmapset_count, - ranked_and_approved_beatmapset_count, - unranked_beatmapset_count, - scores_best_count, - scores_first_count, - scores_pinned_count, - scores_recent_count -) -SELECT - u.id as user_id, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 -FROM users u -ON DUPLICATE KEY UPDATE - user_id = VALUES(user_id); - --- ============================================ --- 同步游戏统计数据 --- ============================================ - --- 从 stats 表同步用户统计数据到 lazer_user_statistics -INSERT INTO lazer_user_statistics ( - user_id, - mode, - count_100, - count_300, - count_50, - count_miss, - level_current, - level_progress, - global_rank, - country_rank, - pp, - ranked_score, - hit_accuracy, - total_score, - total_hits, - maximum_combo, - play_count, - play_time, - replays_watched_by_others, - is_ranked, - grade_ss, - grade_ssh, - grade_s, - grade_sh, - grade_a -) -SELECT - s.id as user_id, - CASE s.mode - WHEN 0 THEN 'osu' - WHEN 1 THEN 'taiko' - WHEN 2 THEN 'fruits' - WHEN 3 THEN 'mania' - ELSE 'osu' - END as mode, - - -- 基本命中统计 - s.n100 as count_100, - s.n300 as count_300, - s.n50 as count_50, - s.nmiss as count_miss, - - -- 等级信息 - 1 as level_current, - 0 as level_progress, - - -- 排名信息 - NULL as global_rank, - NULL as country_rank, - - -- PP 和分数 - s.pp as pp, - s.rscore as ranked_score, - CASE WHEN (s.n300 + s.n100 + s.n50 + s.nmiss) > 0 - THEN ROUND((s.n300 * 300 + s.n100 * 100 + s.n50 * 50) / ((s.n300 + s.n100 + s.n50 + s.nmiss) * 300) * 100, 2) - ELSE 0.00 - END as hit_accuracy, - s.tscore as total_score, - (s.n300 + s.n100 + s.n50) as total_hits, - s.max_combo as maximum_combo, - - -- 游戏统计 - s.plays as play_count, - s.playtime as play_time, - 0 as replays_watched_by_others, - CASE WHEN s.pp > 0 THEN 1 ELSE 0 END as is_ranked, - - -- 成绩等级计数 - 0 as grade_ss, - 0 as grade_ssh, - 0 as grade_s, - 0 as grade_sh, - 0 as grade_a - -FROM stats s -WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = s.id) -ON DUPLICATE KEY UPDATE - count_100 = VALUES(count_100), - count_300 = VALUES(count_300), - count_50 = VALUES(count_50), - count_miss = VALUES(count_miss), - pp = VALUES(pp), - ranked_score = VALUES(ranked_score), - hit_accuracy = VALUES(hit_accuracy), - total_score = VALUES(total_score), - total_hits = VALUES(total_hits), - maximum_combo = VALUES(maximum_combo), - play_count = VALUES(play_count), - play_time = VALUES(play_time), - is_ranked = VALUES(is_ranked); - --- ============================================ --- 同步用户成就数据 --- ============================================ - --- 从 user_achievements 表同步数据(如果存在的话) -INSERT IGNORE INTO lazer_user_achievements ( - user_id, - achievement_id, - achieved_at -) -SELECT - ua.userid as user_id, - ua.achid as achievement_id, - NOW() as achieved_at -- 使用当前时间作为获得时间 -FROM user_achievements ua -WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = ua.userid); - --- ============================================ --- 创建基础 OAuth 令牌记录(如果需要的话) --- ============================================ - --- 注意: OAuth 令牌通常在用户登录时动态创建,这里不需要预先填充 - --- ============================================ --- 同步完成提示 --- ============================================ - --- 显示同步统计信息 -SELECT - 'lazer_user_profiles' as table_name, - COUNT(*) as synced_records -FROM lazer_user_profiles -UNION ALL -SELECT - 'lazer_user_countries' as table_name, - COUNT(*) as synced_records -FROM lazer_user_countries -UNION ALL -SELECT - 'lazer_user_statistics' as table_name, - COUNT(*) as synced_records -FROM lazer_user_statistics -UNION ALL -SELECT - 'lazer_user_achievements' as table_name, - COUNT(*) as synced_records -FROM lazer_user_achievements; - --- 显示一些样本数据 -SELECT - u.id, - u.name, - lup.is_supporter, - lup.playmode, - luc.code as country_code, - lus.pp, - lus.play_count -FROM users u -LEFT JOIN lazer_user_profiles lup ON u.id = lup.user_id -LEFT JOIN lazer_user_countries luc ON u.id = luc.user_id -LEFT JOIN lazer_user_statistics lus ON u.id = lus.user_id AND lus.mode = 'osu' -ORDER BY u.id -LIMIT 10; diff --git a/osu_api_example.py b/osu_api_example.py deleted file mode 100644 index 342d522..0000000 --- a/osu_api_example.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -import os - -import requests - -CLIENT_ID = os.environ.get("OSU_CLIENT_ID", "5") -CLIENT_SECRET = os.environ.get( - "OSU_CLIENT_SECRET", "FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" -) -API_URL = os.environ.get("OSU_API_URL", "https://osu.ppy.sh") - - -def authenticate(username: str, password: str): - """Authenticate via OAuth password flow and return the token dict.""" - url = f"{API_URL}/oauth/token" - data = { - "grant_type": "password", - "username": username, - "password": password, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "scope": "*", - } - response = requests.post(url, data=data) - response.raise_for_status() - return response.json() - - -def refresh_token(refresh: str): - """Refresh the OAuth token.""" - url = f"{API_URL}/oauth/token" - data = { - "grant_type": "refresh_token", - "refresh_token": refresh, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "scope": "*", - } - response = requests.post(url, data=data) - response.raise_for_status() - return response.json() - - -def get_current_user(access_token: str, ruleset: str = "osu"): - """Retrieve the authenticated user's data.""" - url = f"{API_URL}/api/v2/me/{ruleset}" - headers = {"Authorization": f"Bearer {access_token}"} - response = requests.get(url, headers=headers) - response.raise_for_status() - return response.json() - - -if __name__ == "__main__": - import getpass - - username = input("osu! username: ") - password = getpass.getpass() - - token = authenticate(username, password) - print("Access Token:", token["access_token"]) - user = get_current_user(token["access_token"]) - - print(user) diff --git a/quick_sync.py b/quick_sync.py deleted file mode 100644 index 9a0221e..0000000 --- a/quick_sync.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -""" -简化的数据同步执行脚本 -直接使用项目配置执行数据同步 -""" - -from __future__ import annotations - -import os -import subprocess -from urllib.parse import urlparse - -from app.config import settings - - -def parse_database_url(): - """解析数据库 URL""" - url = urlparse(settings.DATABASE_URL) - return { - "host": url.hostname or "localhost", - "port": url.port or 3306, - "user": url.username or "root", - "password": url.password or "", - "database": url.path.lstrip("/") if url.path else "osu_api", - } - - -def run_sql_script(script_path: str): - """使用 mysql 命令行执行 SQL 脚本""" - if not os.path.exists(script_path): - print(f"错误: SQL 脚本不存在 - {script_path}") - return False - - # 解析数据库配置 - db_config = parse_database_url() - - # 构建 mysql 命令 - cmd = [ - "mysql", - f"--host={db_config['host']}", - f"--port={db_config['port']}", - f"--user={db_config['user']}", - db_config["database"], - ] - - # 添加密码(如果有的话) - if db_config["password"]: - cmd.insert(-1, f"--password={db_config['password']}") - - try: - print(f"执行 SQL 脚本: {script_path}") - with open(script_path, encoding="utf-8") as f: - result = subprocess.run( - cmd, stdin=f, capture_output=True, text=True, check=True - ) - - if result.stdout: - print("执行结果:") - print(result.stdout) - - print(f"✓ 成功执行: {script_path}") - return True - - except subprocess.CalledProcessError as e: - print(f"✗ 执行失败: {script_path}") - print(f"错误信息: {e.stderr}") - return False - except FileNotFoundError: - print("错误: 未找到 mysql 命令行工具") - print("请确保 MySQL 客户端已安装并添加到 PATH 环境变量中") - return False - - -def main(): - """主函数""" - print("Lazer API 快速数据同步") - print("=" * 40) - - db_config = parse_database_url() - print(f"数据库: {db_config['host']}:{db_config['port']}/{db_config['database']}") - print() - - # 确认是否继续 - print("这将执行以下操作:") - print("1. 创建 lazer 专用表结构") - print("2. 同步现有用户数据到新表") - print("3. 不会修改现有的原始表数据") - print() - - confirm = input("是否继续? (y/N): ").strip().lower() - if confirm != "y": - print("操作已取消") - return - - # 获取脚本路径 - script_dir = os.path.dirname(__file__) - migrations_dir = os.path.join(script_dir, "migrations_old") - - # 第一步: 创建表结构 - print("\n步骤 1: 创建 lazer 专用表结构...") - add_fields_script = os.path.join(migrations_dir, "add_missing_fields.sql") - if not run_sql_script(add_fields_script): - print("表结构创建失败,停止执行") - return - - # 第二步: 同步数据 - print("\n步骤 2: 同步历史数据...") - sync_script = os.path.join(migrations_dir, "sync_legacy_data.sql") - if not run_sql_script(sync_script): - print("数据同步失败") - return - - # 第三步: 添加缺失的字段 - print("\n步骤 3: 添加缺失的字段...") - add_rank_fields_script = os.path.join(migrations_dir, "add_lazer_rank_fields.sql") - if not run_sql_script(add_rank_fields_script): - print("添加字段失败") - return - - print("\n🎉 数据同步完成!") - print("\n现在您可以:") - print("1. 启动 Lazer API 服务器") - print("2. 使用现有用户账号登录") - print("3. 查看同步后的用户数据") - - -if __name__ == "__main__": - main() diff --git a/remove_ansi.py b/remove_ansi.py deleted file mode 100644 index 1720888..0000000 --- a/remove_ansi.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to remove ANSI escape codes from log files -""" - -from __future__ import annotations - -import re -import sys - - -def remove_ansi_codes(text): - """ - Remove ANSI escape codes from text - """ - # Regular expression to match ANSI escape codes - ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") - return ansi_escape.sub("", text) - - -def process_log_file(input_file, output_file=None): - """ - Process log file and remove ANSI escape codes - """ - if output_file is None: - output_file = ( - input_file.replace(".log", "_clean.log") - if ".log" in input_file - else input_file + "_clean" - ) - - with open(input_file, "r", encoding="utf-8") as infile: - content = infile.read() - - # Remove ANSI escape codes - clean_content = remove_ansi_codes(content) - - with open(output_file, "w", encoding="utf-8") as outfile: - outfile.write(clean_content) - - print(f"Processed {input_file} -> {output_file}") - - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: python remove_ansi.py [output_file]") - sys.exit(1) - - input_file = sys.argv[1] - output_file = sys.argv[2] if len(sys.argv) > 2 else None - - process_log_file(input_file, output_file) diff --git a/sync_data.py b/sync_data.py deleted file mode 100644 index b69d7c8..0000000 --- a/sync_data.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python3 -""" -Lazer API 数据同步脚本 -用于将现有的 bancho.py 数据同步到新的 lazer 专用表中 -""" - -from __future__ import annotations - -import logging -import os -import sys - -import pymysql - -# 配置日志 -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", - handlers=[logging.FileHandler("data_sync.log"), logging.StreamHandler(sys.stdout)], -) - -logger = logging.getLogger(__name__) - - -class DatabaseSyncer: - def __init__(self, host: str, port: int, user: str, password: str, database: str): - """初始化数据库连接配置""" - self.host = host - self.port = port - self.user = user - self.password = password - self.database = database - self.connection = None - - def connect(self): - """连接到数据库""" - try: - self.connection = pymysql.connect( - host=self.host, - port=self.port, - user=self.user, - password=self.password, - database=self.database, - charset="utf8mb4", - autocommit=False, - ) - logger.info(f"成功连接到数据库 {self.database}") - except Exception as e: - logger.error(f"连接数据库失败: {e}") - raise - - def disconnect(self): - """断开数据库连接""" - if self.connection: - self.connection.close() - logger.info("数据库连接已关闭") - - def execute_sql_file(self, file_path: str): - """执行 SQL 文件""" - if not os.path.exists(file_path): - logger.error(f"SQL 文件不存在: {file_path}") - return False - - try: - with open(file_path, encoding="utf-8") as f: - sql_content = f.read() - - # 分割SQL语句(简单实现,按分号分割) - statements = [ - stmt.strip() for stmt in sql_content.split(";") if stmt.strip() - ] - - cursor = self.connection.cursor() - - for i, statement in enumerate(statements): - # 跳过注释和空语句 - if statement.startswith("--") or not statement: - continue - - try: - logger.info(f"执行第 {i + 1}/{len(statements)} 条SQL语句...") - cursor.execute(statement) - - # 如果是SELECT语句,显示结果 - if statement.strip().upper().startswith("SELECT"): - results = cursor.fetchall() - if results: - logger.info(f"查询结果: {results}") - - except Exception as e: - logger.error(f"执行SQL语句失败: {statement[:100]}...") - logger.error(f"错误信息: {e}") - # 继续执行其他语句 - continue - - self.connection.commit() - cursor.close() - logger.info(f"成功执行SQL文件: {file_path}") - return True - - except Exception as e: - logger.error(f"执行SQL文件失败: {e}") - if self.connection: - self.connection.rollback() - return False - - def check_tables_exist(self, tables: list) -> dict: - """检查表是否存在""" - results = {} - cursor = self.connection.cursor() - - for table in tables: - try: - cursor.execute(f"SHOW TABLES LIKE '{table}'") - exists = cursor.fetchone() is not None - results[table] = exists - logger.info(f"表 '{table}' {'存在' if exists else '不存在'}") - except Exception as e: - logger.error(f"检查表 '{table}' 时出错: {e}") - results[table] = False - - cursor.close() - return results - - def get_table_count(self, table: str) -> int: - """获取表的记录数""" - try: - cursor = self.connection.cursor() - cursor.execute(f"SELECT COUNT(*) FROM {table}") - result = cursor.fetchone() - count = result[0] if result else 0 - cursor.close() - return count - except Exception as e: - logger.error(f"获取表 '{table}' 记录数失败: {e}") - return -1 - - -def main(): - """主函数""" - print("Lazer API 数据同步工具") - print("=" * 50) - - # 数据库配置 - db_config = { - "host": input("数据库主机 [localhost]: ").strip() or "localhost", - "port": int(input("数据库端口 [3306]: ").strip() or "3306"), - "user": input("数据库用户名: ").strip(), - "password": input("数据库密码: ").strip(), - "database": input("数据库名称: ").strip(), - } - - syncer = DatabaseSyncer(**db_config) - - try: - # 连接数据库 - syncer.connect() - - # 检查必要的原始表是否存在 - required_tables = ["users", "stats"] - table_status = syncer.check_tables_exist(required_tables) - - missing_tables = [table for table, exists in table_status.items() if not exists] - if missing_tables: - logger.error(f"缺少必要的原始表: {missing_tables}") - return - - # 显示原始表的记录数 - for table in required_tables: - count = syncer.get_table_count(table) - logger.info(f"表 '{table}' 当前有 {count} 条记录") - - # 确认是否执行同步 - print("\n准备执行数据同步...") - print("这将会:") - print("1. 创建 lazer 专用表结构 (如果不存在)") - print("2. 从现有表同步数据到新表") - print("3. 不会修改或删除现有数据") - - confirm = input("\n是否继续? (y/N): ").strip().lower() - if confirm != "y": - print("操作已取消") - return - - # 执行表结构创建 - migrations_dir = os.path.join(os.path.dirname(__file__), "migrations_old") - - print("\n步骤 1: 创建表结构...") - add_fields_sql = os.path.join(migrations_dir, "add_missing_fields.sql") - if os.path.exists(add_fields_sql): - success = syncer.execute_sql_file(add_fields_sql) - if not success: - logger.error("创建表结构失败") - return - else: - logger.warning(f"表结构文件不存在: {add_fields_sql}") - - # 执行数据同步 - print("\n步骤 2: 同步数据...") - sync_sql = os.path.join(migrations_dir, "sync_legacy_data.sql") - if os.path.exists(sync_sql): - success = syncer.execute_sql_file(sync_sql) - if not success: - logger.error("数据同步失败") - return - else: - logger.error(f"同步脚本不存在: {sync_sql}") - return - - # 显示同步后的统计信息 - print("\n步骤 3: 同步完成统计...") - lazer_tables = [ - "lazer_user_profiles", - "lazer_user_countries", - "lazer_user_statistics", - "lazer_user_kudosu", - "lazer_user_counts", - ] - - for table in lazer_tables: - count = syncer.get_table_count(table) - if count >= 0: - logger.info(f"表 '{table}' 现在有 {count} 条记录") - - print("\n数据同步完成!") - - except KeyboardInterrupt: - print("\n\n操作被用户中断") - except Exception as e: - logger.error(f"同步过程中发生错误: {e}") - finally: - syncer.disconnect() - - -if __name__ == "__main__": - main() diff --git a/test_api.py b/test_api.py deleted file mode 100644 index c87ef5b..0000000 --- a/test_api.py +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env python3 -""" -测试 osu! API 模拟服务器的脚本 -""" - -from __future__ import annotations - -import os - -from dotenv import load_dotenv -import httpx as requests - -# 加载 .env 文件 -load_dotenv() - -CLIENT_ID = os.environ.get("OSU_CLIENT_ID", "5") -CLIENT_SECRET = os.environ.get( - "OSU_CLIENT_SECRET", "FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" -) -API_URL = os.environ.get("OSU_API_URL", "http://localhost:8000") - - -def test_server_health(): - """测试服务器健康状态""" - try: - response = requests.get(f"{API_URL}/health") - if response.status_code == 200: - print("✅ 服务器健康检查通过") - return True - else: - print(f"❌ 服务器健康检查失败: {response.status_code}") - return False - except Exception as e: - print(f"❌ 无法连接到服务器: {e}") - return False - - -def authenticate(username: str, password: str): - """通过 OAuth 密码流进行身份验证并返回令牌字典""" - url = f"{API_URL}/oauth/token" - data = { - "grant_type": "password", - "username": username, - "password": password, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "scope": "*", - } - - try: - response = requests.post(url, data=data) - if response.status_code == 200: - print("✅ 身份验证成功") - return response.json() - else: - print(f"❌ 身份验证失败: {response.status_code}") - print(f"响应内容: {response.text}") - return None - except Exception as e: - print(f"❌ 身份验证请求失败: {e}") - return None - - -def refresh_token(refresh_token: str): - """刷新 OAuth 令牌""" - url = f"{API_URL}/oauth/token" - data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "scope": "*", - } - - try: - response = requests.post(url, data=data) - if response.status_code == 200: - print("✅ 令牌刷新成功") - return response.json() - else: - print(f"❌ 令牌刷新失败: {response.status_code}") - print(f"响应内容: {response.text}") - return None - except Exception as e: - print(f"❌ 令牌刷新请求失败: {e}") - return None - - -def get_current_user(access_token: str, ruleset: str = "osu"): - """获取认证用户的数据""" - url = f"{API_URL}/api/v2/me/{ruleset}" - headers = {"Authorization": f"Bearer {access_token}"} - - try: - response = requests.get(url, headers=headers) - if response.status_code == 200: - print(f"✅ 成功获取 {ruleset} 模式的用户数据") - return response.json() - else: - print(f"❌ 获取用户数据失败: {response.status_code}") - print(f"响应内容: {response.text}") - return None - except Exception as e: - print(f"❌ 获取用户数据请求失败: {e}") - return None - - -def get_beatmap_scores(access_token: str, beatmap_id: int): - """获取谱面成绩数据""" - url = f"{API_URL}/api/v2/beatmaps/{beatmap_id}/scores" - headers = {"Authorization": f"Bearer {access_token}"} - - try: - response = requests.get(url, headers=headers) - if response.status_code == 200: - print(f"✅ 成功获取谱面 {beatmap_id} 的成绩数据") - return response.json() - else: - print(f"❌ 获取谱面成绩失败: {response.status_code}") - print(f"响应内容: {response.text}") - return None - except Exception as e: - print(f"❌ 获取谱面成绩请求失败: {e}") - return None - - -def get_user_beatmap_score(access_token: str, beatmap_id: int, user_id: int): - """获取玩家成绩""" - url = f"{API_URL}/api/v2/beatmaps/{beatmap_id}/scores/users/{user_id}" - headers = {"Authorization": f"Bearer {access_token}"} - try: - response = requests.get(url, headers=headers) - if response.status_code == 200: - print(f"✅ 成功获取谱面 {beatmap_id} 中用户 {user_id} 的成绩数据") - return response.json() - else: - print(f"❌ 获取谱面成绩失败: {response.status_code}") - print(f"响应内容: {response.text}") - return None - except Exception as e: - print(f"❌ 获取谱面成绩请求失败: {e}") - return None - - -def get_user_beatmap_score_all(access_token: str, beatmap_id: int, user_id: int): - """获取玩家成绩""" - url = f"{API_URL}/api/v2/beatmaps/{beatmap_id}/scores/users/{user_id}/all" - headers = {"Authorization": f"Bearer {access_token}"} - try: - response = requests.get(url, headers=headers) - if response.status_code == 200: - print(f"✅ 成功获取谱面 {beatmap_id} 中用户 {user_id} 的成绩数据") - return response.json() - else: - print(f"❌ 获取谱面成绩失败: {response.status_code}") - print(f"响应内容: {response.text}") - return None - except Exception as e: - print(f"❌ 获取谱面成绩请求失败: {e}") - return None - - -def main(): - """主测试函数""" - print("=== osu! API 模拟服务器测试 ===\n") - - # 1. 测试服务器健康状态 - print("1. 检查服务器状态...") - if not test_server_health(): - print("请确保服务器正在运行: uvicorn main:app --reload") - return - - print() - - # 2. 获取用户凭据 - print("2. 用户身份验证...") - username = input("请输入用户名 (默认: Googujiang): ").strip() or "Googujiang" - - import getpass - - password = getpass.getpass("请输入密码 (默认: password123): ") or "password123" - - # 3. 身份验证 - print(f"\n3. 正在验证用户 '{username}'...") - token_data = authenticate(username, password) - if not token_data: - print("身份验证失败,请检查用户名和密码") - return - - print(f"访问令牌: {token_data['access_token']}") - print(f"刷新令牌: {token_data['refresh_token']}") - print(f"令牌有效期: {token_data['expires_in']} 秒") - - # 4. 获取用户数据 - print("\n4. 获取用户数据...") - for ruleset in ["osu", "taiko", "fruits", "mania"]: - print(f"\n--- {ruleset.upper()} 模式 ---") - user_data = get_current_user(token_data["access_token"], ruleset) - if user_data: - print(f"用户名: {user_data['username']}") - print(f"国家: {user_data['country']['name']} ({user_data['country_code']})") - print(f"全球排名: {user_data['statistics']['global_rank']}") - print(f"PP: {user_data['statistics']['pp']}") - print(f"游戏次数: {user_data['statistics']['play_count']}") - print(f"命中精度: {user_data['statistics']['hit_accuracy']:.2f}%") - - # 5. 测试获取谱面成绩 - print("\n5. 测试获取谱面成绩...") - scores_data = get_beatmap_scores(token_data["access_token"], 1) - if scores_data: - print(f"谱面成绩总数: {len(scores_data['scores'])}") - if scores_data["userScore"]: - print("用户在该谱面有成绩记录") - print(f"用户成绩 ID: {scores_data['userScore']['id']}") - print(f"用户成绩分数: {scores_data['userScore']['total_score']}\n") - else: - print("用户在该谱面没有成绩记录\n") - - # 5a. 测试谱面指定用户成绩 - user_score = get_user_beatmap_score(token_data["access_token"], 1, 1) - if user_score: - print(f"用户成绩ID:{user_score['score']['id']}") - print(f"此成绩acc:{user_score['score']['accuracy']}") - print(f"总分:{user_score['score']['classic_total_score']}\n") - else: - print("该用户在此谱面没有记录\n") - - # 5b. 测试谱面指定用户成绩 - user_score_all = get_user_beatmap_score_all(token_data["access_token"], 1, 1) - if user_score_all: - index = 1 - for score in user_score_all: - print(f"第{index}个成绩:") - print(f"用户成绩ID:{score['id']}") - print(f"此成绩acc:{score['accuracy']}") - print(f"总分:{score['classic_total_score']}") - else: - print("该用户在此谱面没有记录") - - # 6. 测试令牌刷新 - print("\n6. 测试令牌刷新...") - new_token_data = refresh_token(token_data["refresh_token"]) - if new_token_data: - print(f"新访问令牌: {new_token_data['access_token']}") - - # 使用新令牌获取用户数据 - print("\n6. 使用新令牌获取用户数据...") - user_data = get_current_user(new_token_data["access_token"]) - if user_data: - print(f"✅ 新令牌有效,用户: {user_data['username']}") - - print("\n=== 测试完成 ===") - - -if __name__ == "__main__": - main() diff --git a/test_lazer.py b/test_lazer.py deleted file mode 100644 index 627325d..0000000 --- a/test_lazer.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env python3 -""" -Lazer API 系统测试脚本 -验证新的 lazer 表支持是否正常工作 -""" - -from __future__ import annotations - -import os -import sys - -sys.path.append(os.path.dirname(os.path.dirname(__file__))) - -from app.database import User -from app.dependencies.database import engine -from app.utils import convert_db_user_to_api_user - -from sqlmodel import select -from sqlmodel.ext.asyncio.session import AsyncSession - - -async def test_lazer_tables(): - """测试 lazer 表的基本功能""" - print("测试 Lazer API 表支持...") - - async with AsyncSession(engine) as session: - async with session.begin(): - try: - # 测试查询用户 - statement = select(User) - result = await session.execute(statement) - user = result.scalars().first() - if not user: - print("❌ 没有找到用户,请先同步数据") - return False - - print(f"✓ 找到用户: {user.name} (ID: {user.id})") - - # 测试 lazer 资料 - if user.lazer_profile: - print( - f"✓ 用户有 lazer 资料: 支持者={user.lazer_profile.is_supporter}" - ) - else: - print("⚠ 用户没有 lazer 资料,将使用默认值") - - # 测试 lazer 统计 - osu_stats = None - for stat in user.lazer_statistics: - if stat.mode == "osu": - osu_stats = stat - break - - if osu_stats: - print( - f"✓ 用户有 osu! 统计: PP={osu_stats.pp}, " - f"游戏次数={osu_stats.play_count}" - ) - else: - print("⚠ 用户没有 osu! 统计,将使用默认值") - - # 测试转换为 API 格式 - api_user = convert_db_user_to_api_user(user, "osu") - print("✓ 成功转换为 API 用户格式") - print(f" - 用户名: {api_user.username}") - print(f" - 国家: {api_user.country_code}") - print(f" - PP: {api_user.statistics.pp}") - print(f" - 是否支持者: {api_user.is_supporter}") - - return True - - except Exception as e: - print(f"❌ 测试失败: {e}") - import traceback - - traceback.print_exc() - return False - - -async def test_authentication(): - """测试认证功能""" - print("\n测试认证功能...") - - async with AsyncSession(engine) as session: - async with session.begin(): - try: - # 尝试认证第一个用户 - statement = select(User) - result = await session.execute(statement) - user = result.scalars().first() - if not user: - print("❌ 没有用户进行认证测试") - return False - - print(f"✓ 测试用户: {user.name}") - print("⚠ 注意: 实际密码认证需要正确的密码") - - return True - - except Exception as e: - print(f"❌ 认证测试失败: {e}") - return False - - -async def main(): - """主测试函数""" - print("Lazer API 系统测试") - print("=" * 40) - - # 测试表连接 - success1 = await test_lazer_tables() - - # 测试认证 - success2 = await test_authentication() - - print("\n" + "=" * 40) - if success1 and success2: - print("🎉 所有测试通过!") - print("\n现在可以:") - print("1. 启动 API 服务器: python main.py") - print("2. 测试 OAuth 认证") - print("3. 调用 /api/v2/me/osu 获取用户信息") - else: - print("❌ 测试失败,请检查:") - print("1. 数据库连接是否正常") - print("2. 是否已运行数据同步脚本") - print("3. lazer 表是否正确创建") - - -if __name__ == "__main__": - import asyncio - - asyncio.run(main()) diff --git a/test_password.py b/test_password.py deleted file mode 100644 index c0aa3cd..0000000 --- a/test_password.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -""" -测试密码哈希和验证逻辑 -""" - -from __future__ import annotations - -import hashlib - -from app.auth import bcrypt_cache, get_password_hash, verify_password_legacy - - -def test_password_logic(): - """测试密码逻辑""" - print("=== 测试密码哈希和验证逻辑 ===\n") - - # 测试密码 - password = "password123" - print(f"原始密码: {password}") - - # 1. 生成哈希 - print("\n1. 生成密码哈希...") - hashed = get_password_hash(password) - print(f"bcrypt 哈希: {hashed}") - - # 2. 验证密码 - print("\n2. 验证密码...") - is_valid = verify_password_legacy(password, hashed) - print(f"验证结果: {'✅ 成功' if is_valid else '❌ 失败'}") - - # 3. 测试错误密码 - print("\n3. 测试错误密码...") - wrong_password = "wrongpassword" - is_valid_wrong = verify_password_legacy(wrong_password, hashed) - print(f"错误密码验证结果: {'❌ 不应该成功' if is_valid_wrong else '✅ 正确拒绝'}") - - # 4. 测试缓存 - print("\n4. 缓存状态:") - print(f"缓存中的条目数: {len(bcrypt_cache)}") - if hashed in bcrypt_cache: - print(f"缓存的 MD5: {bcrypt_cache[hashed]}") - expected_md5 = hashlib.md5(password.encode()).hexdigest().encode() - print(f"期望的 MD5: {expected_md5}") - print(f"缓存匹配: {'✅' if bcrypt_cache[hashed] == expected_md5 else '❌'}") - - # 5. 再次验证(应该使用缓存) - print("\n5. 再次验证(使用缓存)...") - is_valid_cached = verify_password_legacy(password, hashed) - print(f"缓存验证结果: {'✅ 成功' if is_valid_cached else '❌ 失败'}") - - print("\n=== 测试完成 ===") - - -if __name__ == "__main__": - test_password_logic() From d9cf522f40e95f6533bedb7238b9d4847c31170d Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 05:05:08 +0000 Subject: [PATCH 069/104] feat(score): support custom pp calculator --- app/calculator.py | 13 +++++++++---- app/models/score.py | 10 +++++++--- pyproject.toml | 1 - uv.lock | 28 ---------------------------- 4 files changed, 16 insertions(+), 36 deletions(-) diff --git a/app/calculator.py b/app/calculator.py index 1815fa8..cc21f0f 100644 --- a/app/calculator.py +++ b/app/calculator.py @@ -7,7 +7,15 @@ from app.models.beatmap import BeatmapAttributes from app.models.mods import APIMod from app.models.score import GameMode -import rosu_pp_py as rosu +try: + import rosu_pp_py as rosu +except ImportError: + raise ImportError( + "rosu-pp-py is not installed. " + "Please install it.\n" + " Official: uv add rosu-pp-py\n" + " ppy-sb: uv add git+https://github.com/ppy-sb/rosu-pp-py.git" + ) if TYPE_CHECKING: from app.database.score import Score @@ -51,8 +59,6 @@ def calculate_pp( ) -> float: map = rosu.Beatmap(content=beatmap) map.convert(score.gamemode.to_rosu(), score.mods) # pyright: ignore[reportArgumentType] - if map.is_suspicious(): - return 0.0 perf = rosu.Performance( mods=score.mods, lazer=True, @@ -67,7 +73,6 @@ def calculate_pp( n100=score.n100, n50=score.n50, misses=score.nmiss, - hitresult_priority=rosu.HitResultPriority.Fastest, ) attrs = perf.calculate(map) return attrs.pp diff --git a/app/models/score.py b/app/models/score.py index cef6b28..1da72a8 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -1,12 +1,14 @@ from __future__ import annotations from enum import Enum -from typing import Literal, TypedDict +from typing import TYPE_CHECKING, Literal, TypedDict from .mods import API_MODS, APIMod, init_mods from pydantic import BaseModel, Field, ValidationInfo, field_validator -import rosu_pp_py as rosu + +if TYPE_CHECKING: + import rosu_pp_py as rosu class GameMode(str, Enum): @@ -15,7 +17,9 @@ class GameMode(str, Enum): FRUITS = "fruits" MANIA = "mania" - def to_rosu(self) -> rosu.GameMode: + def to_rosu(self) -> "rosu.GameMode": + import rosu_pp_py as rosu + return { GameMode.OSU: rosu.GameMode.Osu, GameMode.TAIKO: rosu.GameMode.Taiko, diff --git a/pyproject.toml b/pyproject.toml index 3ab61c6..efadb39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ dependencies = [ "python-jose[cryptography]>=3.3.0", "python-multipart>=0.0.6", "redis>=5.0.1", - "rosu-pp-py>=3.1.0", "sqlalchemy>=2.0.23", "sqlmodel>=0.0.24", "uvicorn[standard]>=0.24.0", diff --git a/uv.lock b/uv.lock index 22a4f1d..a343b97 100644 --- a/uv.lock +++ b/uv.lock @@ -518,7 +518,6 @@ dependencies = [ { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, { name = "redis" }, - { name = "rosu-pp-py" }, { name = "sqlalchemy" }, { name = "sqlmodel" }, { name = "uvicorn", extra = ["standard"] }, @@ -548,7 +547,6 @@ requires-dist = [ { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, { name = "python-multipart", specifier = ">=0.0.6" }, { name = "redis", specifier = ">=5.0.1" }, - { name = "rosu-pp-py", specifier = ">=3.1.0" }, { name = "sqlalchemy", specifier = ">=2.0.23" }, { name = "sqlmodel", specifier = ">=0.0.24" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, @@ -761,32 +759,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/67/e60968d3b0e077495a8fee89cf3f2373db98e528288a48f1ee44967f6e8c/redis-6.2.0-py3-none-any.whl", hash = "sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e", size = 278659, upload-time = "2025-05-28T05:01:16.955Z" }, ] -[[package]] -name = "rosu-pp-py" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/19/b44c30066c6e85cd6a4fd8a8983be91d2336a4e7f0ef04e576bc9b1d7c63/rosu_pp_py-3.1.0.tar.gz", hash = "sha256:4aa64eb5e68b8957357f9b304047db285423b207ad913e28829ccfcd5348d41a", size = 31144, upload-time = "2025-06-03T17:14:27.461Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/04/d752d7cfb71afcbecd0513ffcc716abcf5c3b2b4b9a4e44a3c7e7fc43fba/rosu_pp_py-3.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:61275ddfedd7f67bcb5c42a136fb30a66aeb7e07323c59a67db590de687bd78d", size = 552307, upload-time = "2025-06-03T17:13:33.203Z" }, - { url = "https://files.pythonhosted.org/packages/27/76/e7d3415cdd384b8ea0a2f461c87d9b451108cbded46e2e88676611a99875/rosu_pp_py-3.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:04aacaa6faba9d0892ba5584884cfaf42eb1a7678dc0dff453fc6988e8be8809", size = 508787, upload-time = "2025-06-03T17:13:34.507Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a0/c59168f75b32b6cf3e41d5d44dc478b113eebe38166e6b87af193ebb8d4f/rosu_pp_py-3.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9eecd7a78aeb82abf39ac7db670350a42b6eb8a54eb4a8a13610def02c56d005", size = 525740, upload-time = "2025-06-03T17:13:35.631Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c0/7b498f8ecd6650d718291994c5e6d3931e5572e408d8d7bc9000f2441575/rosu_pp_py-3.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3dd5118614335e9084f076f9fa88fb139e64a9e1750c0d8020c8e8abe9e42dce", size = 550091, upload-time = "2025-06-03T17:13:36.733Z" }, - { url = "https://files.pythonhosted.org/packages/0d/21/85f67440c93bc22135e6e43f6fc1d35d184b9c1523416acfae4b8721d9e5/rosu_pp_py-3.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:edbd67da486af4fbf5d53cd310fddc280a67d06274aea5eb3e322ffc66e82479", size = 566542, upload-time = "2025-06-03T17:13:38.308Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ed/1d3727d327097edf2ecf8a39a267d5f2ba7a82ce2f7c71e1be5b6c278870/rosu_pp_py-3.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:af295819cda6df49324179e5c3986eb4215d6c456a055620ec30716ed22ec97c", size = 704380, upload-time = "2025-06-03T17:13:39.839Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4d/db4fb9bcd1cdebbc761728a8684d700559a5b44e5d2baec262e07907917a/rosu_pp_py-3.1.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b0367959b9ef74f51f1cc414d587b6dabab00390496a855a89073b55e08330b0", size = 813664, upload-time = "2025-06-03T17:13:41.052Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a9/3ec4502f4f44c0e22b7658308def31c96320e339b89cdf474c2612b40351/rosu_pp_py-3.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:adf103385961c01859ae99ded0c289e03f5ab33d86ecabdd4e8f3139c84c6240", size = 738024, upload-time = "2025-06-03T17:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f6/d33cde2f911ff2fdedbbc2be6b249e29f3a65e11acd1b645df77ece0747a/rosu_pp_py-3.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:8dc48f45aff62fc2798e3a4adf4596d9e810079f16650a98c8ed6cf1a37e506b", size = 458391, upload-time = "2025-06-03T17:13:43.706Z" }, - { url = "https://files.pythonhosted.org/packages/ac/53/3f68a24d75c65b789200241f490c2379d86a3760f48dc9e22348f0a619c9/rosu_pp_py-3.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5cda7206c2e8c96fdaccf0b531d0614df5e30ad6cd1bf217ec5556406294ed6c", size = 552011, upload-time = "2025-06-03T17:13:44.889Z" }, - { url = "https://files.pythonhosted.org/packages/b6/95/6251e0d7f615c148d17e5151b89e3da7da89ef5363de921b5957b5407510/rosu_pp_py-3.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d54606719ac93ccadbcb40acd3dda41f6e319e075303b6bbfdebf784ed451281", size = 508659, upload-time = "2025-06-03T17:13:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/7f/2b/23d449a97fb6d34ced7c421a13669d98a5522ce79fabd8151a873d3d152a/rosu_pp_py-3.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec88b95845851018e95e49f3f8610dc989a2cfc74273a8c40fe7ef94e4f37a6a", size = 525367, upload-time = "2025-06-03T17:13:47.56Z" }, - { url = "https://files.pythonhosted.org/packages/52/9a/c8879dd4f62632d8928cc147bca705eb7e2a21dc0ad43307d6f68e0a3b41/rosu_pp_py-3.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f39332ec3c479c68396d0f6ea09ab3ee77ca595ab14f4739581ca8a631dc33d8", size = 549600, upload-time = "2025-06-03T17:13:48.717Z" }, - { url = "https://files.pythonhosted.org/packages/e8/86/a0154a1b3149bd25884ea8009c70b9792a960dbfd4172b65ace0e55394b4/rosu_pp_py-3.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4a290f7920b0015e0a9d829428cce7948ae98043985b237b0d68e2b28c8dba3", size = 566082, upload-time = "2025-06-03T17:13:49.761Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ee/897f5cb48dfe067549dee39cb265581782d1daebc4dd27b1c1bc58551755/rosu_pp_py-3.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:11ab7db7903a2752b7c53458e689b2f1f724bee1e99d627d447dee69e7668299", size = 704157, upload-time = "2025-06-03T17:13:51.175Z" }, - { url = "https://files.pythonhosted.org/packages/43/7d/67ec98bed784807d543106bb517879149bed3544d1987bdf59eab6ced79e/rosu_pp_py-3.1.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:bc5350a00a37dc273f7e734364a27820f2c274a5a1715fe3b0ef62bd071fae54", size = 813310, upload-time = "2025-06-03T17:13:52.421Z" }, - { url = "https://files.pythonhosted.org/packages/a9/02/fbbb54b21cec66fbe8e2884a73837e0c4e97ca5c625587d90b378c5354f0/rosu_pp_py-3.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:28f171e6042d68df379be0536173626b2ae51ddc4a7b1881209ff384c468918a", size = 737638, upload-time = "2025-06-03T17:13:53.709Z" }, - { url = "https://files.pythonhosted.org/packages/18/9e/f951ef3508cbfbaf36dcee3bd828eb8f922a21b2791bc852074adc1835a1/rosu_pp_py-3.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a327e627bc56e55bc8dd3fcc26abcfe60af1497f310dad7aea3ef798434f2e9b", size = 457855, upload-time = "2025-06-03T17:13:55.317Z" }, -] - [[package]] name = "rsa" version = "4.9.1" From 703a7901b39713ae4b30bd67003ace6490e737a1 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 05:05:30 +0000 Subject: [PATCH 070/104] fix(score): cannot get pp in osu, taiko, catch with HR --- app/models/mods.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/models/mods.py b/app/models/mods.py index 299a05f..95e0f83 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy import json from typing import Literal, NotRequired, TypedDict @@ -129,10 +130,10 @@ COMMON_CONFIG: dict[str, dict] = { } RANKED_MODS: dict[int, dict[str, dict]] = { - 0: COMMON_CONFIG, - 1: COMMON_CONFIG, - 2: COMMON_CONFIG, - 3: COMMON_CONFIG, + 0: deepcopy(COMMON_CONFIG), + 1: deepcopy(COMMON_CONFIG), + 2: deepcopy(COMMON_CONFIG), + 3: deepcopy(COMMON_CONFIG), } # osu RANKED_MODS[0]["HD"]["only_fade_approach_circles"] = False From f165ae5dc38a2ee22533afa5c54f8d4e3dcd2d6a Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 05:38:28 +0000 Subject: [PATCH 071/104] refactor(config): use pydantic-settings --- .env.client | 4 --- .env.example | 34 +++++++++++++++++++++ app/auth.py | 6 ++-- app/config.py | 59 ++++++++++++++++++------------------ app/dependencies/database.py | 4 +-- app/dependencies/fetcher.py | 8 ++--- app/log.py | 6 ++-- app/router/auth.py | 16 +++++----- app/signalr/hub/hub.py | 4 +-- main.py | 6 ++-- pyproject.toml | 1 + uv.lock | 16 ++++++++++ 12 files changed, 105 insertions(+), 59 deletions(-) delete mode 100644 .env.client create mode 100644 .env.example diff --git a/.env.client b/.env.client deleted file mode 100644 index eac9b22..0000000 --- a/.env.client +++ /dev/null @@ -1,4 +0,0 @@ -# osu! API 客户端配置 -OSU_CLIENT_ID=5 -OSU_CLIENT_SECRET=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk -OSU_API_URL=http://localhost:8000 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..88437ff --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# 数据库 URL +DATABASE_URL="mysql+aiomysql://root:password@127.0.0.1:3306/osu_api" +# Redis URL +REDIS_URL="redis://127.0.0.1:6379/0" + +# JWT 密钥,使用 openssl rand -hex 32 生成 +JWT_SECRET_KEY="your_jwt_secret_here" +# JWT 算法 +ALGORITHM="HS256" +# JWT 过期时间 +ACCESS_TOKEN_EXPIRE_MINUTES=1440 + +# 服务器地址 +HOST="0.0.0.0" +PORT=8000 +# 调试模式,生产环境请设置为 false +DEBUG=false + +# osu!lazer 登录设置 +OSU_CLIENT_ID="5" +OSU_CLIENT_SECRET="FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" + +# SignalR 服务器设置 +SIGNALR_NEGOTIATE_TIMEOUT=30 +SIGNALR_PING_INTERVAL=15 + +# Fetcher 设置 +FETCHER_CLIENT_ID="" +FETCHER_CLIENT_SECRET="" +FETCHER_SCOPES=["public"] +FETCHER_CALLBACK_URL="http://localhost:8000/fetcher/callback" + +# 日志设置 +LOG_LEVEL="INFO" diff --git a/app/auth.py b/app/auth.py index 4762662..ddf5f56 100644 --- a/app/auth.py +++ b/app/auth.py @@ -125,12 +125,12 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta( - minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + minutes=settings.access_token_expire_minutes ) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode( - to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM + to_encode, settings.secret_key, algorithm=settings.algorithm ) return encoded_jwt @@ -146,7 +146,7 @@ def verify_token(token: str) -> dict | None: """验证访问令牌""" try: payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + token, settings.secret_key, algorithms=[settings.algorithm] ) return payload except JWTError: diff --git a/app/config.py b/app/config.py index 778155f..d008ccb 100644 --- a/app/config.py +++ b/app/config.py @@ -1,51 +1,50 @@ from __future__ import annotations -import os +from typing import Annotated, Any -from dotenv import load_dotenv - -load_dotenv() +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict -class Settings: +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + # 数据库设置 - DATABASE_URL: str = os.getenv( - "DATABASE_URL", "mysql+aiomysql://root:password@127.0.0.1:3306/osu_api" - ) - REDIS_URL: str = os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0") + database_url: str = "mysql+aiomysql://root:password@127.0.0.1:3306/osu_api" + redis_url: str = "redis://127.0.0.1:6379/0" # JWT 设置 - SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here") - ALGORITHM: str = os.getenv("ALGORITHM", "HS256") - ACCESS_TOKEN_EXPIRE_MINUTES: int = int( - os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440") - ) + secret_key: str = Field(default="your-secret-key-here", alias="jwt_secret_key") + algorithm: str = "HS256" + access_token_expire_minutes: int = 1440 # OAuth 设置 - OSU_CLIENT_ID: str = os.getenv("OSU_CLIENT_ID", "5") - OSU_CLIENT_SECRET: str = os.getenv( - "OSU_CLIENT_SECRET", "FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" - ) + osu_client_id: str = "5" + osu_client_secret: str = "FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" # 服务器设置 - HOST: str = os.getenv("HOST", "0.0.0.0") - PORT: int = int(os.getenv("PORT", "8000")) - DEBUG: bool = os.getenv("DEBUG", "True").lower() == "true" + host: str = "0.0.0.0" + port: int = 8000 + debug: bool = False # SignalR 设置 - SIGNALR_NEGOTIATE_TIMEOUT: int = int(os.getenv("SIGNALR_NEGOTIATE_TIMEOUT", "30")) - SIGNALR_PING_INTERVAL: int = int(os.getenv("SIGNALR_PING_INTERVAL", "15")) + signalr_negotiate_timeout: int = 30 + signalr_ping_interval: int = 15 # Fetcher 设置 - FETCHER_CLIENT_ID: str = os.getenv("FETCHER_CLIENT_ID", "") - FETCHER_CLIENT_SECRET: str = os.getenv("FETCHER_CLIENT_SECRET", "") - FETCHER_SCOPES: list[str] = os.getenv("FETCHER_SCOPES", "public").split(",") - FETCHER_CALLBACK_URL: str = os.getenv( - "FETCHER_CALLBACK_URL", "http://localhost:8000/fetcher/callback" - ) + fetcher_client_id: str = "" + fetcher_client_secret: str = "" + fetcher_scopes: Annotated[list[str], NoDecode] = ["public"] + fetcher_callback_url: str = "http://localhost:8000/fetcher/callback" # 日志设置 - LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO").upper() + log_level: str = "INFO" + + @field_validator("fetcher_scopes", mode="before") + def validate_fetcher_scopes(cls, v: Any) -> list[str]: + if isinstance(v, str): + return v.split(",") + return v settings = Settings() diff --git a/app/dependencies/database.py b/app/dependencies/database.py index 1525bfb..c3ec9a4 100644 --- a/app/dependencies/database.py +++ b/app/dependencies/database.py @@ -18,10 +18,10 @@ def json_serializer(value): # 数据库引擎 -engine = create_async_engine(settings.DATABASE_URL, json_serializer=json_serializer) +engine = create_async_engine(settings.database_url, json_serializer=json_serializer) # Redis 连接 -redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True) +redis_client = redis.from_url(settings.redis_url, decode_responses=True) # 数据库依赖 diff --git a/app/dependencies/fetcher.py b/app/dependencies/fetcher.py index 806eb87..51964f0 100644 --- a/app/dependencies/fetcher.py +++ b/app/dependencies/fetcher.py @@ -12,10 +12,10 @@ async def get_fetcher() -> Fetcher: global fetcher if fetcher is None: fetcher = Fetcher( - settings.FETCHER_CLIENT_ID, - settings.FETCHER_CLIENT_SECRET, - settings.FETCHER_SCOPES, - settings.FETCHER_CALLBACK_URL, + settings.fetcher_client_id, + settings.fetcher_client_secret, + settings.fetcher_scopes, + settings.fetcher_callback_url, ) redis = get_redis() access_token = await redis.get(f"fetcher:access_token:{fetcher.client_id}") diff --git a/app/log.py b/app/log.py index 600ec4d..8383494 100644 --- a/app/log.py +++ b/app/log.py @@ -120,10 +120,10 @@ logger.add( format=( "{time:YYYY-MM-DD HH:mm:ss} [{level}] | {message}" ), - level=settings.LOG_LEVEL, - diagnose=settings.DEBUG, + level=settings.log_level, + diagnose=settings.debug, ) -logging.basicConfig(handlers=[InterceptHandler()], level=settings.LOG_LEVEL, force=True) +logging.basicConfig(handlers=[InterceptHandler()], level=settings.log_level, force=True) uvicorn_loggers = [ "uvicorn", diff --git a/app/router/auth.py b/app/router/auth.py index 7a2a14d..2fa69ad 100644 --- a/app/router/auth.py +++ b/app/router/auth.py @@ -199,8 +199,8 @@ async def oauth_token( """OAuth 令牌端点""" # 验证客户端凭据 if ( - client_id != settings.OSU_CLIENT_ID - or client_secret != settings.OSU_CLIENT_SECRET + client_id != settings.osu_client_id + or client_secret != settings.osu_client_secret ): return create_oauth_error_response( error="invalid_client", @@ -242,7 +242,7 @@ async def oauth_token( ) # 生成令牌 - access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) access_token = create_access_token( data={"sub": str(user.id)}, expires_delta=access_token_expires ) @@ -255,13 +255,13 @@ async def oauth_token( user.id, access_token, refresh_token_str, - settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + settings.access_token_expire_minutes * 60, ) return TokenResponse( access_token=access_token, token_type="Bearer", - expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + expires_in=settings.access_token_expire_minutes * 60, refresh_token=refresh_token_str, scope=scope, ) @@ -295,7 +295,7 @@ async def oauth_token( ) # 生成新的访问令牌 - access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) access_token = create_access_token( data={"sub": str(token_record.user_id)}, expires_delta=access_token_expires ) @@ -307,13 +307,13 @@ async def oauth_token( token_record.user_id, access_token, new_refresh_token, - settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + settings.access_token_expire_minutes * 60, ) return TokenResponse( access_token=access_token, token_type="Bearer", - expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + expires_in=settings.access_token_expire_minutes * 60, refresh_token=new_refresh_token, scope=scope, ) diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index 4bab451..92fc90d 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -74,7 +74,7 @@ class Client: while True: try: await self.send_packet(PingPacket()) - await asyncio.sleep(settings.SIGNALR_PING_INTERVAL) + await asyncio.sleep(settings.signalr_ping_interval) except WebSocketDisconnect: break except Exception as e: @@ -131,7 +131,7 @@ class Hub[TState: UserState]: if connection_token in self.waited_clients: if ( self.waited_clients[connection_token] - < time.time() - settings.SIGNALR_NEGOTIATE_TIMEOUT + < time.time() - settings.signalr_negotiate_timeout ): raise TimeoutError(f"Connection {connection_id} has waited too long.") del self.waited_clients[connection_token] diff --git a/main.py b/main.py index fe27c14..ade04f2 100644 --- a/main.py +++ b/main.py @@ -57,9 +57,9 @@ if __name__ == "__main__": uvicorn.run( "main:app", - host=settings.HOST, - port=settings.PORT, - reload=settings.DEBUG, + host=settings.host, + port=settings.port, + reload=settings.debug, log_config=None, # 禁用uvicorn默认日志配置 access_log=True, # 启用访问日志 ) diff --git a/pyproject.toml b/pyproject.toml index efadb39..a687f17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "loguru>=0.7.3", "msgpack-lazer-api", "passlib[bcrypt]>=1.7.4", + "pydantic-settings>=2.10.1", "pydantic[email]>=2.5.0", "python-dotenv>=1.0.0", "python-jose[cryptography]>=3.3.0", diff --git a/uv.lock b/uv.lock index a343b97..ffc6105 100644 --- a/uv.lock +++ b/uv.lock @@ -514,6 +514,7 @@ dependencies = [ { name = "msgpack-lazer-api" }, { name = "passlib", extra = ["bcrypt"] }, { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, @@ -543,6 +544,7 @@ requires-dist = [ { name = "msgpack-lazer-api", editable = "packages/msgpack_lazer_api" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "pydantic", extras = ["email"], specifier = ">=2.5.0" }, + { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, { name = "python-multipart", specifier = ">=0.0.6" }, @@ -678,6 +680,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, +] + [[package]] name = "pymysql" version = "1.1.1" From efc784d903ba3eceafe9bd2507fc5f7eb427e7d3 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 07:02:55 +0000 Subject: [PATCH 072/104] feat(score): support osu-rx & osu-ap & all beatmap leaderboard like osu.ppy.sb --- .env.example | 7 ++ app/config.py | 7 ++ app/database/beatmap.py | 19 +-- app/database/beatmapset.py | 17 ++- app/database/score.py | 18 ++- app/models/beatmap.py | 14 +++ app/models/mods.py | 8 ++ app/models/score.py | 7 ++ app/router/auth.py | 10 +- app/router/score.py | 9 +- app/service/osu_rx_statistics.py | 42 +++++++ app/signalr/hub/spectator.py | 5 +- main.py | 2 + .../19cdc9ce4dcb_gamemode_add_osurx_osupp.py | 116 ++++++++++++++++++ 14 files changed, 262 insertions(+), 19 deletions(-) create mode 100644 app/service/osu_rx_statistics.py create mode 100644 migrations/versions/19cdc9ce4dcb_gamemode_add_osurx_osupp.py diff --git a/.env.example b/.env.example index 88437ff..47b770c 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,10 @@ FETCHER_CALLBACK_URL="http://localhost:8000/fetcher/callback" # 日志设置 LOG_LEVEL="INFO" + +# 游戏设置 +ENABLE_OSU_RX=false # 启用 osu!RX 统计数据 +ENABLE_OSU_AP=false # 启用 osu!AP 统计数据 +ENABLE_ALL_MODS_PP=false # 启用所有 Mod 的 PP 计算 +ENABLE_SUPPORTER_FOR_ALL_USERS=false # 启用所有新注册用户的支持者状态 +ENABLE_ALL_BEATMAP_LEADERBOARD=false # 启用所有谱面的排行榜(没有排行榜的谱面会以 APPROVED 状态返回) diff --git a/app/config.py b/app/config.py index d008ccb..e9ea027 100644 --- a/app/config.py +++ b/app/config.py @@ -40,6 +40,13 @@ class Settings(BaseSettings): # 日志设置 log_level: str = "INFO" + # 游戏设置 + enable_osu_rx: bool = False + enable_osu_ap: bool = False + enable_all_mods_pp: bool = False + enable_supporter_for_all_users: bool = False + enable_all_beatmap_leaderboard: bool = False + @field_validator("fetcher_scopes", mode="before") def validate_fetcher_scopes(cls, v: Any) -> list[str]: if isinstance(v, str): diff --git a/app/database/beatmap.py b/app/database/beatmap.py index 096e9b9..d3ba940 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -1,6 +1,7 @@ from datetime import datetime from typing import TYPE_CHECKING +from app.config import settings from app.models.beatmap import BeatmapRankStatus from app.models.score import MODE_TO_INT, GameMode @@ -62,10 +63,6 @@ class Beatmap(BeatmapBase, table=True): back_populates="beatmaps", sa_relationship_kwargs={"lazy": "joined"} ) - @property - def can_ranked(self) -> bool: - return self.beatmap_status > BeatmapRankStatus.PENDING - @classmethod async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap": d = resp.model_dump() @@ -160,12 +157,20 @@ class BeatmapResp(BeatmapBase): ) -> "BeatmapResp": from .score import Score + beatmap_status = beatmap.beatmap_status beatmap_ = beatmap.model_dump() if query_mode is not None and beatmap.mode != query_mode: beatmap_["convert"] = True - beatmap_["is_scoreable"] = beatmap.beatmap_status > BeatmapRankStatus.PENDING - beatmap_["status"] = beatmap.beatmap_status.name.lower() - beatmap_["ranked"] = beatmap.beatmap_status.value + beatmap_["is_scoreable"] = beatmap_status.has_leaderboard() + if ( + settings.enable_all_beatmap_leaderboard + and not beatmap_status.has_leaderboard() + ): + beatmap_["ranked"] = BeatmapRankStatus.APPROVED.value + beatmap_["status"] = BeatmapRankStatus.APPROVED.name.lower() + else: + beatmap_["status"] = beatmap_status.name.lower() + beatmap_["ranked"] = beatmap_status.value beatmap_["mode_int"] = MODE_TO_INT[beatmap.mode] if not from_set: beatmap_["beatmapset"] = await BeatmapsetResp.from_db( diff --git a/app/database/beatmapset.py b/app/database/beatmapset.py index df7bc57..7ead18f 100644 --- a/app/database/beatmapset.py +++ b/app/database/beatmapset.py @@ -1,6 +1,7 @@ from datetime import datetime from typing import TYPE_CHECKING, NotRequired, TypedDict +from app.config import settings from app.models.beatmap import BeatmapRankStatus, Genre, Language from app.models.score import GameMode @@ -228,11 +229,21 @@ class BeatmapsetResp(BeatmapsetBase): required=beatmapset.nominations_required, current=beatmapset.nominations_current, ), - "status": beatmapset.beatmap_status.name.lower(), - "ranked": beatmapset.beatmap_status.value, - "is_scoreable": beatmapset.beatmap_status > BeatmapRankStatus.PENDING, + "is_scoreable": beatmapset.beatmap_status.has_leaderboard(), **beatmapset.model_dump(), } + + beatmap_status = beatmapset.beatmap_status + if ( + settings.enable_all_beatmap_leaderboard + and not beatmap_status.has_leaderboard() + ): + update["status"] = BeatmapRankStatus.APPROVED.name.lower() + update["ranked"] = BeatmapRankStatus.APPROVED.value + else: + update["status"] = beatmap_status.name.lower() + update["ranked"] = beatmap_status.value + if session and user: existing_favourite = ( await session.exec( diff --git a/app/database/score.py b/app/database/score.py index adeeec9..ea1ccf2 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -13,6 +13,7 @@ from app.calculator import ( calculate_weighted_pp, clamp, ) +from app.config import settings from app.database.team import TeamMember from app.models.model import RespWithCursor, UTCBaseModel from app.models.mods import APIMod, mods_can_get_pp @@ -324,6 +325,13 @@ async def get_leaderboard( user: User | None = None, limit: int = 50, ) -> tuple[list[Score], Score | None]: + is_rx = "RX" in (mods or []) + is_ap = "AP" in (mods or []) + if settings.enable_osu_rx and is_rx: + mode = GameMode.OSURX + elif settings.enable_osu_ap and is_ap: + mode = GameMode.OSUAP + wheres = await _score_where(type, beatmap, mode, mods, user) if wheres is None: return [], None @@ -637,6 +645,14 @@ async def process_score( ) -> Score: assert user.id can_get_pp = info.passed and ranked and mods_can_get_pp(info.ruleset_id, info.mods) + acronyms = [mod["acronym"] for mod in info.mods] + is_rx = "RX" in acronyms + is_ap = "AP" in acronyms + gamemode = INT_TO_MODE[info.ruleset_id] + if settings.enable_osu_rx and is_rx and gamemode == GameMode.OSU: + gamemode = GameMode.OSURX + elif settings.enable_osu_ap and is_ap and gamemode == GameMode.OSU: + gamemode = GameMode.OSUAP score = Score( accuracy=info.accuracy, max_combo=info.max_combo, @@ -648,7 +664,7 @@ async def process_score( total_score_without_mods=info.total_score_without_mods, beatmap_id=beatmap_id, ended_at=datetime.now(UTC), - gamemode=INT_TO_MODE[info.ruleset_id], + gamemode=gamemode, started_at=score_token.created_at, user_id=user.id, preserve=info.passed, diff --git a/app/models/beatmap.py b/app/models/beatmap.py index fae18ba..d9bdd5c 100644 --- a/app/models/beatmap.py +++ b/app/models/beatmap.py @@ -14,6 +14,20 @@ class BeatmapRankStatus(IntEnum): QUALIFIED = 3 LOVED = 4 + def has_leaderboard(self) -> bool: + return self in { + BeatmapRankStatus.RANKED, + BeatmapRankStatus.APPROVED, + BeatmapRankStatus.QUALIFIED, + BeatmapRankStatus.LOVED, + } + + def has_pp(self) -> bool: + return self in { + BeatmapRankStatus.RANKED, + BeatmapRankStatus.APPROVED, + } + class Genre(IntEnum): ANY = 0 diff --git a/app/models/mods.py b/app/models/mods.py index 95e0f83..ecedf6a 100644 --- a/app/models/mods.py +++ b/app/models/mods.py @@ -4,6 +4,7 @@ from copy import deepcopy import json from typing import Literal, NotRequired, TypedDict +from app.config import settings as app_settings from app.path import STATIC_DIR @@ -155,8 +156,15 @@ for i in range(4, 10): def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool: + if app_settings.enable_all_mods_pp: + return True ranked_mods = RANKED_MODS[ruleset_id] for mod in mods: + if app_settings.enable_osu_rx and mod["acronym"] == "RX" and ruleset_id == 0: + continue + if app_settings.enable_osu_ap and mod["acronym"] == "AP" and ruleset_id == 0: + continue + mod["settings"] = mod.get("settings", {}) if (settings := ranked_mods.get(mod["acronym"])) is None: return False diff --git a/app/models/score.py b/app/models/score.py index 1da72a8..ab968e9 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -16,6 +16,8 @@ class GameMode(str, Enum): TAIKO = "taiko" FRUITS = "fruits" MANIA = "mania" + OSURX = "osurx" + OSUAP = "osuap" def to_rosu(self) -> "rosu.GameMode": import rosu_pp_py as rosu @@ -25,6 +27,8 @@ class GameMode(str, Enum): GameMode.TAIKO: rosu.GameMode.Taiko, GameMode.FRUITS: rosu.GameMode.Catch, GameMode.MANIA: rosu.GameMode.Mania, + GameMode.OSURX: rosu.GameMode.Osu, + GameMode.OSUAP: rosu.GameMode.Osu, }[self] @@ -33,8 +37,11 @@ MODE_TO_INT = { GameMode.TAIKO: 1, GameMode.FRUITS: 2, GameMode.MANIA: 3, + GameMode.OSURX: 0, + GameMode.OSUAP: 0, } INT_TO_MODE = {v: k for k, v in MODE_TO_INT.items()} +INT_TO_MODE[0] = GameMode.OSU class Rank(str, Enum): diff --git a/app/router/auth.py b/app/router/auth.py index 2fa69ad..f5015ab 100644 --- a/app/router/auth.py +++ b/app/router/auth.py @@ -159,14 +159,22 @@ async def register_user( country_code="CN", # 默认国家 join_date=datetime.now(UTC), last_visit=datetime.now(UTC), + is_supporter=settings.enable_supporter_for_all_users, + support_level=int(settings.enable_supporter_for_all_users), ) db.add(new_user) await db.commit() await db.refresh(new_user) assert new_user.id is not None, "New user ID should not be None" - for i in GameMode: + for i in [GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS, GameMode.MANIA]: statistics = UserStatistics(mode=i, user_id=new_user.id) db.add(statistics) + if settings.enable_osu_rx: + statistics_rx = UserStatistics(mode=GameMode.OSURX, user_id=new_user.id) + db.add(statistics_rx) + if settings.enable_osu_ap: + statistics_ap = UserStatistics(mode=GameMode.OSUAP, user_id=new_user.id) + db.add(statistics_ap) daily_challenge_user_stats = DailyChallengeStats(user_id=new_user.id) db.add(daily_challenge_user_stats) await db.commit() diff --git a/app/router/score.py b/app/router/score.py index d826fd0..2ec6da4 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -4,6 +4,7 @@ from datetime import UTC, datetime import time from app.calculator import clamp +from app.config import settings from app.database import ( Beatmap, Playlist, @@ -31,7 +32,6 @@ from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher -from app.models.beatmap import BeatmapRankStatus from app.models.room import RoomCategory from app.models.score import ( INT_TO_MODE, @@ -92,10 +92,9 @@ async def submit_score( db_beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap) except HTTPError: raise HTTPException(status_code=404, detail="Beatmap not found") - ranked = db_beatmap.beatmap_status in { - BeatmapRankStatus.RANKED, - BeatmapRankStatus.APPROVED, - } + ranked = ( + db_beatmap.beatmap_status.has_pp() | settings.enable_all_beatmap_leaderboard + ) score = await process_score( current_user, beatmap, diff --git a/app/service/osu_rx_statistics.py b/app/service/osu_rx_statistics.py new file mode 100644 index 0000000..8b84a78 --- /dev/null +++ b/app/service/osu_rx_statistics.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from app.config import settings +from app.database.lazer_user import User +from app.database.statistics import UserStatistics +from app.dependencies.database import engine +from app.models.score import GameMode + +from sqlalchemy import exists +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def create_rx_statistics(): + async with AsyncSession(engine) as session: + users = (await session.exec(select(User.id))).all() + for i in users: + if settings.enable_osu_rx: + is_exist = ( + await session.exec( + select(exists()).where( + UserStatistics.user_id == i, + UserStatistics.mode == GameMode.OSURX, + ) + ) + ).first() + if not is_exist: + statistics_rx = UserStatistics(mode=GameMode.OSURX, user_id=i) + session.add(statistics_rx) + if settings.enable_osu_ap: + is_exist = ( + await session.exec( + select(exists()).where( + UserStatistics.user_id == i, + UserStatistics.mode == GameMode.OSUAP, + ) + ) + ).first() + if not is_exist: + statistics_ap = UserStatistics(mode=GameMode.OSUAP, user_id=i) + session.add(statistics_ap) + await session.commit() diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index d5a12ff..7764994 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -7,12 +7,12 @@ import struct import time from typing import override +from app.config import settings from app.database import Beatmap, User from app.database.score import Score from app.database.score_token import ScoreToken from app.dependencies.database import engine from app.dependencies.fetcher import get_fetcher -from app.models.beatmap import BeatmapRankStatus from app.models.mods import mods_to_int from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatistics from app.models.spectator_hub import ( @@ -244,7 +244,8 @@ class SpectatorHub(Hub[StoreClientState]): ): return if ( - BeatmapRankStatus.PENDING < store.beatmap_status <= BeatmapRankStatus.LOVED + settings.enable_all_beatmap_leaderboard + and store.beatmap_status.has_leaderboard() ) and any(k.is_hit() and v > 0 for k, v in score.score_info.statistics.items()): await self._process_score(store, client) store.state = None diff --git a/main.py b/main.py index ade04f2..5c88a6b 100644 --- a/main.py +++ b/main.py @@ -14,6 +14,7 @@ from app.router import ( signalr_router, ) from app.service.daily_challenge import daily_challenge_job +from app.service.osu_rx_statistics import create_rx_statistics from fastapi import FastAPI @@ -21,6 +22,7 @@ from fastapi import FastAPI @asynccontextmanager async def lifespan(app: FastAPI): # on startup + await create_rx_statistics() await get_fetcher() # 初始化 fetcher init_scheduler() await daily_challenge_job() diff --git a/migrations/versions/19cdc9ce4dcb_gamemode_add_osurx_osupp.py b/migrations/versions/19cdc9ce4dcb_gamemode_add_osurx_osupp.py new file mode 100644 index 0000000..3cd82c7 --- /dev/null +++ b/migrations/versions/19cdc9ce4dcb_gamemode_add_osurx_osupp.py @@ -0,0 +1,116 @@ +"""gamemode: add osurx & osupp + +Revision ID: 19cdc9ce4dcb +Revises: fdb3822a30ba +Create Date: 2025-08-10 06:10:08.093591 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "19cdc9ce4dcb" +down_revision: str | Sequence[str] | None = "fdb3822a30ba" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "lazer_users", + "playmode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "beatmaps", + "mode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "lazer_user_statistics", + "mode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "score_tokens", + "ruleset_id", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "scores", + "gamemode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "best_scores", + "gamemode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + op.alter_column( + "total_score_best_scores", + "gamemode", + type_=sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "total_score_best_scores", + "gamemode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "best_scores", + "gamemode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "scores", + "gamemode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "score_tokens", + "ruleset_id", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "lazer_user_statistics", + "mode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "beatmaps", + "mode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + op.alter_column( + "lazer_users", + "playmode", + type_=sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", name="gamemode"), + ) + # ### end Alembic commands ### From 8c18c8e51972deac65eb2684e5e4b6301b0ee3ac Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 07:07:33 +0000 Subject: [PATCH 073/104] chore(license): add license --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e05d8a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 GooGuTeam + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 314fbf827b2a69c04712d3e218a3e72dce81f6ff Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 08:28:01 +0000 Subject: [PATCH 074/104] deploy(docker): support deploy with docker --- .env.example | 9 ++- Dockerfile | 66 ++++++++++++--------- Dockerfile-osurx | 39 +++++++++++++ app/config.py | 11 +++- docker-compose-osurx.yml | 77 ++++++++++++++++++++++++ docker-compose.yml | 122 +++++++++++++++++++++++---------------- docker-entrypoint.sh | 13 +++++ migrations/env.py | 8 +-- requirements.txt | 57 ------------------ 9 files changed, 260 insertions(+), 142 deletions(-) create mode 100644 Dockerfile-osurx create mode 100644 docker-compose-osurx.yml create mode 100644 docker-entrypoint.sh delete mode 100644 requirements.txt diff --git a/.env.example b/.env.example index 47b770c..7715368 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ -# 数据库 URL -DATABASE_URL="mysql+aiomysql://root:password@127.0.0.1:3306/osu_api" +# 数据库设置 +MYSQL_HOST="localhost" +MYSQL_PORT=3306 +MYSQL_DATABASE="osu_api" +MYSQL_USER="osu_api" +MYSQL_PASSWORD="password" +MYSQL_ROOT_PASSWORD="password" # Redis URL REDIS_URL="redis://127.0.0.1:6379/0" diff --git a/Dockerfile b/Dockerfile index c1ed5c7..13e59dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,38 @@ -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim - -WORKDIR /app -ENV UV_PROJECT_ENVIRONMENT=syncvenv - -# 安装系统依赖 -RUN apt-get update && apt-get install -y \ - gcc \ - pkg-config \ - default-libmysqlclient-dev \ - && rm -rf /var/lib/apt/lists/* - -# 复制依赖文件 -COPY uv.lock . -COPY pyproject.toml . -COPY requirements.txt . - -# 安装Python依赖 -RUN pip install -r requirements.txt - -# 复制应用代码 -COPY . . - -# 暴露端口 -EXPOSE 8000 - -# 启动命令 -CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + gcc \ + pkg-config \ + default-libmysqlclient-dev \ + curl \ + netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV UV_PROJECT_ENVIRONMENT=/app/.venv + +COPY pyproject.toml uv.lock ./ +COPY packages/ ./packages/ + +RUN uv sync --frozen --no-dev + +RUN uv pip install rosu-pp-py + +COPY . . + +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD ["uv", "run", "--no-sync", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile-osurx b/Dockerfile-osurx new file mode 100644 index 0000000..d30cd09 --- /dev/null +++ b/Dockerfile-osurx @@ -0,0 +1,39 @@ +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + gcc \ + pkg-config \ + default-libmysqlclient-dev \ + curl \ + netcat-openbsd \ + git \ + && rm -rf /var/lib/apt/lists/* + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV UV_PROJECT_ENVIRONMENT=/app/.venv + +COPY pyproject.toml uv.lock ./ +COPY packages/ ./packages/ + +RUN uv sync --frozen --no-dev + +RUN uv pip install git+https://github.com/ppy-sb/rosu-pp-py.git + +COPY . . + +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD ["uv", "run", "--no-sync", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/config.py b/app/config.py index e9ea027..f0df438 100644 --- a/app/config.py +++ b/app/config.py @@ -10,9 +10,18 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") # 数据库设置 - database_url: str = "mysql+aiomysql://root:password@127.0.0.1:3306/osu_api" + mysql_host: str = "localhost" + mysql_port: int = 3306 + mysql_database: str = "osu_api" + mysql_user: str = "osu_api" + mysql_password: str = "password" + mysql_root_password: str = "password" redis_url: str = "redis://127.0.0.1:6379/0" + @property + def database_url(self) -> str: + return f"mysql+aiomysql://{self.mysql_user}:{self.mysql_password}@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}" + # JWT 设置 secret_key: str = Field(default="your-secret-key-here", alias="jwt_secret_key") algorithm: str = "HS256" diff --git a/docker-compose-osurx.yml b/docker-compose-osurx.yml new file mode 100644 index 0000000..159b7da --- /dev/null +++ b/docker-compose-osurx.yml @@ -0,0 +1,77 @@ +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile-osurx + container_name: osu_api_server_osurx + ports: + - "8000:8000" + environment: + - MYSQL_HOST=mysql + - MYSQL_PORT=3306 + - REDIS_URL=redis://redis:6379/0 + - ENABLE_OSU_RX=true + - ENABLE_OSU_AP=true + - ENABLE_ALL_MODS_PP=true + - ENABLE_SUPPORTER_FOR_ALL_USERS=true + - ENABLE_ALL_BEATMAP_LEADERBOARD=true + env_file: + - .env + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./replays:/app/replays + - ./static:/app/static + restart: unless-stopped + networks: + - osu-network + + mysql: + image: mysql:8.0 + container_name: osu_api_mysql_osurx + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + - ./mysql-init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 10 + interval: 10s + start_period: 40s + restart: unless-stopped + networks: + - osu-network + + redis: + image: redis:7-alpine + container_name: osu_api_redis_osurx + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + timeout: 5s + retries: 5 + interval: 10s + start_period: 10s + restart: unless-stopped + networks: + - osu-network + command: redis-server --appendonly yes + +volumes: + mysql_data: + redis_data: + +networks: + osu-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 8c109c5..9a6f2cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,50 +1,72 @@ -version: '3.8' - -services: - mysql: - image: mysql:8.0 - container_name: osu_api_mysql - environment: - MYSQL_ROOT_PASSWORD: password - MYSQL_DATABASE: osu_api - MYSQL_USER: osu_user - MYSQL_PASSWORD: osu_password - ports: - - "3306:3306" - volumes: - - mysql_data:/var/lib/mysql - - ./mysql-init:/docker-entrypoint-initdb.d - restart: unless-stopped - - redis: - image: redis:7-alpine - container_name: osu_api_redis - ports: - - "6379:6379" - volumes: - - redis_data:/data - restart: unless-stopped - command: redis-server --appendonly yes - - api: - build: . - container_name: osu_api_server - ports: - - "8000:8000" - environment: - DATABASE_URL: mysql+aiomysql://osu_user:osu_password@mysql:3306/osu_api - REDIS_URL: redis://redis:6379/0 - SECRET_KEY: your-production-secret-key-here - OSU_CLIENT_ID: "5" - OSU_CLIENT_SECRET: "FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" - depends_on: - - mysql - - redis - restart: unless-stopped - volumes: - - ./:/app - command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload - -volumes: - mysql_data: - redis_data: +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: osu_api_server + ports: + - "8000:8000" + environment: + - MYSQL_HOST=mysql + - MYSQL_PORT=3306 + - REDIS_URL=redis://redis:6379/0 + env_file: + - .env + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./replays:/app/replays + - ./static:/app/static + restart: unless-stopped + networks: + - osu-network + + mysql: + image: mysql:8.0 + container_name: osu_api_mysql + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + - ./mysql-init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 10 + interval: 10s + start_period: 40s + restart: unless-stopped + networks: + - osu-network + + redis: + image: redis:7-alpine + container_name: osu_api_redis + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + timeout: 5s + retries: 5 + interval: 10s + start_period: 10s + restart: unless-stopped + networks: + - osu-network + command: redis-server --appendonly yes + +volumes: + mysql_data: + redis_data: + +networks: + osu-network: + driver: bridge diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..5c51a4b --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +echo "Waiting for database connection..." +while ! nc -z $MYSQL_HOST $MYSQL_PORT; do + sleep 1 +done +echo "Database connected" + +echo "Running alembic..." +uv run --no-sync alembic upgrade head + +exec "$@" diff --git a/migrations/env.py b/migrations/env.py index b8c5c5e..825cde6 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -2,8 +2,8 @@ from __future__ import annotations import asyncio from logging.config import fileConfig -import os +from app.config import settings from app.database import * # noqa: F403 from alembic import context @@ -45,7 +45,8 @@ def run_migrations_offline() -> None: script output. """ - url = os.environ.get("DATABASE_URL", config.get_main_option("sqlalchemy.url")) + url = settings.database_url + print(url) context.configure( url=url, target_metadata=target_metadata, @@ -73,8 +74,7 @@ async def run_async_migrations() -> None: """ sa_config = config.get_section(config.config_ini_section, {}) - if db_url := os.environ.get("DATABASE_URL"): - sa_config["sqlalchemy.url"] = db_url + sa_config["sqlalchemy.url"] = settings.database_url connectable = async_engine_from_config( sa_config, prefix="sqlalchemy.", diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c0fd664..0000000 --- a/requirements.txt +++ /dev/null @@ -1,57 +0,0 @@ -aiomysql==0.2.0 -alembic==1.16.4 -annotated-types==0.7.0 -anyio==4.9.0 -bcrypt==4.3.0 -certifi==2025.7.14 -cffi==1.17.1 -cfgv==3.4.0 -click==8.2.1 -cryptography==45.0.5 -distlib==0.4.0 -dnspython==2.7.0 -ecdsa==0.19.1 -email-validator==2.2.0 -fastapi==0.116.1 -filelock==3.18.0 -greenlet==3.2.3 -h11==0.16.0 -httpcore==1.0.9 -httptools==0.6.4 -httpx==0.28.1 -identify==2.6.12 -idna==3.10 -loguru==0.7.3 -mako==1.3.10 -markupsafe==3.0.2 -maturin==1.9.2 --e file:///workspaces/osu_lazer_api/packages/msgpack_lazer_api -nodeenv==1.9.1 -passlib==1.7.4 -platformdirs==4.3.8 -pre-commit==4.2.0 -pyasn1==0.6.1 -pycparser==2.22 -pydantic==2.11.7 -pydantic-core==2.33.2 -pymysql==1.1.1 -python-dotenv==1.1.1 -python-jose==3.5.0 -python-multipart==0.0.20 -pyyaml==6.0.2 -redis==6.2.0 -rosu-pp-py==3.1.0 -rsa==4.9.1 -ruff==0.12.4 -six==1.17.0 -sniffio==1.3.1 -sqlalchemy==2.0.41 -sqlmodel==0.0.24 -starlette==0.47.2 -typing-extensions==4.14.1 -typing-inspection==0.4.1 -uvicorn==0.35.0 -uvloop==0.21.0 -virtualenv==20.32.0 -watchfiles==1.1.0 -websockets==15.0.1 From 9778f93da4f00f01d443ff685c96a53600f83622 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 08:41:10 +0000 Subject: [PATCH 075/104] chore(dev): update devcontainer --- .devcontainer/devcontainer.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8c11285..c85ab18 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,6 +4,13 @@ "service": "devcontainer", "shutdownAction": "stopCompose", "workspaceFolder": "/workspaces/osu_lazer_api", + "containerEnv": { + "MYSQL_DATABASE": "osu_api", + "MYSQL_USER": "osu_user", + "MYSQL_PASSWORD": "osu_password", + "MYSQL_HOST": "mysql", + "MYSQL_PORT": "3306" + }, "customizations": { "vscode": { "extensions": [ @@ -66,6 +73,6 @@ 3306, 6379 ], - "postCreateCommand": "uv sync --dev && uv run pre-commit install && cd packages/msgpack_lazer_api && cargo check", + "postCreateCommand": "uv sync --dev && uv pip install rosu-pp-py && uv run alembic upgrade head && uv run pre-commit install && cd packages/msgpack_lazer_api && cargo check", "remoteUser": "vscode" -} +} \ No newline at end of file From 2c872785f63071682293c182c5f2c85cdf880309 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 08:52:40 +0000 Subject: [PATCH 076/104] docs(readme): update readme --- README.md | 338 +++++++++++++++++++----------------------------------- 1 file changed, 120 insertions(+), 218 deletions(-) diff --git a/README.md b/README.md index 267e2b5..bcdd149 100644 --- a/README.md +++ b/README.md @@ -1,218 +1,120 @@ -# osu! API 模拟服务器 - -这是一个使用 FastAPI + MySQL + Redis 实现的 osu! API 模拟服务器,提供了完整的用户认证和数据管理功能。 - -## 功能特性 - -- **OAuth 2.0 认证**: 支持密码流和刷新令牌流 -- **用户数据管理**: 完整的用户信息、统计数据、成就等 -- **多游戏模式支持**: osu!, taiko, fruits, mania -- **数据库持久化**: MySQL 存储用户数据 -- **缓存支持**: Redis 缓存令牌和会话信息 -- **容器化部署**: Docker 和 Docker Compose 支持 - -## API 端点 - -### 认证端点 -- `POST /oauth/token` - OAuth 令牌获取/刷新 - -### 用户端点 -- `GET /api/v2/me/{ruleset}` - 获取当前用户信息 - -### 其他端点 -- `GET /` - 根端点 -- `GET /health` - 健康检查 - -## 快速开始 - -### 使用 Docker Compose (推荐) - -1. 克隆项目 -```bash -git clone -cd osu_lazer_api -``` - -2. 启动服务 -```bash -docker-compose up -d -``` - -3. 创建示例数据 -```bash -docker-compose exec api python create_sample_data.py -``` - -4. 测试 API -```bash -# 获取访问令牌 -curl -X POST http://localhost:8000/oauth/token \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password&username=Googujiang&password=password123&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk&scope=*" - -# 使用令牌获取用户信息 -curl -X GET http://localhost:8000/api/v2/me/osu \ - -H "Authorization: Bearer YOUR_ACCESS_TOKEN" -``` - -### 本地开发 - -1. 安装依赖 -```bash -pip install -r requirements.txt -``` - -2. 配置环境变量 -```bash -# 复制服务器配置文件 -cp .env .env.local - -# 复制客户端配置文件(用于测试脚本) -cp .env.client .env.client.local -``` - -3. 启动 MySQL 和 Redis -```bash -# 使用 Docker -docker run -d --name mysql -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=osu_api -p 3306:3306 mysql:8.0 -docker run -d --name redis -p 6379:6379 redis:7-alpine -``` - -4. 创建示例数据 -```bash -python create_sample_data.py -``` - -5. 启动应用 -```bash -uvicorn main:app --reload -``` - -6. 测试 API -```bash -# 使用测试脚本(会自动加载 .env 文件) -python test_api.py - -# 或使用原始示例脚本 -python osu_api_example.py -``` - -## 项目结构 - -``` -osu_lazer_api/ -├── app/ -│ ├── __init__.py -│ ├── models.py # Pydantic 数据模型 -│ ├── database.py # SQLAlchemy 数据库模型 -│ ├── config.py # 配置设置 -│ ├── dependencies.py # 依赖注入 -│ ├── auth.py # 认证和令牌管理 -│ └── utils.py # 工具函数 -├── main.py # FastAPI 应用主文件 -├── create_sample_data.py # 示例数据创建脚本 -├── requirements.txt # Python 依赖 -├── .env # 环境变量配置 -├── docker-compose.yml # Docker Compose 配置 -├── Dockerfile # Docker 镜像配置 -└── README.md # 项目说明 -``` - -## 示例用户 - -创建示例数据后,您可以使用以下凭据进行测试: - -- **用户名**: `Googujiang` -- **密码**: `password123` -- **用户ID**: `15651670` - -## 环境变量配置 - -项目包含两个环境配置文件: - -### 服务器配置 (`.env`) -用于配置 FastAPI 服务器的运行参数: - -| 变量名 | 描述 | 默认值 | -|--------|------|--------| -| `DATABASE_URL` | MySQL 数据库连接字符串 | `mysql+pymysql://root:password@localhost:3306/osu_api` | -| `REDIS_URL` | Redis 连接字符串 | `redis://localhost:6379/0` | -| `SECRET_KEY` | JWT 签名密钥 | `your-secret-key-here` | -| `ACCESS_TOKEN_EXPIRE_MINUTES` | 访问令牌过期时间(分钟) | `1440` | -| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` | -| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` | -| `HOST` | 服务器监听地址 | `0.0.0.0` | -| `PORT` | 服务器监听端口 | `8000` | -| `DEBUG` | 调试模式 | `True` | - -### 客户端配置 (`.env.client`) -用于配置客户端脚本的 API 连接参数: - -| 变量名 | 描述 | 默认值 | -|--------|------|--------| -| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` | -| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` | -| `OSU_API_URL` | API 服务器地址 | `http://localhost:8000` | - -> **注意**: 在生产环境中,请务必更改默认的密钥和密码! - -## API 使用示例 - -### 获取访问令牌 - -```bash -curl -X POST http://localhost:8000/oauth/token \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password&username=Googujiang&password=password123&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk&scope=*" -``` - -响应: -```json -{ - "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", - "token_type": "Bearer", - "expires_in": 86400, - "refresh_token": "abc123...", - "scope": "*" -} -``` - -### 获取用户信息 - -```bash -curl -X GET http://localhost:8000/api/v2/me/osu \ - -H "Authorization: Bearer YOUR_ACCESS_TOKEN" -``` - -### 刷新令牌 - -```bash -curl -X POST http://localhost:8000/oauth/token \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" -``` - -## 开发 - -### 添加新用户 - -您可以通过修改 `create_sample_data.py` 文件来添加更多示例用户,或者扩展 API 来支持用户注册功能。 - -### 扩展功能 - -- 添加更多 API 端点(排行榜、谱面信息等) -- 实现实时功能(WebSocket) -- 添加管理面板 -- 实现数据导入/导出功能 - -### 迁移数据库 - -参考[数据库迁移指南](./MIGRATE_GUIDE.md) - -## 许可证 - -MIT License - -## 贡献 - -欢迎提交 Issue 和 Pull Request! +# osu! API 模拟服务器 + +这是一个使用 FastAPI + MySQL + Redis 实现的 osu! API 模拟服务器,提供了完整的用户认证和数据管理功能。 + +## 功能特性 + +- **OAuth 2.0 认证**: 支持密码流和刷新令牌流 +- **用户数据管理**: 完整的用户信息、统计数据、成就等 +- **多游戏模式支持**: osu! (osu!rx, osu!ap), taiko, fruits, mania +- **数据库持久化**: MySQL 存储用户数据 +- **缓存支持**: Redis 缓存令牌和会话信息 +- **容器化部署**: Docker 和 Docker Compose 支持 + +## 快速开始 + +### 使用 Docker Compose (推荐) + +1. 克隆项目 +```bash +git clone https://github.com/GooGuTeam/osu_lazer_api.git +cd osu_lazer_api +``` + +2. 创建 `.env` 文件 + +请参考下方的服务器配置修改 .env 文件 + +```bash +cp .env.example .env +``` + +3. 启动服务 +```bash +# 标准服务器 +docker-compose -f docker-compose.yml up -d +# 启用 osu!RX 和 osu!AP 模式 (偏偏要上班 pp 算法) +docker-compose -f docker-compose-osurx.yml up -d +``` + +4. 通过游戏连接服务器 + +使用[自定义的 osu!lazer 客户端](https://github.com/GooGuTeam/osu),或者使用 [LazerAuthlibInjection](https://github.com/MingxuanGame/LazerAuthlibInjection),修改服务器设置为服务器的 IP + +## 环境变量配置 + +### 数据库设置 +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| `MYSQL_HOST` | MySQL 主机地址 | `localhost` | +| `MYSQL_PORT` | MySQL 端口 | `3306` | +| `MYSQL_DATABASE` | MySQL 数据库名 | `osu_api` | +| `MYSQL_USER` | MySQL 用户名 | `osu_api` | +| `MYSQL_PASSWORD` | MySQL 密码 | `password` | +| `MYSQL_ROOT_PASSWORD` | MySQL root 密码 | `password` | +| `REDIS_URL` | Redis 连接字符串 | `redis://127.0.0.1:6379/0` | + +### JWT 设置 +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| `JWT_SECRET_KEY` | JWT 签名密钥 | `your_jwt_secret_here` | +| `ALGORITHM` | JWT 算法 | `HS256` | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | 访问令牌过期时间(分钟) | `1440` | + +### 服务器设置 +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| `HOST` | 服务器监听地址 | `0.0.0.0` | +| `PORT` | 服务器监听端口 | `8000` | +| `DEBUG` | 调试模式 | `false` | + +### OAuth 设置 +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` | +| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` | + +### SignalR 服务器设置 +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| `SIGNALR_NEGOTIATE_TIMEOUT` | SignalR 协商超时时间(秒) | `30` | +| `SIGNALR_PING_INTERVAL` | SignalR ping 间隔(秒) | `15` | + +### Fetcher 设置 + +Fetcher 用于从 osu! 官方 API 获取数据,使用 osu! 官方 API 的 OAuth 2.0 认证 + +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| `FETCHER_CLIENT_ID` | Fetcher 客户端 ID | `""` | +| `FETCHER_CLIENT_SECRET` | Fetcher 客户端密钥 | `""` | +| `FETCHER_SCOPES` | Fetcher 权限范围 | `public` | +| `FETCHER_CALLBACK_URL` | Fetcher 回调 URL | `http://localhost:8000/fetcher/callback` | + +### 日志设置 +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| `LOG_LEVEL` | 日志级别 | `INFO` | + +### 游戏设置 +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| `ENABLE_OSU_RX` | 启用 osu!RX 统计数据 | `false` | +| `ENABLE_OSU_AP` | 启用 osu!AP 统计数据 | `false` | +| `ENABLE_ALL_MODS_PP` | 启用所有 Mod 的 PP 计算 | `false` | +| `ENABLE_SUPPORTER_FOR_ALL_USERS` | 启用所有新注册用户的支持者状态 | `false` | +| `ENABLE_ALL_BEATMAP_LEADERBOARD` | 启用所有谱面的排行榜 | `false` | + +> **注意**: 在生产环境中,请务必更改默认的密钥和密码! + +### 更新数据库 + +参考[数据库迁移指南](./MIGRATE_GUIDE.md) + +## 许可证 + +MIT License + +## 贡献 + +欢迎提交 Issue 和 Pull Request! From 30725422f4627c39485b153d8d443cc20c2e575d Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 11:03:20 +0000 Subject: [PATCH 077/104] deploy(docker): optimize size --- .dockerignore | 4 ++++ Dockerfile | 34 ++++++++++++++++++++++------------ Dockerfile-osurx | 35 ++++++++++++++++++++++------------- docker-compose-osurx.yml | 2 ++ docker-compose.yml | 2 ++ 5 files changed, 52 insertions(+), 25 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9a41bcf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.venv/ +.ruff_cache/ +.vscode/ +replays/ diff --git a/Dockerfile b/Dockerfile index 13e59dd..3e9823a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,13 @@ -FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim - +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder WORKDIR /app -RUN apt-get update && apt-get install -y \ - gcc \ - pkg-config \ - default-libmysqlclient-dev \ - curl \ - netcat-openbsd \ - && rm -rf /var/lib/apt/lists/* +RUN apt-get update \ + && apt-get install -y gcc pkg-config default-libmysqlclient-dev \ + && rm -rf /var/lib/apt/lists/* \ + && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -ENV PATH="/root/.cargo/bin:${PATH}" +ENV PATH="/root/.cargo/bin:${PATH}" \ + PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 UV_PROJECT_ENVIRONMENT=/app/.venv ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 @@ -21,11 +17,25 @@ COPY pyproject.toml uv.lock ./ COPY packages/ ./packages/ RUN uv sync --frozen --no-dev - RUN uv pip install rosu-pp-py COPY . . +# --- + +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y curl netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/app/.venv/bin:${PATH}" \ + PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 + +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app /app + COPY docker-entrypoint.sh /app/docker-entrypoint.sh RUN chmod +x /app/docker-entrypoint.sh diff --git a/Dockerfile-osurx b/Dockerfile-osurx index d30cd09..f64272f 100644 --- a/Dockerfile-osurx +++ b/Dockerfile-osurx @@ -1,18 +1,13 @@ -FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim - +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder WORKDIR /app -RUN apt-get update && apt-get install -y \ - gcc \ - pkg-config \ - default-libmysqlclient-dev \ - curl \ - netcat-openbsd \ - git \ - && rm -rf /var/lib/apt/lists/* +RUN apt-get update \ + && apt-get install -y gcc pkg-config default-libmysqlclient-dev git \ + && rm -rf /var/lib/apt/lists/* \ + && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -ENV PATH="/root/.cargo/bin:${PATH}" +ENV PATH="/root/.cargo/bin:${PATH}" \ + PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 UV_PROJECT_ENVIRONMENT=/app/.venv ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 @@ -22,11 +17,25 @@ COPY pyproject.toml uv.lock ./ COPY packages/ ./packages/ RUN uv sync --frozen --no-dev - RUN uv pip install git+https://github.com/ppy-sb/rosu-pp-py.git COPY . . +# --- + +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y curl netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/app/.venv/bin:${PATH}" \ + PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 + +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app /app + COPY docker-entrypoint.sh /app/docker-entrypoint.sh RUN chmod +x /app/docker-entrypoint.sh diff --git a/docker-compose-osurx.yml b/docker-compose-osurx.yml index 159b7da..e2f5bbc 100644 --- a/docker-compose-osurx.yml +++ b/docker-compose-osurx.yml @@ -2,6 +2,8 @@ version: '3.8' services: app: + # or use + # image: mingxuangame/osu-lazer-api-osurx:latest build: context: . dockerfile: Dockerfile-osurx diff --git a/docker-compose.yml b/docker-compose.yml index 9a6f2cb..8e2e181 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,8 @@ version: '3.8' services: app: + # or use + # image: mingxuangame/osu-lazer-api:latest build: context: . dockerfile: Dockerfile From f60c451c966c1774235dcd84ad4c0b738048b21d Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 11:31:18 +0000 Subject: [PATCH 078/104] fix(relationship): 500 when adding friends --- app/router/relationship.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/router/relationship.py b/app/router/relationship.py index 0832d09..02292c9 100644 --- a/app/router/relationship.py +++ b/app/router/relationship.py @@ -96,7 +96,9 @@ async def add_relationship( ) ).first() assert relationship, "Relationship should exist after commit" - return await RelationshipResp.from_db(db, relationship) + return AddFriendResp( + user_relation=await RelationshipResp.from_db(db, relationship) + ) @router.delete("/friends/{target}", tags=["relationship"]) From 02051289e973e4cbe36a91dcc7828b770e6869fc Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 11:35:41 +0000 Subject: [PATCH 079/104] fix(statistics): extra pp from other modes --- app/database/score.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/database/score.py b/app/database/score.py index ea1ccf2..6b418bb 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -485,12 +485,13 @@ async def get_user_best_pp_in_beatmap( async def get_user_best_pp( session: AsyncSession, user: int, + mode: GameMode, limit: int = 200, ) -> Sequence[PPBestScore]: return ( await session.exec( select(PPBestScore) - .where(PPBestScore.user_id == user) + .where(PPBestScore.user_id == user, PPBestScore.gamemode == mode) .order_by(col(PPBestScore.pp).desc()) .limit(limit) ) @@ -612,7 +613,7 @@ async def process_user( ) if score.passed and ranked: - best_pp_scores = await get_user_best_pp(session, user.id) + best_pp_scores = await get_user_best_pp(session, user.id, score.gamemode) pp_sum = 0.0 acc_sum = 0.0 for i, bp in enumerate(best_pp_scores): From f6c375caf35cc59c9449b979ec3c97365d82f27e Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 11:44:09 +0000 Subject: [PATCH 080/104] feat(fetcher): refresh access_token automatically --- app/fetcher/_base.py | 16 ++++++++++++++++ app/fetcher/beatmap.py | 11 ++++------- app/fetcher/beatmapset.py | 13 +++++-------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/app/fetcher/_base.py b/app/fetcher/_base.py index 2717a35..7e7e35b 100644 --- a/app/fetcher/_base.py +++ b/app/fetcher/_base.py @@ -38,6 +38,22 @@ class BaseFetcher: "Content-Type": "application/json", } + async def request_api(self, url: str, method: str = "GET", **kwargs) -> dict: + if self.is_token_expired(): + await self.refresh_access_token() + header = kwargs.pop("headers", {}) + header = self.header + + async with AsyncClient() as client: + response = await client.request( + method, + url, + headers=header, + **kwargs, + ) + response.raise_for_status() + return response.json() + def is_token_expired(self) -> bool: return self.token_expiry <= int(time.time()) diff --git a/app/fetcher/beatmap.py b/app/fetcher/beatmap.py index c05ad62..cf68fbe 100644 --- a/app/fetcher/beatmap.py +++ b/app/fetcher/beatmap.py @@ -5,8 +5,6 @@ from app.log import logger from ._base import BaseFetcher -from httpx import AsyncClient - class BeatmapFetcher(BaseFetcher): async def get_beatmap( @@ -21,11 +19,10 @@ class BeatmapFetcher(BaseFetcher): logger.opt(colors=True).debug( f"[BeatmapFetcher] get_beatmap: {params}" ) - async with AsyncClient() as client: - response = await client.get( + + return BeatmapResp.model_validate( + await self.request_api( "https://osu.ppy.sh/api/v2/beatmaps/lookup", - headers=self.header, params=params, ) - response.raise_for_status() - return BeatmapResp.model_validate(response.json()) + ) diff --git a/app/fetcher/beatmapset.py b/app/fetcher/beatmapset.py index 048fef0..8062825 100644 --- a/app/fetcher/beatmapset.py +++ b/app/fetcher/beatmapset.py @@ -5,18 +5,15 @@ from app.log import logger from ._base import BaseFetcher -from httpx import AsyncClient - class BeatmapsetFetcher(BaseFetcher): async def get_beatmapset(self, beatmap_set_id: int) -> BeatmapsetResp: logger.opt(colors=True).debug( f"[BeatmapsetFetcher] get_beatmapset: {beatmap_set_id}" ) - async with AsyncClient() as client: - response = await client.get( - f"https://osu.ppy.sh/api/v2/beatmapsets/{beatmap_set_id}", - headers=self.header, + + return BeatmapsetResp.model_validate( + await self.request_api( + f"https://osu.ppy.sh/api/v2/beatmapsets/{beatmap_set_id}" ) - response.raise_for_status() - return BeatmapsetResp.model_validate(response.json()) + ) From b12782abe036b2d21de36e1a63ec22e58f428689 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 13:15:56 +0000 Subject: [PATCH 081/104] fix(beatmapset): `legacy_thread_url` is nullable --- app/database/beatmapset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/database/beatmapset.py b/app/database/beatmapset.py index 7ead18f..aee26e4 100644 --- a/app/database/beatmapset.py +++ b/app/database/beatmapset.py @@ -183,7 +183,7 @@ class BeatmapsetResp(BeatmapsetBase): discussion_enabled: bool = True status: str ranked: int - legacy_thread_url: str = "" + legacy_thread_url: str | None = "" is_scoreable: bool hype: BeatmapHype | None = None availability: BeatmapAvailability From 18914968329f8e2dfcba33aabfb5f2001ae17ca6 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 13:31:08 +0000 Subject: [PATCH 082/104] fix(beatmap,playlist): missing greetlet --- app/database/beatmap.py | 2 +- app/database/playlist_attempts.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/database/beatmap.py b/app/database/beatmap.py index d3ba940..e6f82e5 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -157,8 +157,8 @@ class BeatmapResp(BeatmapBase): ) -> "BeatmapResp": from .score import Score - beatmap_status = beatmap.beatmap_status beatmap_ = beatmap.model_dump() + beatmap_status = beatmap_["beatmap_status"] if query_mode is not None and beatmap.mode != query_mode: beatmap_["convert"] = True beatmap_["is_scoreable"] = beatmap_status.has_leaderboard() diff --git a/app/database/playlist_attempts.py b/app/database/playlist_attempts.py index 5d580cf..e628008 100644 --- a/app/database/playlist_attempts.py +++ b/app/database/playlist_attempts.py @@ -2,6 +2,7 @@ from .lazer_user import User, UserResp from .playlist_best_score import PlaylistBestScore from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncAttrs from sqlmodel import ( BigInteger, Column, @@ -28,7 +29,7 @@ class ItemAttemptsCountBase(SQLModel): total_score: int = 0 -class ItemAttemptsCount(ItemAttemptsCountBase, table=True): +class ItemAttemptsCount(AsyncAttrs, ItemAttemptsCountBase, table=True): __tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType] id: int | None = Field(default=None, primary_key=True) @@ -105,7 +106,7 @@ class ItemAttemptsResp(ItemAttemptsCountBase): ) -> "ItemAttemptsResp": resp = cls.model_validate(item_attempts.model_dump()) resp.user = await UserResp.from_db( - item_attempts.user, + await item_attempts.awaitable_attrs.user, session=session, include=["statistics", "team", "daily_challenge_user_stats"], ) From fac16bde86bd88395dd7f5e727e6b5bb01ae03e6 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 13:49:30 +0000 Subject: [PATCH 083/104] fix(beatmapset): avoid duplicated beatmapset --- app/database/beatmapset.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/database/beatmapset.py b/app/database/beatmapset.py index aee26e4..1ed5d13 100644 --- a/app/database/beatmapset.py +++ b/app/database/beatmapset.py @@ -10,7 +10,7 @@ from .lazer_user import BASE_INCLUDES, User, UserResp from pydantic import BaseModel from sqlalchemy import JSON, Column, DateTime, Text from sqlalchemy.ext.asyncio import AsyncAttrs -from sqlmodel import Field, Relationship, SQLModel, col, func, select +from sqlmodel import Field, Relationship, SQLModel, col, exists, func, select from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: @@ -161,8 +161,11 @@ class Beatmapset(AsyncAttrs, BeatmapsetBase, table=True): "download_disabled": resp.availability.download_disabled or False, } ) - session.add(beatmapset) - await session.commit() + if not ( + await session.exec(select(exists()).where(Beatmapset.id == resp.id)) + ).first(): + session.add(beatmapset) + await session.commit() await Beatmap.from_resp_batch(session, resp.beatmaps, from_=from_) return beatmapset From a087b0de2ef36e6fbbfb047e28a4df81808e250a Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 15:13:33 +0000 Subject: [PATCH 084/104] fix(lounge): fix KeyError for ended realtime room --- app/router/room.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/router/room.py b/app/router/room.py index 6918364..eaee8d0 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -80,8 +80,11 @@ async def get_all_rooms( for room in db_rooms: resp = await RoomResp.from_db(room, db) if category == RoomCategory.REALTIME: - resp.has_password = bool( - MultiplayerHubs.rooms[room.id].room.settings.password.strip() + mp_room = MultiplayerHubs.rooms.get(room.id) + resp.has_password = ( + bool(mp_room.room.settings.password.strip()) + if mp_room is not None + else False ) resp.category = RoomCategory.NORMAL resp_list.append(resp) From 32e2ac5704bb250ab9ae316c1f82384b97b494b7 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 15:36:39 +0000 Subject: [PATCH 085/104] feat(score): support pin score --- app/database/score.py | 12 +- app/models/model.py | 33 ++++ app/router/score.py | 159 +++++++++++++++++- app/router/user.py | 54 +++++- .../319e5f841dcf_score_support_pin_score.py | 34 ++++ 5 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 migrations/versions/319e5f841dcf_score_support_pin_score.py diff --git a/app/database/score.py b/app/database/score.py index 6b418bb..ec790a2 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -15,7 +15,12 @@ from app.calculator import ( ) from app.config import settings from app.database.team import TeamMember -from app.models.model import RespWithCursor, UTCBaseModel +from app.models.model import ( + CurrentUserAttributes, + PinAttributes, + RespWithCursor, + UTCBaseModel, +) from app.models.mods import APIMod, mods_can_get_pp from app.models.score import ( INT_TO_MODE, @@ -122,6 +127,7 @@ class Score(ScoreBase, table=True): nslider_tail_hit: int | None = Field(default=None, exclude=True) nsmall_tick_hit: int | None = Field(default=None, exclude=True) gamemode: GameMode = Field(index=True) + pinned_order: int = Field(default=0, exclude=True) # optional beatmap: Beatmap = Relationship() @@ -166,6 +172,7 @@ class ScoreResp(ScoreBase): rank_country: int | None = None position: int | None = None scores_around: "ScoreAround | None" = None + current_user_attributes: CurrentUserAttributes | None = None @classmethod async def from_db(cls, session: AsyncSession, score: Score) -> "ScoreResp": @@ -234,6 +241,9 @@ class ScoreResp(ScoreBase): ) or None ) + s.current_user_attributes = CurrentUserAttributes( + pin=PinAttributes(is_pinned=bool(score.pinned_order), score_id=score.id) + ) return s diff --git a/app/models/model.py b/app/models/model.py index 5ba8093..34d4902 100644 --- a/app/models/model.py +++ b/app/models/model.py @@ -2,6 +2,8 @@ from __future__ import annotations from datetime import UTC, datetime +from app.models.score import GameMode + from pydantic import BaseModel, field_serializer @@ -20,3 +22,34 @@ Cursor = dict[str, int] class RespWithCursor(BaseModel): cursor: Cursor | None = None + + +class PinAttributes(BaseModel): + is_pinned: bool + score_id: int + + +class CurrentUserAttributes(BaseModel): + can_beatmap_update_owner: bool | None = None + can_delete: bool | None = None + can_edit_metadata: bool | None = None + can_edit_tags: bool | None = None + can_hype: bool | None = None + can_hype_reason: str | None = None + can_love: bool | None = None + can_remove_from_loved: bool | None = None + is_watching: bool | None = None + new_hype_time: datetime | None = None + nomination_modes: list[GameMode] | None = None + remaining_hype: int | None = None + can_destroy: bool | None = None + can_reopen: bool | None = None + can_moderate_kudosu: bool | None = None + can_resolve: bool | None = None + vote_score: int | None = None + can_message: bool | None = None + can_message_error: str | None = None + last_read_id: int | None = None + can_new_comment: bool | None = None + can_new_comment_reason: str | None = None + pin: PinAttributes | None = None diff --git a/app/router/score.py b/app/router/score.py index 2ec6da4..dbd82b1 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -43,12 +43,12 @@ from app.models.score import ( from .api_router import router -from fastapi import Depends, Form, HTTPException, Query +from fastapi import Body, Depends, Form, HTTPException, Query from httpx import HTTPError from pydantic import BaseModel from redis.asyncio import Redis from sqlalchemy.orm import joinedload -from sqlmodel import col, select +from sqlmodel import col, func, select from sqlmodel.ext.asyncio.session import AsyncSession READ_SCORE_TIMEOUT = 10 @@ -548,3 +548,158 @@ async def get_user_playlist_score( room_id, playlist_id, score_record.score_id, session ) return resp + + +@router.put("/score-pins/{score}", status_code=204) +async def pin_score( + score: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + score_record = ( + await db.exec( + select(Score).where( + Score.id == score, + Score.user_id == current_user.id, + col(Score.passed).is_(True), + ) + ) + ).first() + if not score_record: + raise HTTPException(status_code=404, detail="Score not found") + + if score_record.pinned_order > 0: + return + + next_order = ( + ( + await db.exec( + select(func.max(Score.pinned_order)).where( + Score.user_id == current_user.id, + Score.gamemode == score_record.gamemode, + ) + ) + ).first() + or 0 + ) + 1 + score_record.pinned_order = next_order + await db.commit() + + +@router.delete("/score-pins/{score}", status_code=204) +async def unpin_score( + score: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + score_record = ( + await db.exec( + select(Score).where(Score.id == score, Score.user_id == current_user.id) + ) + ).first() + if not score_record: + raise HTTPException(status_code=404, detail="Score not found") + + if score_record.pinned_order == 0: + return + changed_score = ( + await db.exec( + select(Score).where( + Score.user_id == current_user.id, + Score.pinned_order > score_record.pinned_order, + Score.gamemode == score_record.gamemode, + ) + ) + ).all() + for s in changed_score: + s.pinned_order -= 1 + await db.commit() + + +@router.post("/score-pins/{score}/reorder", status_code=204) +async def reorder_score_pin( + score: int, + after_score_id: int | None = Body(default=None), + before_score_id: int | None = Body(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + score_record = ( + await db.exec( + select(Score).where(Score.id == score, Score.user_id == current_user.id) + ) + ).first() + if not score_record: + raise HTTPException(status_code=404, detail="Score not found") + + if score_record.pinned_order == 0: + raise HTTPException(status_code=400, detail="Score is not pinned") + + if (after_score_id is None) == (before_score_id is None): + raise HTTPException( + status_code=400, + detail="Either after_score_id or before_score_id " + "must be provided (but not both)", + ) + + all_pinned_scores = ( + await db.exec( + select(Score) + .where( + Score.user_id == current_user.id, + Score.pinned_order > 0, + Score.gamemode == score_record.gamemode, + ) + .order_by(col(Score.pinned_order)) + ) + ).all() + + target_order = None + reference_score_id = after_score_id or before_score_id + + reference_score = next( + (s for s in all_pinned_scores if s.id == reference_score_id), None + ) + if not reference_score: + detail = "After score not found" if after_score_id else "Before score not found" + raise HTTPException(status_code=404, detail=detail) + + if after_score_id: + target_order = reference_score.pinned_order + 1 + else: + target_order = reference_score.pinned_order + + current_order = score_record.pinned_order + + if current_order == target_order: + return + + updates = [] + + if current_order < target_order: + for s in all_pinned_scores: + if current_order < s.pinned_order <= target_order and s.id != score: + updates.append((s.id, s.pinned_order - 1)) + if after_score_id: + final_target = ( + target_order - 1 if target_order > current_order else target_order + ) + else: + final_target = target_order + else: + for s in all_pinned_scores: + if target_order <= s.pinned_order < current_order and s.id != score: + updates.append((s.id, s.pinned_order + 1)) + final_target = target_order + + for score_id, new_order in updates: + await db.exec(select(Score).where(Score.id == score_id)) + score_to_update = ( + await db.exec(select(Score).where(Score.id == score_id)) + ).first() + if score_to_update: + score_to_update.pinned_order = new_order + + score_record.pinned_order = final_target + + await db.commit() diff --git a/app/router/user.py b/app/router/user.py index 089aa4f..c22fc85 100644 --- a/app/router/user.py +++ b/app/router/user.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Literal + from app.database import ( BeatmapPlaycounts, BeatmapPlaycountsResp, @@ -8,6 +10,7 @@ from app.database import ( UserResp, ) from app.database.lazer_user import SEARCH_INCLUDED +from app.database.score import Score, ScoreResp from app.dependencies.database import get_db from app.dependencies.user import get_current_user from app.models.score import GameMode @@ -17,7 +20,7 @@ from .api_router import router from fastapi import Depends, HTTPException, Query from pydantic import BaseModel -from sqlmodel import select +from sqlmodel import false, select from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.sql.expression import col @@ -130,3 +133,52 @@ async def get_user_beatmapsets( raise HTTPException(400, detail="Invalid beatmapset type") return resp + + +@router.get("/users/{user}/scores/{type}", response_model=list[ScoreResp]) +async def get_user_scores( + user: int, + type: Literal["best", "recent", "firsts", "pinned"], + legacy_only: bool = Query(False), + include_fails: bool = Query(False), + mode: GameMode | None = None, + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), + session: AsyncSession = Depends(get_db), +): + db_user = await session.get(User, user) + if not db_user: + raise HTTPException(404, detail="User not found") + + gamemode = mode or db_user.playmode + order_by = None + where_clause = ( + (col(Score.user_id) == db_user.id) + & (col(Score.gamemode) == gamemode) + & (col(Score.passed).is_(True)) + ) + if type == "pinned": + where_clause &= Score.pinned_order > 0 + order_by = col(Score.pinned_order).asc() + else: + # TODO + where_clause &= false() + + scores = ( + await session.exec( + select(Score) + .where(where_clause) + .order_by(order_by) + .limit(limit) + .offset(offset) + ) + ).all() + if not scores: + return [] + return [ + await ScoreResp.from_db( + session, + score, + ) + for score in scores + ] diff --git a/migrations/versions/319e5f841dcf_score_support_pin_score.py b/migrations/versions/319e5f841dcf_score_support_pin_score.py new file mode 100644 index 0000000..ceacdec --- /dev/null +++ b/migrations/versions/319e5f841dcf_score_support_pin_score.py @@ -0,0 +1,34 @@ +"""score: support pin score + +Revision ID: 319e5f841dcf +Revises: 19cdc9ce4dcb +Create Date: 2025-08-10 14:07:51.749025 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "319e5f841dcf" +down_revision: str | Sequence[str] | None = "19cdc9ce4dcb" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("scores", sa.Column("pinned_order", sa.Integer(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("scores", "pinned_order") + # ### end Alembic commands ### From a4e095c9ea9f09108e1f82e1245d3ff82a74455d Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 15:42:58 +0000 Subject: [PATCH 086/104] fix(statistics): add length of beatmap to PT instead of `ended_at - started_at` --- app/database/score.py | 4 ++-- app/router/score.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/database/score.py b/app/database/score.py index ec790a2..c247ffc 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -509,7 +509,7 @@ async def get_user_best_pp( async def process_user( - session: AsyncSession, user: User, score: Score, ranked: bool = False + session: AsyncSession, user: User, score: Score, length: int, ranked: bool = False ): assert user.id assert score.id @@ -613,7 +613,7 @@ async def process_user( ) statistics.play_count += 1 mouthly_playcount.playcount += 1 - statistics.play_time += int((score.ended_at - score.started_at).total_seconds()) + statistics.play_time += length statistics.count_100 += score.n100 + score.nkatu statistics.count_300 += score.n300 + score.ngeki statistics.count_50 += score.n50 diff --git a/app/router/score.py b/app/router/score.py index dbd82b1..b2a52ca 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -95,6 +95,7 @@ async def submit_score( ranked = ( db_beatmap.beatmap_status.has_pp() | settings.enable_all_beatmap_leaderboard ) + beatmap_length = db_beatmap.total_length score = await process_score( current_user, beatmap, @@ -110,7 +111,7 @@ async def submit_score( await db.refresh(current_user) score_id = score.id score_token.score_id = score_id - await process_user(db, current_user, score, ranked) + await process_user(db, current_user, score, beatmap_length, ranked) score = (await db.exec(select(Score).where(Score.id == score_id))).first() assert score is not None return await ScoreResp.from_db(db, score) From bc782cda01d5e9fa1dedf192f97df9dfd28e3532 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 10 Aug 2025 17:00:29 +0000 Subject: [PATCH 087/104] fix(beatmap): missing greetlet in batch beatmap request --- app/database/beatmap.py | 2 +- app/router/beatmap.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/database/beatmap.py b/app/database/beatmap.py index e6f82e5..7e770b7 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -158,7 +158,7 @@ class BeatmapResp(BeatmapBase): from .score import Score beatmap_ = beatmap.model_dump() - beatmap_status = beatmap_["beatmap_status"] + beatmap_status = beatmap.beatmap_status if query_mode is not None and beatmap.mode != query_mode: beatmap_["convert"] = True beatmap_["is_scoreable"] = beatmap_status.has_leaderboard() diff --git a/app/router/beatmap.py b/app/router/beatmap.py index 6800246..591a7ae 100644 --- a/app/router/beatmap.py +++ b/app/router/beatmap.py @@ -108,6 +108,8 @@ async def batch_get_beatmaps( ) if isinstance(beatmap, Beatmap) ) + for beatmap in beatmaps: + await db.refresh(beatmap) return BatchGetResp( beatmaps=[ From 680c7525b89a57514ed172bbb1f86deedcf26bb8 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 11 Aug 2025 07:32:41 +0000 Subject: [PATCH 088/104] feat(user): support view recent & best scores --- app/database/lazer_user.py | 67 +++++++++++++++++++++++++++++++++++--- app/router/user.py | 18 ++++++---- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/app/database/lazer_user.py b/app/database/lazer_user.py index 2717c3a..2397323 100644 --- a/app/database/lazer_user.py +++ b/app/database/lazer_user.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, NotRequired, TypedDict from app.models.model import UTCBaseModel @@ -6,6 +6,7 @@ from app.models.score import GameMode from app.models.user import Country, Page, RankHistory from .achievement import UserAchievement, UserAchievementResp +from .beatmap_playcounts import BeatmapPlaycounts from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp from .monthly_playcounts import MonthlyPlaycounts, MonthlyPlaycountsResp from .statistics import UserStatistics, UserStatisticsResp @@ -21,6 +22,7 @@ from sqlmodel import ( Field, Relationship, SQLModel, + col, func, select, ) @@ -164,7 +166,7 @@ class UserResp(UserBase): is_online: bool = False groups: list = [] # TODO country: Country = Field(default_factory=lambda: Country(code="CN", name="China")) - favourite_beatmapset_count: int = 0 # TODO + favourite_beatmapset_count: int = 0 graveyard_beatmapset_count: int = 0 # TODO guest_beatmapset_count: int = 0 # TODO loved_beatmapset_count: int = 0 # TODO @@ -176,9 +178,10 @@ class UserResp(UserBase): follower_count: int = 0 friends: list["RelationshipResp"] | None = None scores_best_count: int = 0 - scores_first_count: int = 0 + scores_first_count: int = 0 # TODO scores_recent_count: int = 0 scores_pinned_count: int = 0 + beatmap_playcounts_count: int = 0 account_history: list[UserAccountHistoryResp] = [] active_tournament_banners: list[dict] = [] # TODO kudosu: Kudosu = Field(default_factory=lambda: Kudosu(available=0, total=0)) # TODO @@ -207,7 +210,11 @@ class UserResp(UserBase): from app.dependencies.database import get_redis from .best_score import BestScore + from .favourite_beatmapset import FavouriteBeatmapset from .relationship import Relationship, RelationshipResp, RelationshipType + from .score import Score + + ruleset = ruleset or obj.playmode u = cls.model_validate(obj.model_dump()) u.id = obj.id @@ -275,7 +282,7 @@ class UserResp(UserBase): if "statistics" in include: current_stattistics = None for i in await obj.awaitable_attrs.statistics: - if i.mode == (ruleset or obj.playmode): + if i.mode == ruleset: current_stattistics = i break u.statistics = ( @@ -302,6 +309,58 @@ class UserResp(UserBase): for ua in await obj.awaitable_attrs.achievement ] + u.favourite_beatmapset_count = ( + await session.exec( + select(func.count()) + .select_from(FavouriteBeatmapset) + .where(FavouriteBeatmapset.user_id == obj.id) + ) + ).one() + u.scores_pinned_count = ( + await session.exec( + select(func.count()) + .select_from(Score) + .where( + Score.user_id == obj.id, + Score.pinned_order > 0, + Score.gamemode == ruleset, + col(Score.passed).is_(True), + ) + ) + ).one() + u.scores_best_count = ( + await session.exec( + select(func.count()) + .select_from(BestScore) + .where( + BestScore.user_id == obj.id, + BestScore.gamemode == ruleset, + ) + .limit(200) + ) + ).one() + u.scores_recent_count = ( + await session.exec( + select(func.count()) + .select_from(Score) + .where( + Score.user_id == obj.id, + Score.gamemode == ruleset, + col(Score.passed).is_(True), + Score.ended_at > datetime.now(UTC) - timedelta(hours=24), + ) + ) + ).one() + u.beatmap_playcounts_count = ( + await session.exec( + select(func.count()) + .select_from(BeatmapPlaycounts) + .where( + BeatmapPlaycounts.user_id == obj.id, + ) + ) + ).one() + return u diff --git a/app/router/user.py b/app/router/user.py index c22fc85..e8d0a45 100644 --- a/app/router/user.py +++ b/app/router/user.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import UTC, datetime, timedelta from typing import Literal from app.database import ( @@ -10,6 +11,7 @@ from app.database import ( UserResp, ) from app.database.lazer_user import SEARCH_INCLUDED +from app.database.pp_best_score import PPBestScore from app.database.score import Score, ScoreResp from app.dependencies.database import get_db from app.dependencies.user import get_current_user @@ -20,7 +22,7 @@ from .api_router import router from fastapi import Depends, HTTPException, Query from pydantic import BaseModel -from sqlmodel import false, select +from sqlmodel import exists, false, select from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.sql.expression import col @@ -152,15 +154,19 @@ async def get_user_scores( gamemode = mode or db_user.playmode order_by = None - where_clause = ( - (col(Score.user_id) == db_user.id) - & (col(Score.gamemode) == gamemode) - & (col(Score.passed).is_(True)) + where_clause = (col(Score.user_id) == db_user.id) & ( + col(Score.gamemode) == gamemode ) + if not include_fails: + where_clause &= col(Score.passed).is_(True) if type == "pinned": where_clause &= Score.pinned_order > 0 order_by = col(Score.pinned_order).asc() - else: + elif type == "best": + where_clause &= exists().where(col(PPBestScore.score_id) == Score.id) + elif type == "recent": + where_clause &= Score.ended_at > datetime.now(UTC) - timedelta(hours=24) + elif type == "firsts": # TODO where_clause &= false() From b9babb8f240270f12f6dbb706297ac3f12bdc621 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 11 Aug 2025 08:19:05 +0000 Subject: [PATCH 089/104] feat(score): support download replay --- app/database/__init__.py | 8 ++ .../{monthly_playcounts.py => counts.py} | 31 +++++-- app/database/lazer_user.py | 19 +++- app/database/score.py | 4 +- app/router/score.py | 59 +++++++++++- app/signalr/hub/spectator.py | 4 +- ...13f905_count_add_replays_watched_counts.py | 90 +++++++++++++++++++ 7 files changed, 198 insertions(+), 17 deletions(-) rename app/database/{monthly_playcounts.py => counts.py} (55%) create mode 100644 migrations/versions/aa582c13f905_count_add_replays_watched_counts.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 7b5c228..b4167f0 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -10,6 +10,11 @@ from .beatmapset import ( BeatmapsetResp as BeatmapsetResp, ) from .best_score import BestScore +from .counts import ( + CountResp, + MonthlyPlaycounts, + ReplayWatchedCount, +) from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp from .favourite_beatmapset import FavouriteBeatmapset from .lazer_user import ( @@ -56,11 +61,13 @@ __all__ = [ "Beatmapset", "BeatmapsetResp", "BestScore", + "CountResp", "DailyChallengeStats", "DailyChallengeStatsResp", "FavouriteBeatmapset", "ItemAttemptsCount", "ItemAttemptsResp", + "MonthlyPlaycounts", "MultiplayerEvent", "MultiplayerEventResp", "MultiplayerScores", @@ -73,6 +80,7 @@ __all__ = [ "Relationship", "RelationshipResp", "RelationshipType", + "ReplayWatchedCount", "Room", "RoomParticipatedUser", "RoomResp", diff --git a/app/database/monthly_playcounts.py b/app/database/counts.py similarity index 55% rename from app/database/monthly_playcounts.py rename to app/database/counts.py index 46192d1..c999471 100644 --- a/app/database/monthly_playcounts.py +++ b/app/database/counts.py @@ -14,7 +14,13 @@ if TYPE_CHECKING: from .lazer_user import User -class MonthlyPlaycounts(SQLModel, table=True): +class CountBase(SQLModel): + year: int = Field(index=True) + month: int = Field(index=True) + count: int = Field(default=0) + + +class MonthlyPlaycounts(CountBase, table=True): __tablename__ = "monthly_playcounts" # pyright: ignore[reportAssignmentType] id: int | None = Field( @@ -24,20 +30,29 @@ class MonthlyPlaycounts(SQLModel, table=True): user_id: int = Field( sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) ) - year: int = Field(index=True) - month: int = Field(index=True) - playcount: int = Field(default=0) - user: "User" = Relationship(back_populates="monthly_playcounts") -class MonthlyPlaycountsResp(SQLModel): +class ReplayWatchedCount(CountBase, table=True): + __tablename__ = "replays_watched_counts" # pyright: ignore[reportAssignmentType] + + id: int | None = Field( + default=None, + sa_column=Column(BigInteger, primary_key=True, autoincrement=True), + ) + user_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) + ) + user: "User" = Relationship(back_populates="replays_watched_counts") + + +class CountResp(SQLModel): start_date: date count: int @classmethod - def from_db(cls, db_model: MonthlyPlaycounts) -> "MonthlyPlaycountsResp": + def from_db(cls, db_model: CountBase) -> "CountResp": return cls( start_date=date(db_model.year, db_model.month, 1), - count=db_model.playcount, + count=db_model.count, ) diff --git a/app/database/lazer_user.py b/app/database/lazer_user.py index 2397323..05998a7 100644 --- a/app/database/lazer_user.py +++ b/app/database/lazer_user.py @@ -7,8 +7,8 @@ from app.models.user import Country, Page, RankHistory from .achievement import UserAchievement, UserAchievementResp from .beatmap_playcounts import BeatmapPlaycounts +from .counts import CountResp, MonthlyPlaycounts, ReplayWatchedCount from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp -from .monthly_playcounts import MonthlyPlaycounts, MonthlyPlaycountsResp from .statistics import UserStatistics, UserStatisticsResp from .team import Team, TeamMember from .user_account_history import UserAccountHistory, UserAccountHistoryResp @@ -76,7 +76,6 @@ class UserBase(UTCBaseModel, SQLModel): username: str = Field(max_length=32, unique=True, index=True) page: Page = Field(sa_column=Column(JSON), default=Page(html="", raw="")) previous_usernames: list[str] = Field(default_factory=list, sa_column=Column(JSON)) - # TODO: replays_watched_counts support_level: int = 0 badges: list[Badge] = Field(default_factory=list, sa_column=Column(JSON)) @@ -146,6 +145,9 @@ class User(AsyncAttrs, UserBase, table=True): back_populates="user" ) monthly_playcounts: list[MonthlyPlaycounts] = Relationship(back_populates="user") + replays_watched_counts: list[ReplayWatchedCount] = Relationship( + back_populates="user" + ) favourite_beatmapsets: list["FavouriteBeatmapset"] = Relationship( back_populates="user" ) @@ -185,7 +187,8 @@ class UserResp(UserBase): account_history: list[UserAccountHistoryResp] = [] active_tournament_banners: list[dict] = [] # TODO kudosu: Kudosu = Field(default_factory=lambda: Kudosu(available=0, total=0)) # TODO - monthly_playcounts: list[MonthlyPlaycountsResp] = Field(default_factory=list) + monthly_playcounts: list[CountResp] = Field(default_factory=list) + replay_watched_counts: list[CountResp] = Field(default_factory=list) unread_pm_count: int = 0 # TODO rank_history: RankHistory | None = None # TODO rank_highest: RankHighest | None = None # TODO @@ -299,10 +302,16 @@ class UserResp(UserBase): if "monthly_playcounts" in include: u.monthly_playcounts = [ - MonthlyPlaycountsResp.from_db(pc) + CountResp.from_db(pc) for pc in await obj.awaitable_attrs.monthly_playcounts ] + if "replays_watched_counts" in include: + u.replay_watched_counts = [ + CountResp.from_db(rwc) + for rwc in await obj.awaitable_attrs.replays_watched_counts + ] + if "achievements" in include: u.user_achievements = [ UserAchievementResp.from_db(ua) @@ -373,6 +382,7 @@ ALL_INCLUDED = [ "statistics_rulesets", "achievements", "monthly_playcounts", + "replays_watched_counts", ] @@ -383,6 +393,7 @@ SEARCH_INCLUDED = [ "statistics_rulesets", "achievements", "monthly_playcounts", + "replays_watched_counts", ] BASE_INCLUDES = [ diff --git a/app/database/score.py b/app/database/score.py index c247ffc..9efb081 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -37,8 +37,8 @@ from .beatmap import Beatmap, BeatmapResp from .beatmap_playcounts import process_beatmap_playcount from .beatmapset import BeatmapsetResp from .best_score import BestScore +from .counts import MonthlyPlaycounts from .lazer_user import User, UserResp -from .monthly_playcounts import MonthlyPlaycounts from .pp_best_score import PPBestScore from .relationship import ( Relationship as DBRelationship, @@ -612,7 +612,7 @@ async def process_user( ) ) statistics.play_count += 1 - mouthly_playcount.playcount += 1 + mouthly_playcount.count += 1 statistics.play_time += length statistics.count_100 += score.n100 + score.nkatu statistics.count_300 += score.n300 + score.ngeki diff --git a/app/router/score.py b/app/router/score.py index b2a52ca..d197c8b 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, date, datetime import time from app.calculator import clamp @@ -15,12 +15,14 @@ from app.database import ( ScoreTokenResp, User, ) +from app.database.counts import ReplayWatchedCount from app.database.playlist_attempts import ItemAttemptsCount from app.database.playlist_best_score import ( PlaylistBestScore, get_position, process_playlist_best_score, ) +from app.database.relationship import Relationship, RelationshipType from app.database.score import ( MultiplayerScores, ScoreAround, @@ -40,15 +42,17 @@ from app.models.score import ( Rank, SoloScoreSubmissionInfo, ) +from app.path import REPLAY_DIR from .api_router import router from fastapi import Body, Depends, Form, HTTPException, Query +from fastapi.responses import FileResponse from httpx import HTTPError from pydantic import BaseModel from redis.asyncio import Redis from sqlalchemy.orm import joinedload -from sqlmodel import col, func, select +from sqlmodel import col, exists, func, select from sqlmodel.ext.asyncio.session import AsyncSession READ_SCORE_TIMEOUT = 10 @@ -704,3 +708,54 @@ async def reorder_score_pin( score_record.pinned_order = final_target await db.commit() + + +@router.get("/scores/{score_id}/download") +async def download_score_replay( + score_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + score = (await db.exec(select(Score).where(Score.id == score_id))).first() + if not score: + raise HTTPException(status_code=404, detail="Score not found") + + filename = f"{score.id}_{score.beatmap_id}_{score.user_id}_lazer_replay.osr" + path = REPLAY_DIR / filename + + if not path.exists(): + raise HTTPException(status_code=404, detail="Replay file not found") + + is_friend = ( + score.user_id == current_user.id + or ( + await db.exec( + select(exists()).where( + Relationship.user_id == current_user.id, + Relationship.target_id == score.user_id, + Relationship.type == RelationshipType.FOLLOW, + ) + ) + ).first() + ) + if not is_friend: + replay_watched_count = ( + await db.exec( + select(ReplayWatchedCount).where( + ReplayWatchedCount.user_id == score.user_id, + ReplayWatchedCount.year == date.today().year, + ReplayWatchedCount.month == date.today().month, + ) + ) + ).first() + if replay_watched_count is None: + replay_watched_count = ReplayWatchedCount( + user_id=score.user_id, year=date.today().year, month=date.today().month + ) + db.add(replay_watched_count) + replay_watched_count.count += 1 + await db.commit() + + return FileResponse( + path=path, filename=filename, media_type="application/x-osu-replay" + ) diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index 7764994..dcc47f3 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -136,7 +136,9 @@ def save_replay( data.extend(struct.pack(" None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "replays_watched_counts", + sa.Column("year", sa.Integer(), nullable=False), + sa.Column("month", sa.Integer(), nullable=False), + sa.Column("count", sa.Integer(), nullable=False), + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_replays_watched_counts_month"), + "replays_watched_counts", + ["month"], + unique=False, + ) + op.create_index( + op.f("ix_replays_watched_counts_user_id"), + "replays_watched_counts", + ["user_id"], + unique=False, + ) + op.create_index( + op.f("ix_replays_watched_counts_year"), + "replays_watched_counts", + ["year"], + unique=False, + ) + op.alter_column( + "monthly_playcounts", + "playcount", + new_column_name="count", + type_=sa.Integer(), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "monthly_playcounts", + "count", + new_column_name="playcount", + type_=sa.Integer(), + ) + op.drop_constraint( + "replays_watched_counts_ibfk_1", + "replays_watched_counts", + type_="foreignkey", + ) + op.drop_index( + op.f("ix_replays_watched_counts_year"), table_name="replays_watched_counts" + ) + op.drop_index( + op.f("ix_replays_watched_counts_user_id"), table_name="replays_watched_counts" + ) + op.drop_index( + op.f("ix_replays_watched_counts_month"), table_name="replays_watched_counts" + ) + op.drop_table("replays_watched_counts") + # ### end Alembic commands ### From 2f54ac262c6e309cc749a71067b904b59fca6cb9 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 11 Aug 2025 09:13:42 +0000 Subject: [PATCH 090/104] feat(misc): support seasonal backgrounds --- .env.example | 1 + README.md | 1 + app/config.py | 1 + app/router/__init__.py | 1 + app/router/misc.py | 25 +++++++++++++++++++++++++ 5 files changed, 29 insertions(+) create mode 100644 app/router/misc.py diff --git a/.env.example b/.env.example index 7715368..82b575b 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,4 @@ ENABLE_OSU_AP=false # 启用 osu!AP 统计数据 ENABLE_ALL_MODS_PP=false # 启用所有 Mod 的 PP 计算 ENABLE_SUPPORTER_FOR_ALL_USERS=false # 启用所有新注册用户的支持者状态 ENABLE_ALL_BEATMAP_LEADERBOARD=false # 启用所有谱面的排行榜(没有排行榜的谱面会以 APPROVED 状态返回) +SEASONAL_BACKGROUNDS='[]' # 季节背景图 URL 列表 diff --git a/README.md b/README.md index bcdd149..01ea6d9 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Fetcher 用于从 osu! 官方 API 获取数据,使用 osu! 官方 API 的 OAut | `ENABLE_ALL_MODS_PP` | 启用所有 Mod 的 PP 计算 | `false` | | `ENABLE_SUPPORTER_FOR_ALL_USERS` | 启用所有新注册用户的支持者状态 | `false` | | `ENABLE_ALL_BEATMAP_LEADERBOARD` | 启用所有谱面的排行榜 | `false` | +| `SEASONAL_BACKGROUNDS` | 季节背景图 URL 列表 | `[]` | > **注意**: 在生产环境中,请务必更改默认的密钥和密码! diff --git a/app/config.py b/app/config.py index f0df438..a5045e3 100644 --- a/app/config.py +++ b/app/config.py @@ -55,6 +55,7 @@ class Settings(BaseSettings): enable_all_mods_pp: bool = False enable_supporter_for_all_users: bool = False enable_all_beatmap_leaderboard: bool = False + seasonal_backgrounds: list[str] = [] @field_validator("fetcher_scopes", mode="before") def validate_fetcher_scopes(cls, v: Any) -> list[str]: diff --git a/app/router/__init__.py b/app/router/__init__.py index 22f6c70..bc82905 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -6,6 +6,7 @@ from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401 beatmap, beatmapset, me, + misc, relationship, room, score, diff --git a/app/router/misc.py b/app/router/misc.py new file mode 100644 index 0000000..1921008 --- /dev/null +++ b/app/router/misc.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from app.config import settings + +from .api_router import router + +from pydantic import BaseModel + + +class Background(BaseModel): + url: str + + +class BackgroundsResp(BaseModel): + ends_at: datetime = datetime(year=9999, month=12, day=31, tzinfo=UTC) + backgrounds: list[Background] + + +@router.get("/seasonal-backgrounds", response_model=BackgroundsResp) +async def get_seasonal_backgrounds(): + return BackgroundsResp( + backgrounds=[Background(url=url) for url in settings.seasonal_backgrounds] + ) From ee9381d1f08e13fe7b1e4cc7a2ba1f72c791bc34 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 11 Aug 2025 09:55:15 +0000 Subject: [PATCH 091/104] fix(user): wrong order for best & recent scores --- app/database/oauth_clients.py | 0 app/router/user.py | 2 ++ 2 files changed, 2 insertions(+) create mode 100644 app/database/oauth_clients.py diff --git a/app/database/oauth_clients.py b/app/database/oauth_clients.py new file mode 100644 index 0000000..e69de29 diff --git a/app/router/user.py b/app/router/user.py index e8d0a45..e45426d 100644 --- a/app/router/user.py +++ b/app/router/user.py @@ -164,8 +164,10 @@ async def get_user_scores( order_by = col(Score.pinned_order).asc() elif type == "best": where_clause &= exists().where(col(PPBestScore.score_id) == Score.id) + order_by = col(Score.pp).desc() elif type == "recent": where_clause &= Score.ended_at > datetime.now(UTC) - timedelta(hours=24) + order_by = col(Score.ended_at).desc() elif type == "firsts": # TODO where_clause &= false() From 6e7114114646253666385f42502df640ed8ce3bd Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 11 Aug 2025 12:33:31 +0000 Subject: [PATCH 092/104] feat(developer): support custom OAuth 2.0 client --- .env.example | 10 +- README.md | 2 + app/auth.py | 24 +++- app/config.py | 6 +- app/database/__init__.py | 3 +- app/database/auth.py | 14 ++- app/database/oauth_clients.py | 0 app/dependencies/user.py | 76 ++++++++++-- app/router/auth.py | 112 ++++++++++++++++-- app/router/beatmap.py | 10 +- app/router/beatmapset.py | 10 +- app/router/me.py | 4 +- app/router/relationship.py | 11 +- app/router/room.py | 30 +++-- app/router/score.py | 30 ++--- app/router/user.py | 7 +- app/signalr/router.py | 16 ++- main.py | 15 ++- ...a8669ba11e96_auth_support_custom_client.py | 67 +++++++++++ pyproject.toml | 1 + uv.lock | 14 +++ 21 files changed, 380 insertions(+), 82 deletions(-) delete mode 100644 app/database/oauth_clients.py create mode 100644 migrations/versions/a8669ba11e96_auth_support_custom_client.py diff --git a/.env.example b/.env.example index 82b575b..a713dbb 100644 --- a/.env.example +++ b/.env.example @@ -21,9 +21,11 @@ PORT=8000 # 调试模式,生产环境请设置为 false DEBUG=false -# osu!lazer 登录设置 -OSU_CLIENT_ID="5" -OSU_CLIENT_SECRET="FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" +# osu! 登录设置 +OSU_CLIENT_ID=5 # lazer client ID +OSU_CLIENT_SECRET="FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" # lazer client secret +OSU_WEB_CLIENT_ID=6 # 网页端 client ID +OSU_WEB_CLIENT_SECRET="your_osu_web_client_secret_here" # 网页端 client secret,使用 openssl rand -hex 40 生成 # SignalR 服务器设置 SIGNALR_NEGOTIATE_TIMEOUT=30 @@ -32,7 +34,7 @@ SIGNALR_PING_INTERVAL=15 # Fetcher 设置 FETCHER_CLIENT_ID="" FETCHER_CLIENT_SECRET="" -FETCHER_SCOPES=["public"] +FETCHER_SCOPES=public FETCHER_CALLBACK_URL="http://localhost:8000/fetcher/callback" # 日志设置 diff --git a/README.md b/README.md index 01ea6d9..4bf5445 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ docker-compose -f docker-compose-osurx.yml up -d |--------|------|--------| | `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` | | `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` | +| `OSU_WEB_CLIENT_ID` | Web OAuth 客户端 ID | `6` | +| `OSU_WEB_CLIENT_SECRET` | Web OAuth 客户端密钥 | `your_osu_web_client_secret_here` ### SignalR 服务器设置 | 变量名 | 描述 | 默认值 | diff --git a/app/auth.py b/app/auth.py index ddf5f56..c1a0000 100644 --- a/app/auth.py +++ b/app/auth.py @@ -15,6 +15,7 @@ from app.log import logger import bcrypt from jose import JWTError, jwt from passlib.context import CryptContext +from redis.asyncio import Redis from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -156,6 +157,8 @@ def verify_token(token: str) -> dict | None: async def store_token( db: AsyncSession, user_id: int, + client_id: int, + scopes: list[str], access_token: str, refresh_token: str, expires_in: int, @@ -164,7 +167,9 @@ async def store_token( expires_at = datetime.utcnow() + timedelta(seconds=expires_in) # 删除用户的旧令牌 - statement = select(OAuthToken).where(OAuthToken.user_id == user_id) + statement = select(OAuthToken).where( + OAuthToken.user_id == user_id, OAuthToken.client_id == client_id + ) old_tokens = (await db.exec(statement)).all() for token in old_tokens: await db.delete(token) @@ -179,7 +184,9 @@ async def store_token( # 创建新令牌记录 token_record = OAuthToken( user_id=user_id, + client_id=client_id, access_token=access_token, + scope=",".join(scopes), refresh_token=refresh_token, expires_at=expires_at, ) @@ -209,3 +216,18 @@ async def get_token_by_refresh_token( OAuthToken.expires_at > datetime.utcnow(), ) return (await db.exec(statement)).first() + + +async def get_user_by_authorization_code( + db: AsyncSession, redis: Redis, client_id: int, code: str +) -> tuple[User, list[str]] | None: + user_id = await redis.hget(f"oauth:code:{client_id}:{code}", "user_id") # pyright: ignore[reportGeneralTypeIssues] + scopes = await redis.hget(f"oauth:code:{client_id}:{code}", "scopes") # pyright: ignore[reportGeneralTypeIssues] + if not user_id or not scopes: + return None + + await redis.hdel(f"oauth:code:{client_id}:{code}", "user_id", "scopes") # pyright: ignore[reportGeneralTypeIssues] + + statement = select(User).where(User.id == int(user_id)) + user = (await db.exec(statement)).first() + return (user, scopes.split(",")) if user else None diff --git a/app/config.py b/app/config.py index a5045e3..004971d 100644 --- a/app/config.py +++ b/app/config.py @@ -23,13 +23,15 @@ class Settings(BaseSettings): return f"mysql+aiomysql://{self.mysql_user}:{self.mysql_password}@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}" # JWT 设置 - secret_key: str = Field(default="your-secret-key-here", alias="jwt_secret_key") + secret_key: str = Field(default="your_jwt_secret_here", alias="jwt_secret_key") algorithm: str = "HS256" access_token_expire_minutes: int = 1440 # OAuth 设置 - osu_client_id: str = "5" + osu_client_id: int = 5 osu_client_secret: str = "FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" + osu_web_client_id: int = 6 + osu_web_client_secret: str = "your_osu_web_client_secret_here" # 服务器设置 host: str = "0.0.0.0" diff --git a/app/database/__init__.py b/app/database/__init__.py index b4167f0..5304b34 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -1,5 +1,5 @@ from .achievement import UserAchievement, UserAchievementResp -from .auth import OAuthToken +from .auth import OAuthClient, OAuthToken from .beatmap import ( Beatmap as Beatmap, BeatmapResp as BeatmapResp, @@ -71,6 +71,7 @@ __all__ = [ "MultiplayerEvent", "MultiplayerEventResp", "MultiplayerScores", + "OAuthClient", "OAuthToken", "PPBestScore", "Playlist", diff --git a/app/database/auth.py b/app/database/auth.py index 554dced..cf62afe 100644 --- a/app/database/auth.py +++ b/app/database/auth.py @@ -1,10 +1,11 @@ from datetime import datetime +import secrets from typing import TYPE_CHECKING from app.models.model import UTCBaseModel from sqlalchemy import Column, DateTime -from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel +from sqlmodel import JSON, BigInteger, Field, ForeignKey, Relationship, SQLModel if TYPE_CHECKING: from .lazer_user import User @@ -17,6 +18,7 @@ class OAuthToken(UTCBaseModel, SQLModel, table=True): user_id: int = Field( sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) ) + client_id: int = Field(index=True) access_token: str = Field(max_length=500, unique=True) refresh_token: str = Field(max_length=500, unique=True) token_type: str = Field(default="Bearer", max_length=20) @@ -27,3 +29,13 @@ class OAuthToken(UTCBaseModel, SQLModel, table=True): ) user: "User" = Relationship() + + +class OAuthClient(SQLModel, table=True): + __tablename__ = "oauth_clients" # pyright: ignore[reportAssignmentType] + client_id: int | None = Field(default=None, primary_key=True, index=True) + client_secret: str = Field(default_factory=secrets.token_hex, index=True) + redirect_uris: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + owner_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) + ) diff --git a/app/database/oauth_clients.py b/app/database/oauth_clients.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/dependencies/user.py b/app/dependencies/user.py index 5537f4f..8ebde87 100644 --- a/app/dependencies/user.py +++ b/app/dependencies/user.py @@ -1,34 +1,84 @@ from __future__ import annotations +from typing import Annotated + from app.auth import get_token_by_access_token +from app.config import settings from app.database import User from .database import get_db from fastapi import Depends, HTTPException -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi.security import ( + HTTPBearer, + OAuth2AuthorizationCodeBearer, + OAuth2PasswordBearer, + SecurityScopes, +) from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession security = HTTPBearer() +oauth2_password = OAuth2PasswordBearer( + tokenUrl="oauth/token", + scopes={"*": "Allows access to all scopes."}, +) + +oauth2_code = OAuth2AuthorizationCodeBearer( + authorizationUrl="oauth/authorize", + tokenUrl="oauth/token", + scopes={ + "chat.read": "Allows read chat messages on a user's behalf.", + "chat.write": "Allows sending chat messages on a user's behalf.", + "chat.write_manage": ( + "Allows joining and leaving chat channels on a user's behalf." + ), + "delegate": ( + "Allows acting as the owner of a client; " + "only available for Client Credentials Grant." + ), + "forum.write": "Allows creating and editing forum posts on a user's behalf.", + "friends.read": "Allows reading of the user's friend list.", + "identify": "Allows reading of the public profile of the user (/me).", + "public": "Allows reading of publicly available data on behalf of the user.", + }, +) + + async def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security), - db: AsyncSession = Depends(get_db), + security_scopes: SecurityScopes, + db: Annotated[AsyncSession, Depends(get_db)], + token_pw: Annotated[str | None, Depends(oauth2_password)] = None, + token_code: Annotated[str | None, Depends(oauth2_code)] = None, ) -> User: """获取当前认证用户""" - token = credentials.credentials + token = token_pw or token_code + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") - user = await get_current_user_by_token(token, db) + token_record = await get_token_by_access_token(db, token) + if not token_record: + raise HTTPException(status_code=401, detail="Invalid or expired token") + + is_client = token_record.client_id in ( + settings.osu_client_id, + settings.osu_web_client_id, + ) + + if security_scopes.scopes == ["*"]: + # client/web only + if not token_pw or not is_client: + raise HTTPException(status_code=401, detail="Not authenticated") + elif not is_client: + for scope in security_scopes.scopes: + if scope not in token_record.scope.split(","): + raise HTTPException( + status_code=403, detail=f"Insufficient scope: {scope}" + ) + + user = (await db.exec(select(User).where(User.id == token_record.user_id))).first() if not user: raise HTTPException(status_code=401, detail="Invalid or expired token") return user - - -async def get_current_user_by_token(token: str, db: AsyncSession) -> User | None: - token_record = await get_token_by_access_token(db, token) - if not token_record: - return None - user = (await db.exec(select(User).where(User.id == token_record.user_id))).first() - return user diff --git a/app/router/auth.py b/app/router/auth.py index f5015ab..d0c826a 100644 --- a/app/router/auth.py +++ b/app/router/auth.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta import re +from typing import Literal from app.auth import ( authenticate_user, @@ -9,12 +10,14 @@ from app.auth import ( generate_refresh_token, get_password_hash, get_token_by_refresh_token, + get_user_by_authorization_code, store_token, ) from app.config import settings -from app.database import DailyChallengeStats, User +from app.database import DailyChallengeStats, OAuthClient, User from app.database.statistics import UserStatistics from app.dependencies import get_db +from app.dependencies.database import get_redis from app.log import logger from app.models.oauth import ( OAuthErrorResponse, @@ -26,6 +29,7 @@ from app.models.score import GameMode from fastapi import APIRouter, Depends, Form from fastapi.responses import JSONResponse +from redis.asyncio import Redis from sqlalchemy import text from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -195,21 +199,36 @@ async def register_user( @router.post("/oauth/token", response_model=TokenResponse) async def oauth_token( - grant_type: str = Form(...), - client_id: str = Form(...), + grant_type: Literal[ + "authorization_code", "refresh_token", "password", "client_credentials" + ] = Form(...), + client_id: int = Form(...), client_secret: str = Form(...), + code: str | None = Form(None), scope: str = Form("*"), username: str | None = Form(None), password: str | None = Form(None), refresh_token: str | None = Form(None), db: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), ): """OAuth 令牌端点""" - # 验证客户端凭据 - if ( - client_id != settings.osu_client_id - or client_secret != settings.osu_client_secret - ): + scopes = scope.split(" ") + + client = ( + await db.exec( + select(OAuthClient).where( + OAuthClient.client_id == client_id, + OAuthClient.client_secret == client_secret, + ) + ) + ).first() + is_game_client = (client_id, client_secret) in [ + (settings.osu_client_id, settings.osu_client_secret), + (settings.osu_web_client_id, settings.osu_web_client_secret), + ] + + if client is None and not is_game_client: return create_oauth_error_response( error="invalid_client", description=( @@ -222,7 +241,6 @@ async def oauth_token( ) if grant_type == "password": - # 密码授权流程 if not username or not password: return create_oauth_error_response( error="invalid_request", @@ -233,6 +251,16 @@ async def oauth_token( ), hint="Username and password required", ) + if scopes != ["*"]: + return create_oauth_error_response( + error="invalid_scope", + description=( + "The requested scope is invalid, unknown, " + "or malformed. The client may not request " + "more than one scope at a time." + ), + hint="Only '*' scope is allowed for password grant type", + ) # 验证用户 user = await authenticate_user(db, username, password) @@ -261,6 +289,8 @@ async def oauth_token( await store_token( db, user.id, + client_id, + scopes, access_token, refresh_token_str, settings.access_token_expire_minutes * 60, @@ -313,6 +343,8 @@ async def oauth_token( await store_token( db, token_record.user_id, + client_id, + scopes, access_token, new_refresh_token, settings.access_token_expire_minutes * 60, @@ -325,7 +357,69 @@ async def oauth_token( refresh_token=new_refresh_token, scope=scope, ) + elif grant_type == "authorization_code": + if client is None: + return create_oauth_error_response( + error="invalid_client", + description=( + "Client authentication failed (e.g., unknown client, " + "no client authentication included, " + "or unsupported authentication method)." + ), + hint="Invalid client credentials", + status_code=401, + ) + if not code: + return create_oauth_error_response( + error="invalid_request", + description=( + "The request is missing a required parameter, " + "includes an invalid parameter value, " + "includes a parameter more than once, or is otherwise malformed." + ), + hint="Authorization code required", + ) + + code_result = await get_user_by_authorization_code(db, redis, client_id, code) + if not code_result: + return create_oauth_error_response( + error="invalid_grant", + description=( + "The provided authorization grant (e.g., authorization code, " + "resource owner credentials) or refresh token is invalid, " + "expired, revoked, does not match the redirection URI used in " + "the authorization request, or was issued to another client." + ), + hint="Invalid authorization code", + ) + user, scopes = code_result + # 生成令牌 + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) + access_token = create_access_token( + data={"sub": str(user.id)}, expires_delta=access_token_expires + ) + refresh_token_str = generate_refresh_token() + + # 存储令牌 + assert user.id + await store_token( + db, + user.id, + client_id, + scopes, + access_token, + refresh_token_str, + settings.access_token_expire_minutes * 60, + ) + + return TokenResponse( + access_token=access_token, + token_type="Bearer", + expires_in=settings.access_token_expire_minutes * 60, + refresh_token=refresh_token_str, + scope=" ".join(scopes), + ) else: return create_oauth_error_response( error="unsupported_grant_type", diff --git a/app/router/beatmap.py b/app/router/beatmap.py index 591a7ae..123c260 100644 --- a/app/router/beatmap.py +++ b/app/router/beatmap.py @@ -19,7 +19,7 @@ from app.models.score import ( from .api_router import router -from fastapi import Depends, HTTPException, Query +from fastapi import Depends, HTTPException, Query, Security from httpx import HTTPError, HTTPStatusError from pydantic import BaseModel from redis.asyncio import Redis @@ -33,7 +33,7 @@ async def lookup_beatmap( id: int | None = Query(default=None, alias="id"), md5: str | None = Query(default=None, alias="checksum"), filename: str | None = Query(default=None, alias="filename"), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), ): @@ -56,7 +56,7 @@ async def lookup_beatmap( @router.get("/beatmaps/{bid}", tags=["beatmap"], response_model=BeatmapResp) async def get_beatmap( bid: int, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), ): @@ -75,7 +75,7 @@ class BatchGetResp(BaseModel): @router.get("/beatmaps/", tags=["beatmap"], response_model=BatchGetResp) async def batch_get_beatmaps( b_ids: list[int] = Query(alias="ids[]", default_factory=list), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), ): @@ -126,7 +126,7 @@ async def batch_get_beatmaps( ) async def get_beatmap_attributes( beatmap: int, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), mods: list[str] = Query(default_factory=list), ruleset: GameMode | None = Query(default=None), ruleset_id: int | None = Query(default=None), diff --git a/app/router/beatmapset.py b/app/router/beatmapset.py index bebd178..09f1aeb 100644 --- a/app/router/beatmapset.py +++ b/app/router/beatmapset.py @@ -10,7 +10,7 @@ from app.fetcher import Fetcher from .api_router import router -from fastapi import Depends, Form, HTTPException, Query +from fastapi import Depends, Form, HTTPException, Query, Security from fastapi.responses import RedirectResponse from httpx import HTTPError from sqlmodel import select @@ -20,7 +20,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession @router.get("/beatmapsets/lookup", tags=["beatmapset"], response_model=BeatmapsetResp) async def lookup_beatmapset( beatmap_id: int = Query(), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), ): @@ -34,7 +34,7 @@ async def lookup_beatmapset( @router.get("/beatmapsets/{sid}", tags=["beatmapset"], response_model=BeatmapsetResp) async def get_beatmapset( sid: int, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), db: AsyncSession = Depends(get_db), fetcher: Fetcher = Depends(get_fetcher), ): @@ -51,7 +51,7 @@ async def get_beatmapset( async def download_beatmapset( beatmapset: int, no_video: bool = Query(True, alias="noVideo"), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), ): if current_user.country_code == "CN": return RedirectResponse( @@ -68,7 +68,7 @@ async def download_beatmapset( async def favourite_beatmapset( beatmapset: int, action: Literal["favourite", "unfavourite"] = Form(), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), db: AsyncSession = Depends(get_db), ): existing_favourite = ( diff --git a/app/router/me.py b/app/router/me.py index b6d7d26..28b37ea 100644 --- a/app/router/me.py +++ b/app/router/me.py @@ -8,7 +8,7 @@ from app.models.score import GameMode from .api_router import router -from fastapi import Depends +from fastapi import Depends, Security from sqlmodel.ext.asyncio.session import AsyncSession @@ -16,7 +16,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession @router.get("/me/", response_model=UserResp) async def get_user_info_default( ruleset: GameMode | None = None, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["identify"]), session: AsyncSession = Depends(get_db), ): return await UserResp.from_db( diff --git a/app/router/relationship.py b/app/router/relationship.py index 02292c9..b63b3a8 100644 --- a/app/router/relationship.py +++ b/app/router/relationship.py @@ -1,13 +1,12 @@ from __future__ import annotations -from app.database import User as DBUser -from app.database.relationship import Relationship, RelationshipResp, RelationshipType +from app.database import Relationship, RelationshipResp, RelationshipType, User from app.dependencies.database import get_db from app.dependencies.user import get_current_user from .api_router import router -from fastapi import Depends, HTTPException, Query, Request +from fastapi import Depends, HTTPException, Query, Request, Security from pydantic import BaseModel from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -17,7 +16,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession @router.get("/blocks", tags=["relationship"], response_model=list[RelationshipResp]) async def get_relationship( request: Request, - current_user: DBUser = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["friends.read"]), db: AsyncSession = Depends(get_db), ): relationship_type = ( @@ -43,7 +42,7 @@ class AddFriendResp(BaseModel): async def add_relationship( request: Request, target: int = Query(), - current_user: DBUser = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), db: AsyncSession = Depends(get_db), ): relationship_type = ( @@ -106,7 +105,7 @@ async def add_relationship( async def delete_relationship( request: Request, target: int, - current_user: DBUser = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), db: AsyncSession = Depends(get_db), ): relationship_type = ( diff --git a/app/router/room.py b/app/router/room.py index eaee8d0..3f65fcc 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -20,7 +20,7 @@ from app.signalr.hub import MultiplayerHubs from .api_router import router -from fastapi import Depends, HTTPException, Query +from fastapi import Depends, HTTPException, Query, Security from pydantic import BaseModel, Field from redis.asyncio import Redis from sqlalchemy.sql.elements import ColumnElement @@ -36,7 +36,7 @@ async def get_all_rooms( category: RoomCategory = Query(RoomCategory.NORMAL), status: RoomStatus | None = Query(None), db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), ): resp_list: list[RoomResp] = [] where_clauses: list[ColumnElement[bool]] = [col(Room.category) == category] @@ -124,7 +124,7 @@ async def _participate_room( async def create_room( room: APIUploadedRoom, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), ): user_id = current_user.id db_room = await create_playlist_room_from_api(db, room, user_id) @@ -141,7 +141,7 @@ async def get_room( room: int, category: str = Query(default=""), db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), redis: Redis = Depends(get_redis), ): # 直接从db获取信息,毕竟都一样 @@ -155,7 +155,11 @@ async def get_room( @router.delete("/rooms/{room}", tags=["room"]) -async def delete_room(room: int, db: AsyncSession = Depends(get_db)): +async def delete_room( + room: int, + db: AsyncSession = Depends(get_db), + current_user: User = Security(get_current_user, scopes=["*"]), +): db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is None: raise HTTPException(404, "Room not found") @@ -166,7 +170,12 @@ async def delete_room(room: int, db: AsyncSession = Depends(get_db)): @router.put("/rooms/{room}/users/{user}", tags=["room"]) -async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_db)): +async def add_user_to_room( + room: int, + user: int, + db: AsyncSession = Depends(get_db), + current_user: User = Security(get_current_user, scopes=["*"]), +): db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is not None: await _participate_room(room, user, db_room, db) @@ -181,7 +190,10 @@ async def add_user_to_room(room: int, user: int, db: AsyncSession = Depends(get_ @router.delete("/rooms/{room}/users/{user}", tags=["room"]) async def remove_user_from_room( - room: int, user: int, db: AsyncSession = Depends(get_db) + room: int, + user: int, + db: AsyncSession = Depends(get_db), + current_user: User = Security(get_current_user, scopes=["*"]), ): db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is not None: @@ -211,7 +223,7 @@ class APILeaderboard(BaseModel): async def get_room_leaderboard( room: int, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), ): db_room = (await db.exec(select(Room).where(Room.id == room))).first() if db_room is None: @@ -253,7 +265,7 @@ class RoomEvents(BaseModel): async def get_room_events( room_id: int, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), limit: int = Query(100, ge=1, le=1000), after: int | None = Query(None, ge=0), before: int | None = Query(None, ge=0), diff --git a/app/router/score.py b/app/router/score.py index d197c8b..a31030a 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -46,7 +46,7 @@ from app.path import REPLAY_DIR from .api_router import router -from fastapi import Body, Depends, Form, HTTPException, Query +from fastapi import Body, Depends, Form, HTTPException, Query, Security from fastapi.responses import FileResponse from httpx import HTTPError from pydantic import BaseModel @@ -135,7 +135,7 @@ async def get_beatmap_scores( legacy_only: bool = Query(None), # TODO:加入对这个参数的查询 mods: list[str] = Query(default_factory=set, alias="mods[]"), type: LeaderboardType = Query(LeaderboardType.GLOBAL), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), db: AsyncSession = Depends(get_db), limit: int = Query(50, ge=1, le=200), ): @@ -170,7 +170,7 @@ async def get_user_beatmap_score( legacy_only: bool = Query(None), mode: str = Query(None), mods: str = Query(None), # TODO:添加mods筛选 - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), db: AsyncSession = Depends(get_db), ): if legacy_only: @@ -211,7 +211,7 @@ async def get_user_all_beatmap_scores( user: int, legacy_only: bool = Query(None), ruleset: str = Query(None), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), db: AsyncSession = Depends(get_db), ): if legacy_only: @@ -241,7 +241,7 @@ async def create_solo_score( version_hash: str = Form(""), beatmap_hash: str = Form(), ruleset_id: int = Form(..., ge=0, le=3), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), db: AsyncSession = Depends(get_db), ): assert current_user.id @@ -266,7 +266,7 @@ async def submit_solo_score( beatmap: int, token: int, info: SoloScoreSubmissionInfo, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), db: AsyncSession = Depends(get_db), redis: Redis = Depends(get_redis), fetcher=Depends(get_fetcher), @@ -284,7 +284,7 @@ async def create_playlist_score( beatmap_hash: str = Form(), ruleset_id: int = Form(..., ge=0, le=3), version_hash: str = Form(""), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), session: AsyncSession = Depends(get_db), ): room = await session.get(Room, room_id) @@ -351,7 +351,7 @@ async def submit_playlist_score( playlist_id: int, token: int, info: SoloScoreSubmissionInfo, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), session: AsyncSession = Depends(get_db), redis: Redis = Depends(get_redis), fetcher: Fetcher = Depends(get_fetcher), @@ -404,7 +404,7 @@ async def index_playlist_scores( playlist_id: int, limit: int = 50, cursor: int = Query(2000000, alias="cursor[total_score]"), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), session: AsyncSession = Depends(get_db), ): room = await session.get(Room, room_id) @@ -464,7 +464,7 @@ async def show_playlist_score( room_id: int, playlist_id: int, score_id: int, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), session: AsyncSession = Depends(get_db), redis: Redis = Depends(get_redis), ): @@ -528,7 +528,7 @@ async def get_user_playlist_score( room_id: int, playlist_id: int, user_id: int, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), session: AsyncSession = Depends(get_db), ): score_record = None @@ -558,7 +558,7 @@ async def get_user_playlist_score( @router.put("/score-pins/{score}", status_code=204) async def pin_score( score: int, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), db: AsyncSession = Depends(get_db), ): score_record = ( @@ -594,7 +594,7 @@ async def pin_score( @router.delete("/score-pins/{score}", status_code=204) async def unpin_score( score: int, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), db: AsyncSession = Depends(get_db), ): score_record = ( @@ -626,7 +626,7 @@ async def reorder_score_pin( score: int, after_score_id: int | None = Body(default=None), before_score_id: int | None = Body(default=None), - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["*"]), db: AsyncSession = Depends(get_db), ): score_record = ( @@ -713,7 +713,7 @@ async def reorder_score_pin( @router.get("/scores/{score_id}/download") async def download_score_replay( score_id: int, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), db: AsyncSession = Depends(get_db), ): score = (await db.exec(select(Score).where(Score.id == score_id))).first() diff --git a/app/router/user.py b/app/router/user.py index e45426d..eb92552 100644 --- a/app/router/user.py +++ b/app/router/user.py @@ -20,7 +20,7 @@ from app.models.user import BeatmapsetType from .api_router import router -from fastapi import Depends, HTTPException, Query +from fastapi import Depends, HTTPException, Query, Security from pydantic import BaseModel from sqlmodel import exists, false, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -36,6 +36,7 @@ class BatchUserResponse(BaseModel): @router.get("/users/lookup/", response_model=BatchUserResponse) async def get_users( user_ids: list[int] = Query(default_factory=list, alias="ids[]"), + current_user: User = Security(get_current_user, scopes=["public"]), include_variant_statistics: bool = Query(default=False), # TODO: future use session: AsyncSession = Depends(get_db), ): @@ -64,6 +65,7 @@ async def get_user_info( user: str, ruleset: GameMode | None = None, session: AsyncSession = Depends(get_db), + current_user: User = Security(get_current_user, scopes=["public"]), ): searched_user = ( await session.exec( @@ -91,7 +93,7 @@ async def get_user_info( async def get_user_beatmapsets( user_id: int, type: BeatmapsetType, - current_user: User = Depends(get_current_user), + current_user: User = Security(get_current_user, scopes=["public"]), session: AsyncSession = Depends(get_db), limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0), @@ -147,6 +149,7 @@ async def get_user_scores( limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0), session: AsyncSession = Depends(get_db), + current_user: User = Security(get_current_user, scopes=["public"]), ): db_user = await session.get(User, user) if not db_user: diff --git a/app/signalr/router.py b/app/signalr/router.py index 237a575..053117c 100644 --- a/app/signalr/router.py +++ b/app/signalr/router.py @@ -9,13 +9,13 @@ import uuid from app.database import User as DBUser from app.dependencies import get_current_user from app.dependencies.database import get_db -from app.dependencies.user import get_current_user_by_token from app.models.signalr import NegotiateResponse, Transport from .hub import Hubs from .packet import PROTOCOLS, SEP -from fastapi import APIRouter, Depends, Header, Query, WebSocket +from fastapi import APIRouter, Depends, Header, HTTPException, Query, WebSocket +from fastapi.security import SecurityScopes from sqlmodel.ext.asyncio.session import AsyncSession router = APIRouter() @@ -55,9 +55,15 @@ async def connect( if id not in hub_: await websocket.close(code=1008) return - if (user := await get_current_user_by_token(token, db)) is None or str( - user.id - ) != user_id: + try: + if ( + user := await get_current_user( + SecurityScopes(scopes=["*"]), db, token_pw=token + ) + ) is None or str(user.id) != user_id: + await websocket.close(code=1008) + return + except HTTPException: await websocket.close(code=1008) return await websocket.accept() diff --git a/main.py b/main.py index 5c88a6b..3f3522c 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ from app.config import settings from app.dependencies.database import engine, redis_client from app.dependencies.fetcher import get_fetcher from app.dependencies.scheduler import init_scheduler, stop_scheduler +from app.log import logger from app.router import ( api_router, auth_router, @@ -52,9 +53,19 @@ async def health_check(): return {"status": "ok", "timestamp": datetime.utcnow().isoformat()} -if __name__ == "__main__": - from app.log import logger # noqa: F401 +if settings.secret_key == "your_jwt_secret_here": + logger.warning( + "jwt_secret_key is unset. Your server is unsafe. " + "Use this command to generate: openssl rand -hex 32" + ) +if settings.osu_web_client_secret == "your_osu_web_client_secret_here": + logger.warning( + "osu_web_client_secret is unset. Your server is unsafe. " + "Use this command to generate: openssl rand -hex 40" + ) + +if __name__ == "__main__": import uvicorn uvicorn.run( diff --git a/migrations/versions/a8669ba11e96_auth_support_custom_client.py b/migrations/versions/a8669ba11e96_auth_support_custom_client.py new file mode 100644 index 0000000..0a765a2 --- /dev/null +++ b/migrations/versions/a8669ba11e96_auth_support_custom_client.py @@ -0,0 +1,67 @@ +"""auth: support custom client + +Revision ID: a8669ba11e96 +Revises: aa582c13f905 +Create Date: 2025-08-11 11:47:11.004301 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = "a8669ba11e96" +down_revision: str | Sequence[str] | None = "aa582c13f905" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "oauth_clients", + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("client_secret", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("redirect_uris", sa.JSON(), nullable=True), + sa.Column("owner_id", sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint( + ["owner_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("client_id"), + ) + op.create_index( + op.f("ix_oauth_clients_client_id"), "oauth_clients", ["client_id"], unique=False + ) + op.create_index( + op.f("ix_oauth_clients_client_secret"), + "oauth_clients", + ["client_secret"], + unique=False, + ) + op.create_index( + op.f("ix_oauth_clients_owner_id"), "oauth_clients", ["owner_id"], unique=False + ) + op.add_column("oauth_tokens", sa.Column("client_id", sa.Integer(), nullable=False)) + op.create_index( + op.f("ix_oauth_tokens_client_id"), "oauth_tokens", ["client_id"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_oauth_tokens_client_id"), table_name="oauth_tokens") + op.drop_column("oauth_tokens", "client_id") + op.drop_index(op.f("ix_oauth_clients_owner_id"), table_name="oauth_clients") + op.drop_index(op.f("ix_oauth_clients_client_secret"), table_name="oauth_clients") + op.drop_index(op.f("ix_oauth_clients_client_id"), table_name="oauth_clients") + op.drop_table("oauth_clients") + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index a687f17..b717f26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "aiomysql>=0.2.0", "alembic>=1.12.1", "apscheduler>=3.11.0", + "authlib>=1.6.1", "bcrypt>=4.1.2", "cryptography>=41.0.7", "fastapi>=0.104.1", diff --git a/uv.lock b/uv.lock index ffc6105..5609771 100644 --- a/uv.lock +++ b/uv.lock @@ -69,6 +69,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, ] +[[package]] +name = "authlib" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/a1/d8d1c6f8bc922c0b87ae0d933a8ed57be1bef6970894ed79c2852a153cd3/authlib-1.6.1.tar.gz", hash = "sha256:4dffdbb1460ba6ec8c17981a4c67af7d8af131231b5a36a88a1e8c80c111cdfd", size = 159988, upload-time = "2025-07-20T07:38:42.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/58/cc6a08053f822f98f334d38a27687b69c6655fb05cd74a7a5e70a2aeed95/authlib-1.6.1-py2.py3-none-any.whl", hash = "sha256:e9d2031c34c6309373ab845afc24168fe9e93dc52d252631f52642f21f5ed06e", size = 239299, upload-time = "2025-07-20T07:38:39.259Z" }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -506,6 +518,7 @@ dependencies = [ { name = "aiomysql" }, { name = "alembic" }, { name = "apscheduler" }, + { name = "authlib" }, { name = "bcrypt" }, { name = "cryptography" }, { name = "fastapi" }, @@ -536,6 +549,7 @@ requires-dist = [ { name = "aiomysql", specifier = ">=0.2.0" }, { name = "alembic", specifier = ">=1.12.1" }, { name = "apscheduler", specifier = ">=3.11.0" }, + { name = "authlib", specifier = ">=1.6.1" }, { name = "bcrypt", specifier = ">=4.1.2" }, { name = "cryptography", specifier = ">=41.0.7" }, { name = "fastapi", specifier = ">=0.104.1" }, From 1c233ed31c42c0c2dce95d0abed5eceb04b4b460 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 11 Aug 2025 12:37:46 +0000 Subject: [PATCH 093/104] chore(deps): remove unused dependencies `authlib` --- pyproject.toml | 1 - uv.lock | 14 -------------- 2 files changed, 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b717f26..a687f17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [ "aiomysql>=0.2.0", "alembic>=1.12.1", "apscheduler>=3.11.0", - "authlib>=1.6.1", "bcrypt>=4.1.2", "cryptography>=41.0.7", "fastapi>=0.104.1", diff --git a/uv.lock b/uv.lock index 5609771..ffc6105 100644 --- a/uv.lock +++ b/uv.lock @@ -69,18 +69,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, ] -[[package]] -name = "authlib" -version = "1.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8e/a1/d8d1c6f8bc922c0b87ae0d933a8ed57be1bef6970894ed79c2852a153cd3/authlib-1.6.1.tar.gz", hash = "sha256:4dffdbb1460ba6ec8c17981a4c67af7d8af131231b5a36a88a1e8c80c111cdfd", size = 159988, upload-time = "2025-07-20T07:38:42.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/58/cc6a08053f822f98f334d38a27687b69c6655fb05cd74a7a5e70a2aeed95/authlib-1.6.1-py2.py3-none-any.whl", hash = "sha256:e9d2031c34c6309373ab845afc24168fe9e93dc52d252631f52642f21f5ed06e", size = 239299, upload-time = "2025-07-20T07:38:39.259Z" }, -] - [[package]] name = "bcrypt" version = "4.3.0" @@ -518,7 +506,6 @@ dependencies = [ { name = "aiomysql" }, { name = "alembic" }, { name = "apscheduler" }, - { name = "authlib" }, { name = "bcrypt" }, { name = "cryptography" }, { name = "fastapi" }, @@ -549,7 +536,6 @@ requires-dist = [ { name = "aiomysql", specifier = ">=0.2.0" }, { name = "alembic", specifier = ">=1.12.1" }, { name = "apscheduler", specifier = ">=3.11.0" }, - { name = "authlib", specifier = ">=1.6.1" }, { name = "bcrypt", specifier = ">=4.1.2" }, { name = "cryptography", specifier = ">=41.0.7" }, { name = "fastapi", specifier = ">=0.104.1" }, From bc5ae3083adcdd1000e1433772949859fb92ea00 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 11 Aug 2025 13:23:04 +0000 Subject: [PATCH 094/104] feat(signalr): kick device on a login on another device --- app/signalr/hub/hub.py | 15 +++++++++++++++ app/signalr/router.py | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/app/signalr/hub/hub.py b/app/signalr/hub/hub.py index 92fc90d..3c8fe98 100644 --- a/app/signalr/hub/hub.py +++ b/app/signalr/hub/hub.py @@ -99,6 +99,16 @@ class Hub[TState: UserState]: return client return default + def get_before_clients(self, id: str, current_token: str) -> list[Client]: + clients = [] + for client in self.clients.values(): + if client.connection_id != id: + continue + if client.connection_token == current_token: + continue + clients.append(client) + return clients + @abstractmethod def create_state(self, client: Client) -> TState: raise NotImplementedError @@ -117,6 +127,11 @@ class Hub[TState: UserState]: if group_id in self.groups: self.groups[group_id].discard(client) + async def kick_client(self, client: Client) -> None: + await self.call_noblock(client, "DisconnectRequested") + await client.send_packet(ClosePacket(allow_reconnect=False)) + await client.connection.close(code=1000, reason="Disconnected by server") + async def add_client( self, connection_id: str, diff --git a/app/signalr/router.py b/app/signalr/router.py index 053117c..1b50fcf 100644 --- a/app/signalr/router.py +++ b/app/signalr/router.py @@ -98,6 +98,11 @@ async def connect( if error or not client: await websocket.close(code=1008) return + + connected_clients = hub_.get_before_clients(user_id, id) + for connected_client in connected_clients: + await hub_.kick_client(connected_client) + await hub_.clean_state(client, False) task = asyncio.create_task(hub_.on_connect(client)) hub_.tasks.add(task) From 867b99cca51a0a0d23a5ada616899f7c2efc79c7 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 11 Aug 2025 14:01:37 +0000 Subject: [PATCH 095/104] fix(database): fix cross-session user (`current_user` doesn't belong to `get_db`) --- app/dependencies/database.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/dependencies/database.py b/app/dependencies/database.py index c3ec9a4..1ced03c 100644 --- a/app/dependencies/database.py +++ b/app/dependencies/database.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextvars import ContextVar import json from app.config import settings @@ -25,8 +26,22 @@ redis_client = redis.from_url(settings.redis_url, decode_responses=True) # 数据库依赖 +db_session_context: ContextVar[AsyncSession | None] = ContextVar( + "db_session_context", default=None +) + + async def get_db(): - async with AsyncSession(engine) as session: + session = db_session_context.get() + if session is None: + session = AsyncSession(engine) + db_session_context.set(session) + try: + yield session + finally: + await session.close() + db_session_context.set(None) + else: yield session From d8c607137a92ea0b07453da8f3218afd86f79887 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 11 Aug 2025 14:38:45 +0000 Subject: [PATCH 096/104] refactor(router): restructure --- app/router/__init__.py | 19 ++----------------- app/router/fetcher.py | 2 +- app/router/v2/__init__.py | 19 +++++++++++++++++++ app/router/{ => v2}/beatmap.py | 2 +- app/router/{ => v2}/beatmapset.py | 2 +- app/router/{ => v2}/me.py | 2 +- app/router/{ => v2}/misc.py | 2 +- app/router/{ => v2}/relationship.py | 2 +- app/router/{ => v2}/room.py | 2 +- app/router/{api_router.py => v2/router.py} | 2 +- app/router/{ => v2}/score.py | 2 +- app/router/{ => v2}/user.py | 2 +- app/signalr/router.py | 2 +- main.py | 9 ++++----- 14 files changed, 36 insertions(+), 33 deletions(-) create mode 100644 app/router/v2/__init__.py rename app/router/{ => v2}/beatmap.py (99%) rename app/router/{ => v2}/beatmapset.py (99%) rename app/router/{ => v2}/me.py (96%) rename app/router/{ => v2}/misc.py (94%) rename app/router/{ => v2}/relationship.py (99%) rename app/router/{ => v2}/room.py (99%) rename app/router/{api_router.py => v2/router.py} (64%) rename app/router/{ => v2}/score.py (99%) rename app/router/{ => v2}/user.py (99%) diff --git a/app/router/__init__.py b/app/router/__init__.py index bc82905..4a4fb9b 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -2,23 +2,8 @@ from __future__ import annotations from app.signalr import signalr_router as signalr_router -from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401 - beatmap, - beatmapset, - me, - misc, - relationship, - room, - score, - user, -) -from .api_router import router as api_router from .auth import router as auth_router from .fetcher import fetcher_router as fetcher_router +from .v2 import api_v2_router as api_v2_router -__all__ = [ - "api_router", - "auth_router", - "fetcher_router", - "signalr_router", -] +__all__ = ["api_v2_router", "auth_router", "fetcher_router", "signalr_router"] diff --git a/app/router/fetcher.py b/app/router/fetcher.py index 013aaa0..1d0bdca 100644 --- a/app/router/fetcher.py +++ b/app/router/fetcher.py @@ -5,7 +5,7 @@ from app.fetcher import Fetcher from fastapi import APIRouter, Depends -fetcher_router = APIRouter() +fetcher_router = APIRouter(prefix="/fetcher", tags=["fetcher"]) @fetcher_router.get("/callback") diff --git a/app/router/v2/__init__.py b/app/router/v2/__init__.py new file mode 100644 index 0000000..7e09509 --- /dev/null +++ b/app/router/v2/__init__.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from app.signalr import signalr_router as signalr_router + +from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401 + beatmap, + beatmapset, + me, + misc, + relationship, + room, + score, + user, +) +from .router import router as api_v2_router + +__all__ = [ + "api_v2_router", +] diff --git a/app/router/beatmap.py b/app/router/v2/beatmap.py similarity index 99% rename from app/router/beatmap.py rename to app/router/v2/beatmap.py index 123c260..a621a6c 100644 --- a/app/router/beatmap.py +++ b/app/router/v2/beatmap.py @@ -17,7 +17,7 @@ from app.models.score import ( GameMode, ) -from .api_router import router +from .router import router from fastapi import Depends, HTTPException, Query, Security from httpx import HTTPError, HTTPStatusError diff --git a/app/router/beatmapset.py b/app/router/v2/beatmapset.py similarity index 99% rename from app/router/beatmapset.py rename to app/router/v2/beatmapset.py index 09f1aeb..ef5faba 100644 --- a/app/router/beatmapset.py +++ b/app/router/v2/beatmapset.py @@ -8,7 +8,7 @@ from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher -from .api_router import router +from .router import router from fastapi import Depends, Form, HTTPException, Query, Security from fastapi.responses import RedirectResponse diff --git a/app/router/me.py b/app/router/v2/me.py similarity index 96% rename from app/router/me.py rename to app/router/v2/me.py index 28b37ea..b2fd7b6 100644 --- a/app/router/me.py +++ b/app/router/v2/me.py @@ -6,7 +6,7 @@ from app.dependencies import get_current_user from app.dependencies.database import get_db from app.models.score import GameMode -from .api_router import router +from .router import router from fastapi import Depends, Security from sqlmodel.ext.asyncio.session import AsyncSession diff --git a/app/router/misc.py b/app/router/v2/misc.py similarity index 94% rename from app/router/misc.py rename to app/router/v2/misc.py index 1921008..06baf34 100644 --- a/app/router/misc.py +++ b/app/router/v2/misc.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime from app.config import settings -from .api_router import router +from .router import router from pydantic import BaseModel diff --git a/app/router/relationship.py b/app/router/v2/relationship.py similarity index 99% rename from app/router/relationship.py rename to app/router/v2/relationship.py index b63b3a8..fc5c676 100644 --- a/app/router/relationship.py +++ b/app/router/v2/relationship.py @@ -4,7 +4,7 @@ from app.database import Relationship, RelationshipResp, RelationshipType, User from app.dependencies.database import get_db from app.dependencies.user import get_current_user -from .api_router import router +from .router import router from fastapi import Depends, HTTPException, Query, Request, Security from pydantic import BaseModel diff --git a/app/router/room.py b/app/router/v2/room.py similarity index 99% rename from app/router/room.py rename to app/router/v2/room.py index 3f65fcc..217c716 100644 --- a/app/router/room.py +++ b/app/router/v2/room.py @@ -18,7 +18,7 @@ from app.models.room import RoomCategory, RoomStatus from app.service.room import create_playlist_room_from_api from app.signalr.hub import MultiplayerHubs -from .api_router import router +from .router import router from fastapi import Depends, HTTPException, Query, Security from pydantic import BaseModel, Field diff --git a/app/router/api_router.py b/app/router/v2/router.py similarity index 64% rename from app/router/api_router.py rename to app/router/v2/router.py index 6a3e356..0bd0b4b 100644 --- a/app/router/api_router.py +++ b/app/router/v2/router.py @@ -2,4 +2,4 @@ from __future__ import annotations from fastapi import APIRouter -router = APIRouter() +router = APIRouter(prefix="/api/v2") diff --git a/app/router/score.py b/app/router/v2/score.py similarity index 99% rename from app/router/score.py rename to app/router/v2/score.py index a31030a..7ebd757 100644 --- a/app/router/score.py +++ b/app/router/v2/score.py @@ -44,7 +44,7 @@ from app.models.score import ( ) from app.path import REPLAY_DIR -from .api_router import router +from .router import router from fastapi import Body, Depends, Form, HTTPException, Query, Security from fastapi.responses import FileResponse diff --git a/app/router/user.py b/app/router/v2/user.py similarity index 99% rename from app/router/user.py rename to app/router/v2/user.py index eb92552..95b8640 100644 --- a/app/router/user.py +++ b/app/router/v2/user.py @@ -18,7 +18,7 @@ from app.dependencies.user import get_current_user from app.models.score import GameMode from app.models.user import BeatmapsetType -from .api_router import router +from .router import router from fastapi import Depends, HTTPException, Query, Security from pydantic import BaseModel diff --git a/app/signalr/router.py b/app/signalr/router.py index 1b50fcf..cb63d6b 100644 --- a/app/signalr/router.py +++ b/app/signalr/router.py @@ -18,7 +18,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, WebSocket from fastapi.security import SecurityScopes from sqlmodel.ext.asyncio.session import AsyncSession -router = APIRouter() +router = APIRouter(prefix="/signalr", tags=["SignalR"]) @router.post("/{hub}/negotiate", response_model=NegotiateResponse) diff --git a/main.py b/main.py index 3f3522c..774a7b9 100644 --- a/main.py +++ b/main.py @@ -9,7 +9,7 @@ from app.dependencies.fetcher import get_fetcher from app.dependencies.scheduler import init_scheduler, stop_scheduler from app.log import logger from app.router import ( - api_router, + api_v2_router, auth_router, fetcher_router, signalr_router, @@ -35,9 +35,9 @@ async def lifespan(app: FastAPI): app = FastAPI(title="osu! API 模拟服务器", version="1.0.0", lifespan=lifespan) -app.include_router(api_router, prefix="/api/v2") -app.include_router(signalr_router, prefix="/signalr") -app.include_router(fetcher_router, prefix="/fetcher") +app.include_router(api_v2_router) +app.include_router(signalr_router) +app.include_router(fetcher_router) app.include_router(auth_router) @@ -64,7 +64,6 @@ if settings.osu_web_client_secret == "your_osu_web_client_secret_here": "Use this command to generate: openssl rand -hex 40" ) - if __name__ == "__main__": import uvicorn From 8acd4578e29e494bc7724417a1f2683e803a09f7 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 11 Aug 2025 14:41:07 +0000 Subject: [PATCH 097/104] feat(private): initialize private API --- .env.example | 2 ++ README.md | 1 + app/config.py | 1 + app/router/__init__.py | 9 +++++++- app/router/private/__init__.py | 7 ++++++ app/router/private/router.py | 39 ++++++++++++++++++++++++++++++++++ main.py | 7 ++++++ 7 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 app/router/private/__init__.py create mode 100644 app/router/private/router.py diff --git a/.env.example b/.env.example index a713dbb..cec6fbd 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,8 @@ HOST="0.0.0.0" PORT=8000 # 调试模式,生产环境请设置为 false DEBUG=false +# 私有 API 密钥,用于前后端 API 调用,使用 openssl rand -hex 32 生成 +PRIVATE_API_SECRET="your_private_api_secret_here" # osu! 登录设置 OSU_CLIENT_ID=5 # lazer client ID diff --git a/README.md b/README.md index 4bf5445..927767a 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ docker-compose -f docker-compose-osurx.yml up -d | `HOST` | 服务器监听地址 | `0.0.0.0` | | `PORT` | 服务器监听端口 | `8000` | | `DEBUG` | 调试模式 | `false` | +| `PRIVATE_API_SECRET` | 私有 API 密钥,用于前后端 API 调用 | `your_private_api_secret_here` | ### OAuth 设置 | 变量名 | 描述 | 默认值 | diff --git a/app/config.py b/app/config.py index 004971d..37cef2d 100644 --- a/app/config.py +++ b/app/config.py @@ -37,6 +37,7 @@ class Settings(BaseSettings): host: str = "0.0.0.0" port: int = 8000 debug: bool = False + private_api_secret: str = "your_private_api_secret_here" # SignalR 设置 signalr_negotiate_timeout: int = 30 diff --git a/app/router/__init__.py b/app/router/__init__.py index 4a4fb9b..b8d722b 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -4,6 +4,13 @@ from app.signalr import signalr_router as signalr_router from .auth import router as auth_router from .fetcher import fetcher_router as fetcher_router +from .private import private_router as private_router from .v2 import api_v2_router as api_v2_router -__all__ = ["api_v2_router", "auth_router", "fetcher_router", "signalr_router"] +__all__ = [ + "api_v2_router", + "auth_router", + "fetcher_router", + "private_router", + "signalr_router", +] diff --git a/app/router/private/__init__.py b/app/router/private/__init__.py new file mode 100644 index 0000000..4e7fb7a --- /dev/null +++ b/app/router/private/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .router import router as private_router + +__all__ = [ + "private_router", +] diff --git a/app/router/private/router.py b/app/router/private/router.py new file mode 100644 index 0000000..1d3a6e4 --- /dev/null +++ b/app/router/private/router.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import hashlib +import hmac +import time + +from app.config import settings + +from fastapi import APIRouter, Depends, Header, HTTPException, Request + + +async def verify_signature( + request: Request, + ts: int = Header(..., alias="X-Timestamp"), + nonce: str = Header(..., alias="X-Nonce"), + signature: str = Header(..., alias="X-Signature"), +): + path = request.url.path + data = await request.body() + body = data.decode("utf-8") + + py_ts = ts // 1000 + if abs(time.time() - py_ts) > 30: + raise HTTPException(status_code=403, detail="Invalid timestamp") + + payload = f"{path}|{body}|{ts}|{nonce}" + expected_sig = hmac.new( + settings.private_api_secret.encode(), payload.encode(), hashlib.sha256 + ).hexdigest() + + if not hmac.compare_digest(expected_sig, signature): + raise HTTPException(status_code=403, detail="Invalid signature") + + +router = APIRouter( + prefix="/api/private", + dependencies=[Depends(verify_signature)], + include_in_schema=False, +) diff --git a/main.py b/main.py index 774a7b9..5c56eb0 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ from app.router import ( api_v2_router, auth_router, fetcher_router, + private_router, signalr_router, ) from app.service.daily_challenge import daily_challenge_job @@ -39,6 +40,7 @@ app.include_router(api_v2_router) app.include_router(signalr_router) app.include_router(fetcher_router) app.include_router(auth_router) +app.include_router(private_router) @app.get("/") @@ -63,6 +65,11 @@ if settings.osu_web_client_secret == "your_osu_web_client_secret_here": "osu_web_client_secret is unset. Your server is unsafe. " "Use this command to generate: openssl rand -hex 40" ) +if settings.private_api_secret == "your_private_api_secret_here": + logger.warning( + "private_api_secret is unset. Your server is unsafe. " + "Use this command to generate: openssl rand -hex 32" + ) if __name__ == "__main__": import uvicorn From abb53e848848193b5edf9913ffb701014b1ed1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=92=95=E8=B0=B7=E9=85=B1?= <74496778+GooGuJiang@users.noreply.github.com> Date: Tue, 12 Aug 2025 00:58:30 +0800 Subject: [PATCH 098/104] add cors --- main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/main.py b/main.py index 5c56eb0..2081aa0 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ from app.service.daily_challenge import daily_challenge_job from app.service.osu_rx_statistics import create_rx_statistics from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware @asynccontextmanager @@ -36,6 +37,15 @@ async def lifespan(app: FastAPI): app = FastAPI(title="osu! API 模拟服务器", version="1.0.0", lifespan=lifespan) + +# CORS 配置 +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) app.include_router(api_v2_router) app.include_router(signalr_router) app.include_router(fetcher_router) From 79b41010d5b6a1166c11da783ad1b6b57daf3149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=92=95=E8=B0=B7=E9=85=B1?= <74496778+GooGuJiang@users.noreply.github.com> Date: Tue, 12 Aug 2025 01:18:52 +0800 Subject: [PATCH 099/104] Update __init__.py --- app/router/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/router/__init__.py b/app/router/__init__.py index b8d722b..a54de39 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -5,7 +5,7 @@ from app.signalr import signalr_router as signalr_router from .auth import router as auth_router from .fetcher import fetcher_router as fetcher_router from .private import private_router as private_router -from .v2 import api_v2_router as api_v2_router +from .v2.router import router as api_v2_router __all__ = [ "api_v2_router", From cf3a6bbd211dfed25388f9dcaedf714cd71570ea Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Tue, 12 Aug 2025 03:58:06 +0000 Subject: [PATCH 100/104] feat(storage): support cloud storage --- .env.example | 26 ++ README.md | 47 +++ app/config.py | 61 +++- app/dependencies/storage.py | 52 +++ app/storage/__init__.py | 13 + app/storage/aws_s3.py | 103 ++++++ app/storage/base.py | 34 ++ app/storage/cloudflare_r2.py | 26 ++ app/storage/local.py | 78 +++++ pyproject.toml | 3 + uv.lock | 633 +++++++++++++++++++++++++++++++++++ 11 files changed, 1075 insertions(+), 1 deletion(-) create mode 100644 app/dependencies/storage.py create mode 100644 app/storage/__init__.py create mode 100644 app/storage/aws_s3.py create mode 100644 app/storage/base.py create mode 100644 app/storage/cloudflare_r2.py create mode 100644 app/storage/local.py diff --git a/.env.example b/.env.example index cec6fbd..158ccd7 100644 --- a/.env.example +++ b/.env.example @@ -49,3 +49,29 @@ ENABLE_ALL_MODS_PP=false # 启用所有 Mod 的 PP 计算 ENABLE_SUPPORTER_FOR_ALL_USERS=false # 启用所有新注册用户的支持者状态 ENABLE_ALL_BEATMAP_LEADERBOARD=false # 启用所有谱面的排行榜(没有排行榜的谱面会以 APPROVED 状态返回) SEASONAL_BACKGROUNDS='[]' # 季节背景图 URL 列表 + +# 存储服务设置 +# 支持的存储类型:local(本地存储)、r2(Cloudflare R2)、s3(AWS S3) +STORAGE_SERVICE="local" + +# 存储服务配置 (JSON 格式) +# 本地存储配置(当 STORAGE_SERVICE=local 时) +STORAGE_SETTINGS='{"local_storage_path": "./storage"}' + +# Cloudflare R2 存储配置(当 STORAGE_SERVICE=r2 时) +# STORAGE_SETTINGS='{ +# "r2_account_id": "your_cloudflare_r2_account_id", +# "r2_access_key_id": "your_r2_access_key_id", +# "r2_secret_access_key": "your_r2_secret_access_key", +# "r2_bucket_name": "your_r2_bucket_name", +# "r2_public_url_base": "https://your-custom-domain.com" +# }' + +# AWS S3 存储配置(当 STORAGE_SERVICE=s3 时) +# STORAGE_SETTINGS='{ +# "s3_access_key_id": "your_aws_access_key_id", +# "s3_secret_access_key": "your_aws_secret_access_key", +# "s3_bucket_name": "your_s3_bucket_name", +# "s3_region_name": "us-east-1", +# "s3_public_url_base": "https://your-custom-domain.com" +# }' diff --git a/README.md b/README.md index 927767a..5b77d61 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - **多游戏模式支持**: osu! (osu!rx, osu!ap), taiko, fruits, mania - **数据库持久化**: MySQL 存储用户数据 - **缓存支持**: Redis 缓存令牌和会话信息 +- **多种存储后端**: 支持本地存储、Cloudflare R2、AWS S3 - **容器化部署**: Docker 和 Docker Compose 支持 ## 快速开始 @@ -109,6 +110,52 @@ Fetcher 用于从 osu! 官方 API 获取数据,使用 osu! 官方 API 的 OAut | `ENABLE_ALL_BEATMAP_LEADERBOARD` | 启用所有谱面的排行榜 | `false` | | `SEASONAL_BACKGROUNDS` | 季节背景图 URL 列表 | `[]` | +### 存储服务设置 + +用于存储回放文件、头像等静态资源。 + +| 变量名 | 描述 | 默认值 | +|--------|------|--------| +| `STORAGE_SERVICE` | 存储服务类型:`local`、`r2`、`s3` | `local` | +| `STORAGE_SETTINGS` | 存储服务配置 (JSON 格式),配置见下 | `{"local_storage_path": "./storage"}` | + +## 存储服务配置 + +### 本地存储 (推荐用于开发环境) + +本地存储将文件保存在服务器的本地文件系统中,适合开发和小规模部署。 + +```bash +STORAGE_SERVICE="local" +STORAGE_SETTINGS='{"local_storage_path": "./storage"}' +``` + +### Cloudflare R2 存储 (推荐用于生产环境) + +```bash +STORAGE_SERVICE="r2" +STORAGE_SETTINGS='{ + "r2_account_id": "your_cloudflare_account_id", + "r2_access_key_id": "your_r2_access_key_id", + "r2_secret_access_key": "your_r2_secret_access_key", + "r2_bucket_name": "your_bucket_name", + "r2_public_url_base": "https://your-custom-domain.com" +}' +``` + +### AWS S3 存储 + +```bash +STORAGE_SERVICE="s3" +STORAGE_SETTINGS='{ + "s3_access_key_id": "your_aws_access_key_id", + "s3_secret_access_key": "your_aws_secret_access_key", + "s3_bucket_name": "your_s3_bucket_name", + "s3_region_name": "us-east-1", + "s3_public_url_base": "https://your-custom-domain.com" +}' +``` + > **注意**: 在生产环境中,请务必更改默认的密钥和密码! ### 更新数据库 diff --git a/app/config.py b/app/config.py index 37cef2d..397cfe3 100644 --- a/app/config.py +++ b/app/config.py @@ -1,11 +1,38 @@ from __future__ import annotations +from enum import Enum from typing import Annotated, Any -from pydantic import Field, field_validator +from pydantic import Field, ValidationInfo, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict +class AWSS3StorageSettings(BaseSettings): + s3_access_key_id: str + s3_secret_access_key: str + s3_bucket_name: str + s3_region_name: str + s3_public_url_base: str | None = None + + +class CloudflareR2Settings(BaseSettings): + r2_account_id: str + r2_access_key_id: str + r2_secret_access_key: str + r2_bucket_name: str + r2_public_url_base: str | None = None + + +class LocalStorageSettings(BaseSettings): + local_storage_path: str = "./storage" + + +class StorageServiceType(str, Enum): + LOCAL = "local" + CLOUDFLARE_R2 = "r2" + AWS_S3 = "s3" + + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") @@ -60,11 +87,43 @@ class Settings(BaseSettings): enable_all_beatmap_leaderboard: bool = False seasonal_backgrounds: list[str] = [] + # 存储设置 + storage_service: StorageServiceType = StorageServiceType.LOCAL + storage_settings: ( + LocalStorageSettings | CloudflareR2Settings | AWSS3StorageSettings + ) = LocalStorageSettings() + @field_validator("fetcher_scopes", mode="before") def validate_fetcher_scopes(cls, v: Any) -> list[str]: if isinstance(v, str): return v.split(",") return v + @field_validator("storage_settings", mode="after") + def validate_storage_settings( + cls, + v: LocalStorageSettings | CloudflareR2Settings | AWSS3StorageSettings, + info: ValidationInfo, + ) -> LocalStorageSettings | CloudflareR2Settings | AWSS3StorageSettings: + if info.data.get("storage_service") == StorageServiceType.CLOUDFLARE_R2: + if not isinstance(v, CloudflareR2Settings): + raise ValueError( + "When storage_service is 'r2', " + "storage_settings must be CloudflareR2Settings" + ) + elif info.data.get("storage_service") == StorageServiceType.LOCAL: + if not isinstance(v, LocalStorageSettings): + raise ValueError( + "When storage_service is 'local', " + "storage_settings must be LocalStorageSettings" + ) + elif info.data.get("storage_service") == StorageServiceType.AWS_S3: + if not isinstance(v, AWSS3StorageSettings): + raise ValueError( + "When storage_service is 's3', " + "storage_settings must be AWSS3StorageSettings" + ) + return v + settings = Settings() diff --git a/app/dependencies/storage.py b/app/dependencies/storage.py new file mode 100644 index 0000000..22906e0 --- /dev/null +++ b/app/dependencies/storage.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import cast + +from app.config import ( + AWSS3StorageSettings, + CloudflareR2Settings, + LocalStorageSettings, + StorageServiceType, + settings, +) +from app.storage import StorageService +from app.storage.cloudflare_r2 import AWSS3StorageService, CloudflareR2StorageService +from app.storage.local import LocalStorageService + +storage: StorageService | None = None + + +def init_storage_service(): + global storage + if settings.storage_service == StorageServiceType.LOCAL: + storage_settings = cast(LocalStorageSettings, settings.storage_settings) + storage = LocalStorageService( + storage_path=storage_settings.local_storage_path, + ) + elif settings.storage_service == StorageServiceType.CLOUDFLARE_R2: + storage_settings = cast(CloudflareR2Settings, settings.storage_settings) + storage = CloudflareR2StorageService( + account_id=storage_settings.r2_account_id, + access_key_id=storage_settings.r2_access_key_id, + secret_access_key=storage_settings.r2_secret_access_key, + bucket_name=storage_settings.r2_bucket_name, + public_url_base=storage_settings.r2_public_url_base, + ) + elif settings.storage_service == StorageServiceType.AWS_S3: + storage_settings = cast(AWSS3StorageSettings, settings.storage_settings) + storage = AWSS3StorageService( + access_key_id=storage_settings.s3_access_key_id, + secret_access_key=storage_settings.s3_secret_access_key, + bucket_name=storage_settings.s3_bucket_name, + public_url_base=storage_settings.s3_public_url_base, + region_name=storage_settings.s3_region_name, + ) + else: + raise ValueError(f"Unsupported storage service: {settings.storage_service}") + return storage + + +def get_storage_service(): + if storage is None: + return init_storage_service() + return storage diff --git a/app/storage/__init__.py b/app/storage/__init__.py new file mode 100644 index 0000000..99d50ff --- /dev/null +++ b/app/storage/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .aws_s3 import AWSS3StorageService +from .base import StorageService +from .cloudflare_r2 import CloudflareR2StorageService +from .local import LocalStorageService + +__all__ = [ + "AWSS3StorageService", + "CloudflareR2StorageService", + "LocalStorageService", + "StorageService", +] diff --git a/app/storage/aws_s3.py b/app/storage/aws_s3.py new file mode 100644 index 0000000..4c8e6d9 --- /dev/null +++ b/app/storage/aws_s3.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from .base import StorageService + +import aioboto3 +from botocore.exceptions import ClientError + + +class AWSS3StorageService(StorageService): + def __init__( + self, + access_key_id: str, + secret_access_key: str, + bucket_name: str, + region_name: str, + public_url_base: str | None = None, + ): + self.bucket_name = bucket_name + self.public_url_base = public_url_base + self.session = aioboto3.Session() + self.access_key_id = access_key_id + self.secret_access_key = secret_access_key + self.region_name = region_name + + @property + def endpoint_url(self) -> str | None: + return None + + def _get_client(self): + return self.session.client( + "s3", + endpoint_url=self.endpoint_url, + aws_access_key_id=self.access_key_id, + aws_secret_access_key=self.secret_access_key, + region_name=self.region_name, + ) + + async def write_file( + self, + file_path: str, + content: bytes, + content_type: str = "application/octet-stream", + cache_control: str = "public, max-age=31536000", + ) -> None: + async with self._get_client() as client: + await client.put_object( + Bucket=self.bucket_name, + Key=file_path, + Body=content, + ContentType=content_type, + CacheControl=cache_control, + ) + + async def read_file(self, file_path: str) -> bytes: + async with self._get_client() as client: + try: + response = await client.get_object( + Bucket=self.bucket_name, + Key=file_path, + ) + async with response["Body"] as stream: + return await stream.read() + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "404": + raise FileNotFoundError(f"File not found: {file_path}") + raise RuntimeError(f"Failed to read file from R2: {e}") + + async def delete_file(self, file_path: str) -> None: + async with self._get_client() as client: + try: + await client.delete_object( + Bucket=self.bucket_name, + Key=file_path, + ) + except ClientError as e: + raise RuntimeError(f"Failed to delete file from R2: {e}") + + async def is_exists(self, file_path: str) -> bool: + async with self._get_client() as client: + try: + await client.head_object( + Bucket=self.bucket_name, + Key=file_path, + ) + return True + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "404": + return False + raise RuntimeError(f"Failed to check file existence in R2: {e}") + + async def get_file_url(self, file_path: str) -> str: + if self.public_url_base: + return f"{self.public_url_base.rstrip('/')}/{file_path.lstrip('/')}" + + async with self._get_client() as client: + try: + url = await client.generate_presigned_url( + "get_object", + Params={"Bucket": self.bucket_name, "Key": file_path}, + ) + return url + except ClientError as e: + raise RuntimeError(f"Failed to generate file URL: {e}") diff --git a/app/storage/base.py b/app/storage/base.py new file mode 100644 index 0000000..534d7a1 --- /dev/null +++ b/app/storage/base.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import abc + + +class StorageService(abc.ABC): + @abc.abstractmethod + async def write_file( + self, + file_path: str, + content: bytes, + content_type: str = "application/octet-stream", + cache_control: str = "public, max-age=31536000", + ) -> None: + raise NotImplementedError + + @abc.abstractmethod + async def read_file(self, file_path: str) -> bytes: + raise NotImplementedError + + @abc.abstractmethod + async def delete_file(self, file_path: str) -> None: + raise NotImplementedError + + @abc.abstractmethod + async def is_exists(self, file_path: str) -> bool: + raise NotImplementedError + + @abc.abstractmethod + async def get_file_url(self, file_path: str) -> str: + raise NotImplementedError + + async def close(self) -> None: + pass diff --git a/app/storage/cloudflare_r2.py b/app/storage/cloudflare_r2.py new file mode 100644 index 0000000..fbb08c7 --- /dev/null +++ b/app/storage/cloudflare_r2.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from .aws_s3 import AWSS3StorageService + + +class CloudflareR2StorageService(AWSS3StorageService): + def __init__( + self, + account_id: str, + access_key_id: str, + secret_access_key: str, + bucket_name: str, + public_url_base: str | None = None, + ): + super().__init__( + access_key_id=access_key_id, + secret_access_key=secret_access_key, + bucket_name=bucket_name, + public_url_base=public_url_base, + region_name="auto", + ) + self.account_id = account_id + + @property + def endpoint_url(self) -> str: + return f"https://{self.account_id}.r2.cloudflarestorage.com" diff --git a/app/storage/local.py b/app/storage/local.py new file mode 100644 index 0000000..255077b --- /dev/null +++ b/app/storage/local.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from pathlib import Path + +from .base import StorageService + +import aiofiles + + +class LocalStorageService(StorageService): + def __init__( + self, + storage_path: str, + ): + self.storage_path = Path(storage_path).resolve() + self.storage_path.mkdir(parents=True, exist_ok=True) + + def _get_file_path(self, file_path: str) -> Path: + clean_path = file_path.lstrip("/") + full_path = self.storage_path / clean_path + + try: + full_path.resolve().relative_to(self.storage_path) + except ValueError: + raise ValueError(f"Invalid file path: {file_path}") + + return full_path + + async def write_file( + self, + file_path: str, + content: bytes, + content_type: str = "application/octet-stream", + cache_control: str = "public, max-age=31536000", + ) -> None: + full_path = self._get_file_path(file_path) + full_path.parent.mkdir(parents=True, exist_ok=True) + + try: + async with aiofiles.open(full_path, "wb") as f: + await f.write(content) + except OSError as e: + raise RuntimeError(f"Failed to write file: {e}") + + async def read_file(self, file_path: str) -> bytes: + full_path = self._get_file_path(file_path) + + if not full_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + try: + async with aiofiles.open(full_path, "rb") as f: + return await f.read() + except OSError as e: + raise RuntimeError(f"Failed to read file: {e}") + + async def delete_file(self, file_path: str) -> None: + full_path = self._get_file_path(file_path) + + if not full_path.exists(): + return + + try: + full_path.unlink() + + parent = full_path.parent + while parent != self.storage_path and not any(parent.iterdir()): + parent.rmdir() + parent = parent.parent + except OSError as e: + raise RuntimeError(f"Failed to delete file: {e}") + + async def is_exists(self, file_path: str) -> bool: + full_path = self._get_file_path(file_path) + return full_path.exists() and full_path.is_file() + + async def get_file_url(self, file_path: str) -> str: + return str(self.storage_path / file_path) diff --git a/pyproject.toml b/pyproject.toml index a687f17..dfc609f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12" dependencies = [ + "aioboto3>=15.0.0", + "aiofiles>=24.1.0", "aiomysql>=0.2.0", "alembic>=1.12.1", "apscheduler>=3.11.0", @@ -104,4 +106,5 @@ dev = [ "maturin>=1.9.2", "pre-commit>=4.2.0", "ruff>=0.12.4", + "types-aioboto3[aioboto3,essential]>=15.0.0", ] diff --git a/uv.lock b/uv.lock index ffc6105..047da9f 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,120 @@ members = [ "osu-lazer-api", ] +[[package]] +name = "aioboto3" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore", extra = ["boto3"] }, + { name = "aiofiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/d0/ed107e16551ba1b93ddcca9a6bf79580450945268a8bc396530687b3189f/aioboto3-15.0.0.tar.gz", hash = "sha256:dce40b701d1f8e0886dc874d27cd9799b8bf6b32d63743f57e7bef7e4a562756", size = 225278, upload-time = "2025-06-26T16:30:48.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/95/d69c744f408e5e4592fe53ed98fc244dd13b83d84cf1f83b2499d98bfcc9/aioboto3-15.0.0-py3-none-any.whl", hash = "sha256:9cf54b3627c8b34bb82eaf43ab327e7027e37f92b1e10dd5cfe343cd512568d0", size = 35785, upload-time = "2025-06-26T16:30:47.444Z" }, +] + +[[package]] +name = "aiobotocore" +version = "2.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/25/4b06ea1214ddf020a28df27dc7136ac9dfaf87929d51e6f6044dd350ed67/aiobotocore-2.23.0.tar.gz", hash = "sha256:0333931365a6c7053aee292fe6ef50c74690c4ae06bb019afdf706cb6f2f5e32", size = 115825, upload-time = "2025-06-12T23:46:38.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/43/ccf9b29669cdb09fd4bfc0a8effeb2973b22a0f3c3be4142d0b485975d11/aiobotocore-2.23.0-py3-none-any.whl", hash = "sha256:8202cebbf147804a083a02bc282fbfda873bfdd0065fd34b64784acb7757b66e", size = 84161, upload-time = "2025-06-12T23:46:36.305Z" }, +] + +[package.optional-dependencies] +boto3 = [ + { name = "boto3" }, +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, + { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, + { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, + { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, + { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, + { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, + { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, + { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, + { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, + { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, +] + +[[package]] +name = "aioitertools" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/de/38491a84ab323b47c7f86e94d2830e748780525f7a10c8600b67ead7e9ea/aioitertools-0.12.0.tar.gz", hash = "sha256:c2a9055b4fbb7705f561b9d86053e8af5d10cc845d22c32008c43490b2d8dd6b", size = 19369, upload-time = "2024-09-02T03:33:40.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/13/58b70a580de00893223d61de8fea167877a3aed97d4a5e1405c9159ef925/aioitertools-0.12.0-py3-none-any.whl", hash = "sha256:fc1f5fac3d737354de8831cbba3eb04f79dd649d8f3afb4c5b114925e662a796", size = 24345, upload-time = "2024-09-02T03:34:59.454Z" }, +] + [[package]] name = "aiomysql" version = "0.2.0" @@ -20,6 +134,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/87/c982ee8b333c85b8ae16306387d703a1fcdfc81a2f3f15a24820ab1a512d/aiomysql-0.2.0-py3-none-any.whl", hash = "sha256:b7c26da0daf23a5ec5e0b133c03d20657276e4eae9b73e040b72787f6f6ade0a", size = 44215, upload-time = "2023-06-11T19:57:51.09Z" }, ] +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "alembic" version = "1.16.4" @@ -69,6 +196,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, ] +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -119,6 +255,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, ] +[[package]] +name = "boto3" +version = "1.38.27" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/96/fc74d8521d2369dd8c412438401ff12e1350a1cd3eab5c758ed3dd5e5f82/boto3-1.38.27.tar.gz", hash = "sha256:94bd7fdd92d5701b362d4df100d21e28f8307a67ff56b6a8b0398119cf22f859", size = 111875, upload-time = "2025-05-30T19:32:41.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/8b/b2361188bd1e293eede1bc165e2461d390394f71ec0c8c21211c8dabf62c/boto3-1.38.27-py3-none-any.whl", hash = "sha256:95f5fe688795303a8a15e8b7e7f255cadab35eae459d00cc281a4fd77252ea80", size = 139938, upload-time = "2025-05-30T19:32:38.006Z" }, +] + +[[package]] +name = "botocore" +version = "1.38.27" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/5e/67899214ad57f7f26af5bd776ac5eb583dc4ecf5c1e52e2cbfdc200e487a/botocore-1.38.27.tar.gz", hash = "sha256:9788f7efe974328a38cbade64cc0b1e67d27944b899f88cb786ae362973133b6", size = 13919963, upload-time = "2025-05-30T19:32:29.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/83/a753562020b69fa90cebc39e8af2c753b24dcdc74bee8355ee3f6cefdf34/botocore-1.38.27-py3-none-any.whl", hash = "sha256:a785d5e9a5eda88ad6ab9ed8b87d1f2ac409d0226bba6ff801c55359e94d91a8", size = 13580545, upload-time = "2025-05-30T19:32:26.712Z" }, +] + +[[package]] +name = "botocore-stubs" +version = "1.38.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-awscrt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/45/27cabc7c3022dcb12de5098cc646b374065f5e72fae13600ff1756f365ee/botocore_stubs-1.38.46.tar.gz", hash = "sha256:a04e69766ab8bae338911c1897492f88d05cd489cd75f06e6eb4f135f9da8c7b", size = 42299, upload-time = "2025-06-29T22:58:24.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/84/06490071e26bab22ac79a684e98445df118adcf80c58c33ba5af184030f2/botocore_stubs-1.38.46-py3-none-any.whl", hash = "sha256:cc21d9a7dd994bdd90872db4664d817c4719b51cda8004fd507a4bf65b085a75", size = 66083, upload-time = "2025-06-29T22:58:22.234Z" }, +] + [[package]] name = "certifi" version = "2025.7.14" @@ -292,6 +468,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + [[package]] name = "greenlet" version = "3.2.3" @@ -402,6 +638,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -489,6 +734,69 @@ wheels = [ name = "msgpack-lazer-api" source = { editable = "packages/msgpack_lazer_api" } +[[package]] +name = "multidict" +version = "6.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, + { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, + { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, + { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, + { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, + { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, + { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -503,6 +811,8 @@ name = "osu-lazer-api" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "aioboto3" }, + { name = "aiofiles" }, { name = "aiomysql" }, { name = "alembic" }, { name = "apscheduler" }, @@ -529,10 +839,13 @@ dev = [ { name = "maturin" }, { name = "pre-commit" }, { name = "ruff" }, + { name = "types-aioboto3", extra = ["aioboto3", "essential"] }, ] [package.metadata] requires-dist = [ + { name = "aioboto3", specifier = ">=15.0.0" }, + { name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiomysql", specifier = ">=0.2.0" }, { name = "alembic", specifier = ">=1.12.1" }, { name = "apscheduler", specifier = ">=3.11.0" }, @@ -553,12 +866,14 @@ requires-dist = [ { name = "sqlmodel", specifier = ">=0.0.24" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, ] +provides-extras = ["online-storage"] [package.metadata.requires-dev] dev = [ { name = "maturin", specifier = ">=1.9.2" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "ruff", specifier = ">=0.12.4" }, + { name = "types-aioboto3", extras = ["aioboto3", "essential"], specifier = ">=15.0.0" }, ] [[package]] @@ -600,6 +915,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, ] +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -703,6 +1075,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/94/e4181a1f6286f545507528c78016e00065ea913276888db2262507693ce5/PyMySQL-1.1.1-py3-none-any.whl", hash = "sha256:4de15da4c61dc132f4fb9ab763063e693d521a80fd0e87943b9a453dd4c19d6c", size = 44972, upload-time = "2024-05-21T11:03:41.216Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -812,6 +1196,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/02/8857d0dfb8f44ef299a5dfd898f673edefb71e3b533b3b9d2db4c832dd13/ruff-0.12.4-py3-none-win_arm64.whl", hash = "sha256:0618ec4442a83ab545e5b71202a5c0ed7791e8471435b94e655b570a5031a98e", size = 10469336, upload-time = "2025-07-17T17:27:16.913Z" }, ] +[[package]] +name = "s3transfer" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/05/d52bf1e65044b4e5e27d4e63e8d1579dbdec54fce685908ae09bc3720030/s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf", size = 150589, upload-time = "2025-07-18T19:22:42.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/4f/d073e09df851cfa251ef7840007d04db3293a0482ce607d2b993926089be/s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724", size = 85308, upload-time = "2025-07-18T19:22:40.947Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -885,6 +1281,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, ] +[[package]] +name = "types-aioboto3" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore-stubs" }, + { name = "types-aiobotocore" }, + { name = "types-s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/5e/debfd07439455baf0b5d7dd93241c745ddb19a1304f7f0bf3d911f03d472/types_aioboto3-15.0.0.tar.gz", hash = "sha256:307801a6f56e4835289954bd03edaeb0123ad1978e3d1adbb0fb00754e2c6460", size = 80624, upload-time = "2025-06-27T01:16:32.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/c3/b60b579b59c579ebc431202dd6226a0e114b6858ae64984472ad2a59ee81/types_aioboto3-15.0.0-py3-none-any.whl", hash = "sha256:21086df20dcd90284348ec97e06ebb64fd5534ddef77e6774a8db26213609985", size = 42266, upload-time = "2025-06-27T01:16:25.239Z" }, +] + +[package.optional-dependencies] +aioboto3 = [ + { name = "aioboto3" }, +] +essential = [ + { name = "types-aiobotocore-cloudformation" }, + { name = "types-aiobotocore-dynamodb" }, + { name = "types-aiobotocore-ec2" }, + { name = "types-aiobotocore-lambda" }, + { name = "types-aiobotocore-rds" }, + { name = "types-aiobotocore-s3" }, + { name = "types-aiobotocore-sqs" }, +] + +[[package]] +name = "types-aiobotocore" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore-stubs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/e0/89bbb6964a1eb4b63161d3e5b56df2e16023e3798d6e6d3c32848e74d30d/types_aiobotocore-2.24.0.tar.gz", hash = "sha256:5e77e4abd9470bf2a989d2fb9ab2d69b574115ba4a7ce7a5ec7c1f029c0f6ca2", size = 86542, upload-time = "2025-08-09T02:03:38.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/07/4f2555d132c02181aae542df61b7cc51eabe2939debd7314395354a5edfb/types_aiobotocore-2.24.0-py3-none-any.whl", hash = "sha256:a1123752f9d6b6328e99598cfb019e56fd3ae3e05ed957ca93125292069b36f0", size = 54089, upload-time = "2025-08-09T02:03:32.347Z" }, +] + +[[package]] +name = "types-aiobotocore-cloudformation" +version = "2.23.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/92/6cc36d1c1f60526537075f43b1fdd798eada6b816655e246ac94927d86c4/types_aiobotocore_cloudformation-2.23.2.tar.gz", hash = "sha256:888bc8a0aab897398c5055a9c2899644841fb4c27d8ebfd813106ea5036e2d38", size = 59362, upload-time = "2025-07-25T01:52:23.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/d1/f288fbda0e4be2b8a3136fc3a14c234318138bf3c250a93991bf7d77ee47/types_aiobotocore_cloudformation-2.23.2-py3-none-any.whl", hash = "sha256:2692911070b5ddc6bb3fc9413880178d6fa4d9b526037b3c5dc84aa19e6f45a7", size = 70557, upload-time = "2025-07-25T01:52:21.544Z" }, +] + +[[package]] +name = "types-aiobotocore-dynamodb" +version = "2.23.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/93/8f1662d4e9e45dbfb8bf84c068fe091ed490e67d52ed2d841dac75977172/types_aiobotocore_dynamodb-2.23.2.tar.gz", hash = "sha256:daac720f64be500475437657d20e95a81160a955e8fe63e8e5e15b34d7351b90", size = 48001, upload-time = "2025-07-25T01:54:15.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/08/e6c992899b0f2565a34a37eeed455375dc066158ebcc497692da5f0e4e4a/types_aiobotocore_dynamodb-2.23.2-py3-none-any.whl", hash = "sha256:c333513cf97ed6ce3d30fbb09569c32e90688651055bb2e71578e39217045fa8", size = 57805, upload-time = "2025-07-25T01:54:14.863Z" }, +] + +[[package]] +name = "types-aiobotocore-ec2" +version = "2.23.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/11/c56f54c212e6da6c83b855f85eff6ab1d1bb4f57ec1fe6b8c644fc6d075b/types_aiobotocore_ec2-2.23.2.tar.gz", hash = "sha256:e29546d559a6a7e94411019cf6e3604747dfe1e2813f666b93e1e41698056b00", size = 405538, upload-time = "2025-07-25T01:54:26.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/8a/32f8ec137d017bf89c63f74fe797ed378b401ced2d909f266abbdbb9f05d/types_aiobotocore_ec2-2.23.2-py3-none-any.whl", hash = "sha256:4a2b65bed136602b1e7656d9ce7c214ca5540ce7199385315340539d8a0edd66", size = 395060, upload-time = "2025-07-25T01:54:25.181Z" }, +] + +[[package]] +name = "types-aiobotocore-lambda" +version = "2.23.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/68/5e24a33abcec88e3729228abcd01e59397f51e3cc9be437af7e22ecdff17/types_aiobotocore_lambda-2.23.2.tar.gz", hash = "sha256:d7c3f24d5ef99ae4afb38a1805f2ac11a412adaf0a1945ffe69fdbdcafc25b9b", size = 42334, upload-time = "2025-07-25T01:56:54.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/73/7f22a939bfbcd75129ee1ef5441dd02b360467b26ec145be08edff2f0c91/types_aiobotocore_lambda-2.23.2-py3-none-any.whl", hash = "sha256:e5663e39945d655685b381ca10fcb27a7975636d92610c45439d904beef7bfe0", size = 49548, upload-time = "2025-07-25T01:56:52.548Z" }, +] + +[[package]] +name = "types-aiobotocore-rds" +version = "2.23.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/ef/77fd404ccf1fdece6b5649af62a891092710093487856f782791441e5564/types_aiobotocore_rds-2.23.2.tar.gz", hash = "sha256:6e3f30cbc896e1a7bbe060260e3cc50cce97812167ec7bae76376ec8ca6a68f0", size = 85185, upload-time = "2025-07-25T01:59:04.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/47/f3e4bf2ba87f466808b84d5fb4fea2a231631e9c273f82d55e3018312d3a/types_aiobotocore_rds-2.23.2-py3-none-any.whl", hash = "sha256:c88d3fd72e8d4449f38fc6e72a5e8fb94439fa7d63378d46ed601e846a3b6d1f", size = 92083, upload-time = "2025-07-25T01:59:01.948Z" }, +] + +[[package]] +name = "types-aiobotocore-s3" +version = "2.23.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/8d/ef46488c36bc5ae85431d266dac0699671e35b01dbdb5d439015c2865c88/types_aiobotocore_s3-2.23.2.tar.gz", hash = "sha256:f01a08178db31fdee38900965a43077ab598b45c9b548f3ff1512d6aca45e382", size = 76373, upload-time = "2025-07-25T01:59:30.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/81/cce979e19bdf52c4697e639bea562c5f1be75596bde5b310fef81822032a/types_aiobotocore_s3-2.23.2-py3-none-any.whl", hash = "sha256:e0baf323fad6102b27a0d63789415499d879f5f219547097468ef3c7e97b3f13", size = 83822, upload-time = "2025-07-25T01:59:29.051Z" }, +] + +[[package]] +name = "types-aiobotocore-sqs" +version = "2.23.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/21/239fd48621c565e40fa4468a9f5abf93242836562581ae431b32a6fa36bf/types_aiobotocore_sqs-2.23.2.tar.gz", hash = "sha256:424b7f762bb836f22a12b3ab6c1e95f06badd26153f061663c499e61257b00ed", size = 23651, upload-time = "2025-07-25T02:00:22.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/d5/d125cebc90a1c29ba194ecc517613177db9725a230be554f86592e3a4d6b/types_aiobotocore_sqs-2.23.2-py3-none-any.whl", hash = "sha256:73d944ebabffcc055cc7ebf228e9f209f83f86d3aafa2aac0187b4d78271e043", size = 34392, upload-time = "2025-07-25T02:00:21.381Z" }, +] + +[[package]] +name = "types-awscrt" +version = "0.27.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/dd/9dc12092b88b95b88ef161c856619c1ef1f52bec1248273abe43ba56f123/types_awscrt-0.27.5.tar.gz", hash = "sha256:8eefe50d1709520663b77d3643a772c35ace3d8acfcb296f857627622c84cb4c", size = 16953, upload-time = "2025-07-31T02:03:20.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/fc/259979fadf4c6b0ff8a025d61a7d47e2868b4e9e429983c3ee58fdc9d106/types_awscrt-0.27.5-py3-none-any.whl", hash = "sha256:99ee40e787dfb92ae93a5c956251a03b847de3ac532552f7e06dd5eb6e0fd02f", size = 39627, upload-time = "2025-07-31T02:03:19.168Z" }, +] + +[[package]] +name = "types-s3transfer" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/c1/45038f259d6741c252801044e184fec4dbaeff939a58f6160d7c32bf4975/types_s3transfer-0.13.0.tar.gz", hash = "sha256:203dadcb9865c2f68fb44bc0440e1dc05b79197ba4a641c0976c26c9af75ef52", size = 14175, upload-time = "2025-05-28T02:16:07.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5d/6bbe4bf6a79fb727945291aef88b5ecbdba857a603f1bbcf1a6be0d3f442/types_s3transfer-0.13.0-py3-none-any.whl", hash = "sha256:79c8375cbf48a64bff7654c02df1ec4b20d74f8c5672fc13e382f593ca5565b3", size = 19588, upload-time = "2025-05-28T02:16:06.709Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.1" @@ -927,6 +1444,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + [[package]] name = "uvicorn" version = "0.35.0" @@ -1091,3 +1617,110 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b66 wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] + +[[package]] +name = "wrapt" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531, upload-time = "2025-01-14T10:35:45.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799, upload-time = "2025-01-14T10:33:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821, upload-time = "2025-01-14T10:33:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919, upload-time = "2025-01-14T10:34:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721, upload-time = "2025-01-14T10:34:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899, upload-time = "2025-01-14T10:34:09.82Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222, upload-time = "2025-01-14T10:34:11.258Z" }, + { url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707, upload-time = "2025-01-14T10:34:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685, upload-time = "2025-01-14T10:34:15.043Z" }, + { url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567, upload-time = "2025-01-14T10:34:16.563Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672, upload-time = "2025-01-14T10:34:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865, upload-time = "2025-01-14T10:34:19.577Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800, upload-time = "2025-01-14T10:34:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824, upload-time = "2025-01-14T10:34:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920, upload-time = "2025-01-14T10:34:25.386Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690, upload-time = "2025-01-14T10:34:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861, upload-time = "2025-01-14T10:34:29.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174, upload-time = "2025-01-14T10:34:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721, upload-time = "2025-01-14T10:34:32.91Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763, upload-time = "2025-01-14T10:34:34.903Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585, upload-time = "2025-01-14T10:34:36.13Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676, upload-time = "2025-01-14T10:34:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871, upload-time = "2025-01-14T10:34:39.13Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312, upload-time = "2025-01-14T10:34:40.604Z" }, + { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062, upload-time = "2025-01-14T10:34:45.011Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155, upload-time = "2025-01-14T10:34:47.25Z" }, + { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471, upload-time = "2025-01-14T10:34:50.934Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208, upload-time = "2025-01-14T10:34:52.297Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339, upload-time = "2025-01-14T10:34:53.489Z" }, + { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232, upload-time = "2025-01-14T10:34:55.327Z" }, + { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476, upload-time = "2025-01-14T10:34:58.055Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377, upload-time = "2025-01-14T10:34:59.3Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986, upload-time = "2025-01-14T10:35:00.498Z" }, + { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750, upload-time = "2025-01-14T10:35:03.378Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594, upload-time = "2025-01-14T10:35:44.018Z" }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] From ac54603acba6fcf6d56d4496a626789b9f744ddb Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Tue, 12 Aug 2025 03:58:30 +0000 Subject: [PATCH 101/104] feat(score): store replay to storage service --- app/path.py | 3 --- app/router/v2/score.py | 27 ++++++++++++++++++--------- app/signalr/hub/spectator.py | 14 +++++++++----- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/app/path.py b/app/path.py index d086837..d723c53 100644 --- a/app/path.py +++ b/app/path.py @@ -3,6 +3,3 @@ from __future__ import annotations from pathlib import Path STATIC_DIR = Path(__file__).parent.parent / "static" - -REPLAY_DIR = Path(__file__).parent.parent / "replays" -REPLAY_DIR.mkdir(exist_ok=True) diff --git a/app/router/v2/score.py b/app/router/v2/score.py index 7ebd757..acd3bb0 100644 --- a/app/router/v2/score.py +++ b/app/router/v2/score.py @@ -32,6 +32,7 @@ from app.database.score import ( ) from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher +from app.dependencies.storage import get_storage_service from app.dependencies.user import get_current_user from app.fetcher import Fetcher from app.models.room import RoomCategory @@ -42,12 +43,13 @@ from app.models.score import ( Rank, SoloScoreSubmissionInfo, ) -from app.path import REPLAY_DIR +from app.storage.base import StorageService +from app.storage.local import LocalStorageService from .router import router from fastapi import Body, Depends, Form, HTTPException, Query, Security -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, RedirectResponse from httpx import HTTPError from pydantic import BaseModel from redis.asyncio import Redis @@ -715,15 +717,15 @@ async def download_score_replay( score_id: int, current_user: User = Security(get_current_user, scopes=["public"]), db: AsyncSession = Depends(get_db), + storage_service: StorageService = Depends(get_storage_service), ): score = (await db.exec(select(Score).where(Score.id == score_id))).first() if not score: raise HTTPException(status_code=404, detail="Score not found") - filename = f"{score.id}_{score.beatmap_id}_{score.user_id}_lazer_replay.osr" - path = REPLAY_DIR / filename + filepath = f"replays/{score.id}_{score.beatmap_id}_{score.user_id}_lazer_replay.osr" - if not path.exists(): + if not await storage_service.is_exists(filepath): raise HTTPException(status_code=404, detail="Replay file not found") is_friend = ( @@ -755,7 +757,14 @@ async def download_score_replay( db.add(replay_watched_count) replay_watched_count.count += 1 await db.commit() - - return FileResponse( - path=path, filename=filename, media_type="application/x-osu-replay" - ) + if isinstance(storage_service, LocalStorageService): + return FileResponse( + path=await storage_service.get_file_url(filepath), + filename=filepath, + media_type="application/x-osu-replay", + ) + else: + return RedirectResponse( + await storage_service.get_file_url(filepath), + 301, + ) diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index dcc47f3..418228c 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -13,6 +13,7 @@ from app.database.score import Score from app.database.score_token import ScoreToken from app.dependencies.database import engine from app.dependencies.fetcher import get_fetcher +from app.dependencies.storage import get_storage_service from app.models.mods import mods_to_int from app.models.score import LegacyReplaySoloScoreInfo, ScoreStatistics from app.models.spectator_hub import ( @@ -25,7 +26,6 @@ from app.models.spectator_hub import ( StoreClientState, StoreScore, ) -from app.path import REPLAY_DIR from app.utils import unix_timestamp_to_windows from .hub import Client, Hub @@ -64,7 +64,7 @@ def encode_string(s: str) -> bytes: return ret -def save_replay( +async def save_replay( ruleset_id: int, md5: str, username: str, @@ -136,10 +136,14 @@ def save_replay( data.extend(struct.pack(" Date: Tue, 12 Aug 2025 04:54:21 +0000 Subject: [PATCH 102/104] feat(private-api): support upload avatar --- app/router/private/__init__.py | 1 + app/router/private/avatar.py | 56 +++++++++++++++++++++++++++ pyproject.toml | 1 + uv.lock | 69 +++++++++++++++++++++++++++++++++- 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 app/router/private/avatar.py diff --git a/app/router/private/__init__.py b/app/router/private/__init__.py index 4e7fb7a..f577a77 100644 --- a/app/router/private/__init__.py +++ b/app/router/private/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from . import avatar # noqa: F401 from .router import router as private_router __all__ = [ diff --git a/app/router/private/avatar.py b/app/router/private/avatar.py new file mode 100644 index 0000000..3f2116f --- /dev/null +++ b/app/router/private/avatar.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import base64 +import hashlib +from io import BytesIO + +from app.database.lazer_user import User +from app.dependencies.database import get_db +from app.dependencies.storage import get_storage_service +from app.storage.base import StorageService + +from .router import router + +from fastapi import Body, Depends, HTTPException +from PIL import Image +from sqlmodel.ext.asyncio.session import AsyncSession + + +@router.post("/avatar/upload", tags=["avatar"]) +async def upload_avatar( + file: str = Body(...), + user_id: int = Body(...), + storage: StorageService = Depends(get_storage_service), + session: AsyncSession = Depends(get_db), +): + content = base64.b64decode(file) + + user = await session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # check file + if len(content) > 5 * 1024 * 1024: # 5MB limit + raise HTTPException(status_code=400, detail="File size exceeds 5MB limit") + elif len(content) == 0: + raise HTTPException(status_code=400, detail="File cannot be empty") + with Image.open(BytesIO(content)) as img: + if img.format not in ["PNG", "JPEG", "GIF"]: + raise HTTPException(status_code=400, detail="Invalid image format") + if img.size[0] > 256 or img.size[1] > 256: + raise HTTPException( + status_code=400, detail="Image size exceeds 256x256 pixels" + ) + + filehash = hashlib.sha256(content).hexdigest() + storage_path = f"avatars/{user_id}_{filehash}.png" + if not await storage.is_exists(storage_path): + await storage.write_file(storage_path, content) + url = await storage.get_file_url(storage_path) + user.avatar_url = url + await session.commit() + + return { + "url": url, + "filehash": filehash, + } diff --git a/pyproject.toml b/pyproject.toml index dfc609f..d29db87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "loguru>=0.7.3", "msgpack-lazer-api", "passlib[bcrypt]>=1.7.4", + "pillow>=11.3.0", "pydantic-settings>=2.10.1", "pydantic[email]>=2.5.0", "python-dotenv>=1.0.0", diff --git a/uv.lock b/uv.lock index 047da9f..54f2c03 100644 --- a/uv.lock +++ b/uv.lock @@ -823,6 +823,7 @@ dependencies = [ { name = "loguru" }, { name = "msgpack-lazer-api" }, { name = "passlib", extra = ["bcrypt"] }, + { name = "pillow" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "python-dotenv" }, @@ -856,6 +857,7 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7.3" }, { name = "msgpack-lazer-api", editable = "packages/msgpack_lazer_api" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, + { name = "pillow", specifier = ">=11.3.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.5.0" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "python-dotenv", specifier = ">=1.0.0" }, @@ -866,7 +868,6 @@ requires-dist = [ { name = "sqlmodel", specifier = ">=0.0.24" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, ] -provides-extras = ["online-storage"] [package.metadata.requires-dev] dev = [ @@ -890,6 +891,72 @@ bcrypt = [ { name = "bcrypt" }, ] +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + [[package]] name = "platformdirs" version = "4.3.8" From a488257acd9fac71162c70f6b4f34ad6ec320a63 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Tue, 12 Aug 2025 05:23:16 +0000 Subject: [PATCH 103/104] feat(storage): expose a path to access local storage --- .dockerignore | 1 + .env.example | 3 ++- .gitignore | 7 ++++--- app/config.py | 8 ++++++-- app/router/__init__.py | 2 ++ app/router/file.py | 26 ++++++++++++++++++++++++++ app/router/v2/__init__.py | 2 -- app/storage/local.py | 4 +++- main.py | 14 ++++++++------ 9 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 app/router/file.py diff --git a/.dockerignore b/.dockerignore index 9a41bcf..190b30e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ .venv/ .ruff_cache/ .vscode/ +storage/ replays/ diff --git a/.env.example b/.env.example index 158ccd7..94dd4a7 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,8 @@ ACCESS_TOKEN_EXPIRE_MINUTES=1440 # 服务器地址 HOST="0.0.0.0" PORT=8000 +# 服务器 URL +SERVER_URL="http://localhost:8000" # 调试模式,生产环境请设置为 false DEBUG=false # 私有 API 密钥,用于前后端 API 调用,使用 openssl rand -hex 32 生成 @@ -37,7 +39,6 @@ SIGNALR_PING_INTERVAL=15 FETCHER_CLIENT_ID="" FETCHER_CLIENT_SECRET="" FETCHER_SCOPES=public -FETCHER_CALLBACK_URL="http://localhost:8000/fetcher/callback" # 日志设置 LOG_LEVEL="INFO" diff --git a/.gitignore b/.gitignore index 05622b7..dbf8bc7 100644 --- a/.gitignore +++ b/.gitignore @@ -183,9 +183,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ @@ -210,5 +210,6 @@ bancho.py-master/* .vscode/settings.json # runtime file +storage/ replays/ -osu-master/* \ No newline at end of file +osu-master/* diff --git a/app/config.py b/app/config.py index 397cfe3..54486d7 100644 --- a/app/config.py +++ b/app/config.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum from typing import Annotated, Any -from pydantic import Field, ValidationInfo, field_validator +from pydantic import Field, HttpUrl, ValidationInfo, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict @@ -65,6 +65,7 @@ class Settings(BaseSettings): port: int = 8000 debug: bool = False private_api_secret: str = "your_private_api_secret_here" + server_url: HttpUrl = HttpUrl("http://localhost:8000") # SignalR 设置 signalr_negotiate_timeout: int = 30 @@ -74,7 +75,10 @@ class Settings(BaseSettings): fetcher_client_id: str = "" fetcher_client_secret: str = "" fetcher_scopes: Annotated[list[str], NoDecode] = ["public"] - fetcher_callback_url: str = "http://localhost:8000/fetcher/callback" + + @property + def fetcher_callback_url(self) -> str: + return f"{self.server_url}fetcher/callback" # 日志设置 log_level: str = "INFO" diff --git a/app/router/__init__.py b/app/router/__init__.py index a54de39..c5b5b79 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -4,6 +4,7 @@ from app.signalr import signalr_router as signalr_router from .auth import router as auth_router from .fetcher import fetcher_router as fetcher_router +from .file import file_router as file_router from .private import private_router as private_router from .v2.router import router as api_v2_router @@ -11,6 +12,7 @@ __all__ = [ "api_v2_router", "auth_router", "fetcher_router", + "file_router", "private_router", "signalr_router", ] diff --git a/app/router/file.py b/app/router/file.py new file mode 100644 index 0000000..0cb2732 --- /dev/null +++ b/app/router/file.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from app.dependencies.storage import get_storage_service +from app.storage import LocalStorageService, StorageService + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse + +file_router = APIRouter(prefix="/file") + + +@file_router.get("/{path:path}") +async def get_file(path: str, storage: StorageService = Depends(get_storage_service)): + if not isinstance(storage, LocalStorageService): + raise HTTPException(404, "Not Found") + if not await storage.is_exists(path): + raise HTTPException(404, "Not Found") + + try: + return FileResponse( + path=storage._get_file_path(path), + media_type="application/octet-stream", + filename=path.split("/")[-1], + ) + except FileNotFoundError: + raise HTTPException(404, "Not Found") diff --git a/app/router/v2/__init__.py b/app/router/v2/__init__.py index 7e09509..7f981ee 100644 --- a/app/router/v2/__init__.py +++ b/app/router/v2/__init__.py @@ -1,7 +1,5 @@ from __future__ import annotations -from app.signalr import signalr_router as signalr_router - from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401 beatmap, beatmapset, diff --git a/app/storage/local.py b/app/storage/local.py index 255077b..b60eb35 100644 --- a/app/storage/local.py +++ b/app/storage/local.py @@ -2,6 +2,8 @@ from __future__ import annotations from pathlib import Path +from app.config import settings + from .base import StorageService import aiofiles @@ -75,4 +77,4 @@ class LocalStorageService(StorageService): return full_path.exists() and full_path.is_file() async def get_file_url(self, file_path: str) -> str: - return str(self.storage_path / file_path) + return f"{settings.server_url}file/{file_path.lstrip('/')}" diff --git a/main.py b/main.py index 2081aa0..8d14f94 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ from app.router import ( api_v2_router, auth_router, fetcher_router, + file_router, private_router, signalr_router, ) @@ -38,19 +39,20 @@ async def lifespan(app: FastAPI): app = FastAPI(title="osu! API 模拟服务器", version="1.0.0", lifespan=lifespan) +app.include_router(api_v2_router) +app.include_router(signalr_router) +app.include_router(fetcher_router) +app.include_router(file_router) +app.include_router(auth_router) +app.include_router(private_router) # CORS 配置 app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173"], + allow_origins=[str(settings.server_url)], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -app.include_router(api_v2_router) -app.include_router(signalr_router) -app.include_router(fetcher_router) -app.include_router(auth_router) -app.include_router(private_router) @app.get("/") From e66dd08691a820201892bbcaa9c750a0519e2962 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Tue, 12 Aug 2025 05:25:11 +0000 Subject: [PATCH 104/104] docs(readme): update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b77d61..8a8c5dc 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ docker-compose -f docker-compose-osurx.yml up -d | `HOST` | 服务器监听地址 | `0.0.0.0` | | `PORT` | 服务器监听端口 | `8000` | | `DEBUG` | 调试模式 | `false` | +| `SERVER_URL` | 服务器 URL | `http://localhost:8000` | | `PRIVATE_API_SECRET` | 私有 API 密钥,用于前后端 API 调用 | `your_private_api_secret_here` | ### OAuth 设置 @@ -93,7 +94,6 @@ Fetcher 用于从 osu! 官方 API 获取数据,使用 osu! 官方 API 的 OAut | `FETCHER_CLIENT_ID` | Fetcher 客户端 ID | `""` | | `FETCHER_CLIENT_SECRET` | Fetcher 客户端密钥 | `""` | | `FETCHER_SCOPES` | Fetcher 权限范围 | `public` | -| `FETCHER_CALLBACK_URL` | Fetcher 回调 URL | `http://localhost:8000/fetcher/callback` | ### 日志设置 | 变量名 | 描述 | 默认值 |