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
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..190b30e
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+.venv/
+.ruff_cache/
+.vscode/
+storage/
+replays/
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..94dd4a7
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,78 @@
+# 数据库设置
+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"
+
+# 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
+# 服务器 URL
+SERVER_URL="http://localhost: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
+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
+SIGNALR_PING_INTERVAL=15
+
+# Fetcher 设置
+FETCHER_CLIENT_ID=""
+FETCHER_CLIENT_SECRET=""
+FETCHER_SCOPES=public
+
+# 日志设置
+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 状态返回)
+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/.gitignore b/.gitignore
index b1cda6d..23b1cc8 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/
@@ -184,9 +185,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/
@@ -211,5 +212,6 @@ bancho.py-master/*
.vscode/settings.json
# runtime file
+storage/
replays/
-osu-master/*
\ No newline at end of file
+osu-master/*
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/Dockerfile b/Dockerfile
index c1ed5c7..3e9823a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,28 +1,48 @@
-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 AS builder
+WORKDIR /app
+
+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
+
+ENV PATH="/root/.cargo/bin:${PATH}" \
+ PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 UV_PROJECT_ENVIRONMENT=/app/.venv
+
+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 . .
+
+# ---
+
+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
+
+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..f64272f
--- /dev/null
+++ b/Dockerfile-osurx
@@ -0,0 +1,48 @@
+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 git \
+ && rm -rf /var/lib/apt/lists/* \
+ && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
+
+ENV PATH="/root/.cargo/bin:${PATH}" \
+ PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 UV_PROJECT_ENVIRONMENT=/app/.venv
+
+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 . .
+
+# ---
+
+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
+
+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/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.
diff --git a/README.md b/README.md
index a4e1e22..8a8c5dc 100644
--- a/README.md
+++ b/README.md
@@ -6,193 +6,159 @@
- **OAuth 2.0 认证**: 支持密码流和刷新令牌流
- **用户数据管理**: 完整的用户信息、统计数据、成就等
-- **多游戏模式支持**: osu!, taiko, fruits, mania
+- **多游戏模式支持**: osu! (osu!rx, osu!ap), taiko, fruits, mania
- **数据库持久化**: MySQL 存储用户数据
- **缓存支持**: Redis 缓存令牌和会话信息
+- **多种存储后端**: 支持本地存储、Cloudflare R2、AWS S3
- **容器化部署**: Docker 和 Docker Compose 支持
-## API 端点
-
-### 认证端点
-- `POST /oauth/token` - OAuth 令牌获取/刷新
-
-### 用户端点
-- `GET /api/v2/me/{ruleset}` - 获取当前用户信息
-
-### 其他端点
-- `GET /` - 根端点
-- `GET /health` - 健康检查
-
## 快速开始
### 使用 Docker Compose (推荐)
1. 克隆项目
```bash
-git clone
+git clone https://github.com/GooGuTeam/osu_lazer_api.git
cd osu_lazer_api
```
-2. 启动服务
+2. 创建 `.env` 文件
+
+请参考下方的服务器配置修改 .env 文件
+
```bash
-docker-compose up -d
+cp .env.example .env
```
-3. 创建示例数据
+3. 启动服务
```bash
-docker-compose exec api python create_sample_data.py
+# 标准服务器
+docker-compose -f docker-compose.yml up -d
+# 启用 osu!RX 和 osu!AP 模式 (偏偏要上班 pp 算法)
+docker-compose -f docker-compose-osurx.yml up -d
```
-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=*"
+4. 通过游戏连接服务器
-# 使用令牌获取用户信息
-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
-uvicorn main:app --reload
-```
-
-## 项目结构
-
-```
-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`
+使用[自定义的 osu!lazer 客户端](https://github.com/GooGuTeam/osu),或者使用 [LazerAuthlibInjection](https://github.com/MingxuanGame/LazerAuthlibInjection),修改服务器设置为服务器的 IP
## 环境变量配置
-项目包含两个环境配置文件:
-
-### 服务器配置 (`.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` |
+| `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` |
-| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` |
-| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` |
+
+### 服务器设置
+| 变量名 | 描述 | 默认值 |
+|--------|------|--------|
| `HOST` | 服务器监听地址 | `0.0.0.0` |
| `PORT` | 服务器监听端口 | `8000` |
-| `DEBUG` | 调试模式 | `True` |
-
-### 客户端配置 (`.env.client`)
-用于配置客户端脚本的 API 连接参数:
+| `DEBUG` | 调试模式 | `false` |
+| `SERVER_URL` | 服务器 URL | `http://localhost:8000` |
+| `PRIVATE_API_SECRET` | 私有 API 密钥,用于前后端 API 调用 | `your_private_api_secret_here` |
+### OAuth 设置
| 变量名 | 描述 | 默认值 |
|--------|------|--------|
| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` |
| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` |
-| `OSU_API_URL` | API 服务器地址 | `http://localhost:8000` |
+| `OSU_WEB_CLIENT_ID` | Web OAuth 客户端 ID | `6` |
+| `OSU_WEB_CLIENT_SECRET` | Web OAuth 客户端密钥 | `your_osu_web_client_secret_here`
+
+### 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` |
+
+### 日志设置
+| 变量名 | 描述 | 默认值 |
+|--------|------|--------|
+| `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` |
+| `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"
+}'
+```
> **注意**: 在生产环境中,请务必更改默认的密钥和密码!
-## 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)
diff --git a/app/auth.py b/app/auth.py
index 4762662..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
@@ -125,12 +126,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 +147,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:
@@ -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/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/config.py b/app/config.py
index 778155f..54486d7 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,51 +1,133 @@
from __future__ import annotations
-import os
+from enum import Enum
+from typing import Annotated, Any
-from dotenv import load_dotenv
-
-load_dotenv()
+from pydantic import Field, HttpUrl, ValidationInfo, field_validator
+from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
-class Settings:
+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")
+
# 数据库设置
- 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")
+ 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 = 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_jwt_secret_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: 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 = 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
+ private_api_secret: str = "your_private_api_secret_here"
+ server_url: HttpUrl = HttpUrl("http://localhost:8000")
# 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"]
+
+ @property
+ def fetcher_callback_url(self) -> str:
+ return f"{self.server_url}fetcher/callback"
# 日志设置
- LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO").upper()
+ 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
+ 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/database/__init__.py b/app/database/__init__.py
index 568104b..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,
@@ -10,16 +10,33 @@ 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 (
User,
UserResp,
)
+from .multiplayer_event import MultiplayerEvent, MultiplayerEventResp
+from .playlist_attempts import (
+ ItemAttemptsCount,
+ ItemAttemptsResp,
+ PlaylistAggregateScore,
+)
+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 APIUploadedRoom, Room, RoomResp
+from .room_participated_user import RoomParticipatedUser
from .score import (
+ MultiplayerScores,
Score,
+ ScoreAround,
ScoreBase,
ScoreResp,
ScoreStatistics,
@@ -37,21 +54,39 @@ from .user_account_history import (
)
__all__ = [
+ "APIUploadedRoom",
"Beatmap",
"BeatmapPlaycounts",
"BeatmapPlaycountsResp",
"Beatmapset",
"BeatmapsetResp",
"BestScore",
+ "CountResp",
"DailyChallengeStats",
"DailyChallengeStatsResp",
"FavouriteBeatmapset",
+ "ItemAttemptsCount",
+ "ItemAttemptsResp",
+ "MonthlyPlaycounts",
+ "MultiplayerEvent",
+ "MultiplayerEventResp",
+ "MultiplayerScores",
+ "OAuthClient",
"OAuthToken",
"PPBestScore",
+ "Playlist",
+ "PlaylistAggregateScore",
+ "PlaylistBestScore",
+ "PlaylistResp",
"Relationship",
"RelationshipResp",
"RelationshipType",
+ "ReplayWatchedCount",
+ "Room",
+ "RoomParticipatedUser",
+ "RoomResp",
"Score",
+ "ScoreAround",
"ScoreBase",
"ScoreResp",
"ScoreStatistics",
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/beatmap.py b/app/database/beatmap.py
index 5470277..7e770b7 100644
--- a/app/database/beatmap.py
+++ b/app/database/beatmap.py
@@ -1,14 +1,14 @@
from datetime import datetime
from typing import TYPE_CHECKING
+from app.config import settings
from app.models.beatmap import BeatmapRankStatus
-from app.models.model import UTCBaseModel
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
@@ -23,14 +23,12 @@ class BeatmapOwner(SQLModel):
username: str
-class BeatmapBase(SQLModel, UTCBaseModel):
+class BeatmapBase(SQLModel):
# Beatmap
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
@@ -42,17 +40,11 @@ class BeatmapBase(SQLModel, UTCBaseModel):
# 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)
@@ -63,7 +55,7 @@ class BeatmapBase(SQLModel, UTCBaseModel):
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
@@ -71,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()
@@ -170,11 +158,19 @@ class BeatmapResp(BeatmapBase):
from .score import Score
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.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 65a3b2a..1ed5d13 100644
--- a/app/database/beatmapset.py
+++ b/app/database/beatmapset.py
@@ -1,58 +1,38 @@
from datetime import datetime
-from typing import TYPE_CHECKING, TypedDict, cast
+from typing import TYPE_CHECKING, NotRequired, TypedDict
+from app.config import settings
from app.models.beatmap import BeatmapRankStatus, Genre, Language
-from app.models.model import UTCBaseModel
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 import Field, Relationship, SQLModel, col, exists, 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
-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):
@@ -74,12 +54,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):
@@ -87,7 +67,7 @@ class BeatmapTranslationText(BaseModel):
id: int | None = None
-class BeatmapsetBase(SQLModel, UTCBaseModel):
+class BeatmapsetBase(SQLModel):
# Beatmapset
artist: str = Field(index=True)
artist_unicode: str = Field(index=True)
@@ -121,7 +101,7 @@ class BeatmapsetBase(SQLModel, UTCBaseModel):
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))
@@ -181,11 +161,24 @@ 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
+ @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
@@ -193,7 +186,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
@@ -239,11 +232,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/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),
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 2717c3a..05998a7 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,8 +6,9 @@ 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 .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
@@ -21,6 +22,7 @@ from sqlmodel import (
Field,
Relationship,
SQLModel,
+ col,
func,
select,
)
@@ -74,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))
@@ -144,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"
)
@@ -164,7 +168,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,13 +180,15 @@ 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
- 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
@@ -207,7 +213,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 +285,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 = (
@@ -292,16 +302,74 @@ 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)
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
@@ -314,6 +382,7 @@ ALL_INCLUDED = [
"statistics_rulesets",
"achievements",
"monthly_playcounts",
+ "replays_watched_counts",
]
@@ -324,6 +393,7 @@ SEARCH_INCLUDED = [
"statistics_rulesets",
"achievements",
"monthly_playcounts",
+ "replays_watched_counts",
]
BASE_INCLUDES = [
diff --git a/app/database/multiplayer_event.py b/app/database/multiplayer_event.py
new file mode 100644
index 0000000..904fbe4
--- /dev/null
+++ b/app/database/multiplayer_event.py
@@ -0,0 +1,56 @@
+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,
+ 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(
+ 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
new file mode 100644
index 0000000..e628008
--- /dev/null
+++ b/app/database/playlist_attempts.py
@@ -0,0 +1,152 @@
+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,
+ Field,
+ ForeignKey,
+ Relationship,
+ SQLModel,
+ col,
+ func,
+ select,
+)
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+
+class ItemAttemptsCountBase(SQLModel):
+ room_id: int = Field(foreign_key="rooms.id", index=True)
+ attempts: 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(AsyncAttrs, ItemAttemptsCountBase, table=True):
+ __tablename__ = "item_attempts_count" # pyright: ignore[reportAssignmentType]
+ id: int | None = Field(default=None, 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 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.model_dump())
+ resp.user = await UserResp.from_db(
+ await item_attempts.awaitable_attrs.user,
+ session=session,
+ include=["statistics", "team", "daily_challenge_user_stats"],
+ )
+ if "position" in include:
+ 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
new file mode 100644
index 0000000..6ecb18a
--- /dev/null
+++ b/app/database/playlist_best_score.py
@@ -0,0 +1,110 @@
+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))
+ attempts: int = Field(default=0) # playlist
+
+ 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:
+ previous = PlaylistBestScore(
+ user_id=user_id,
+ score_id=score_id,
+ room_id=room_id,
+ playlist_id=playlist_id,
+ total_score=total_score,
+ )
+ 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
+ await session.commit()
+ if await redis.exists(f"multiplayer:{room_id}:gameplay:players"):
+ 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
new file mode 100644
index 0000000..c177432
--- /dev/null
+++ b/app/database/playlists.py
@@ -0,0 +1,143 @@
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from app.models.model import UTCBaseModel
+from app.models.mods import APIMod
+from app.models.multiplayer_hub import PlaylistItem
+
+from .beatmap import Beatmap, BeatmapResp
+
+from sqlmodel import (
+ JSON,
+ BigInteger,
+ Column,
+ DateTime,
+ Field,
+ ForeignKey,
+ Relationship,
+ SQLModel,
+ func,
+ select,
+)
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+if TYPE_CHECKING:
+ from .room import Room
+
+
+class PlaylistBase(SQLModel, UTCBaseModel):
+ 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)
+ 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 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=next_id,
+ owner_id=playlist.owner_id,
+ ruleset_id=playlist.ruleset_id,
+ beatmap_id=playlist.beatmap_id,
+ required_mods=playlist.required_mods,
+ allowed_mods=playlist.allowed_mods,
+ expired=playlist.expired,
+ playlist_order=playlist.playlist_order,
+ played_at=playlist.played_at,
+ freestyle=playlist.freestyle,
+ 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 = playlist.required_mods
+ db_playlist.allowed_mods = playlist.allowed_mods
+ db_playlist.expired = playlist.expired
+ db_playlist.playlist_order = playlist.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
+
+ @classmethod
+ async def from_db(
+ cls, playlist: Playlist, include: list[str] = []
+ ) -> "PlaylistResp":
+ data = playlist.model_dump()
+ if "beatmap" in include:
+ 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 7a1aff8..368a04a 100644
--- a/app/database/room.py
+++ b/app/database/room.py
@@ -1,6 +1,177 @@
-from sqlmodel import Field, SQLModel
+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 (
+ MatchType,
+ QueueMode,
+ RoomCategory,
+ RoomDifficultyRange,
+ RoomPlaylistItemStats,
+ RoomStatus,
+)
+
+from .lazer_user import User, UserResp
+from .playlists import Playlist, PlaylistResp
+
+from sqlalchemy.ext.asyncio import AsyncAttrs
+from sqlmodel import (
+ BigInteger,
+ Column,
+ DateTime,
+ Field,
+ ForeignKey,
+ Relationship,
+ SQLModel,
+ col,
+ select,
+)
+from sqlmodel.ext.asyncio.session import AsyncSession
-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]
+class RoomBase(SQLModel, UTCBaseModel):
+ name: str = Field(index=True)
+ category: RoomCategory = Field(default=RoomCategory.NORMAL, index=True)
+ duration: int | None = Field(default=None) # minutes
+ starts_at: datetime | None = Field(
+ sa_column=Column(
+ DateTime(timezone=True),
+ ),
+ default=datetime.now(UTC),
+ )
+ ends_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
+
+
+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(
+ sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
+ )
+
+ host: User = Relationship()
+ playlist: list[Playlist] = Relationship(
+ sa_relationship_kwargs={
+ "lazy": "selectin",
+ "cascade": "all, delete-orphan",
+ "overlaps": "room",
+ }
+ )
+
+
+class RoomResp(RoomBase):
+ id: int
+ has_password: bool = False
+ host: UserResp | None = None
+ playlist: list[PlaylistResp] = []
+ playlist_item_stats: RoomPlaylistItemStats | None = None
+ 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] = [],
+ user: User | None = None,
+ ) -> "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, ["beatmap"]))
+ 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.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
+ )
+ 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=int(room.settings.auto_start_duration.total_seconds()),
+ 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
+
+
+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/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/database/score.py b/app/database/score.py
index bbef7ab..9efb081 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,
@@ -13,8 +13,14 @@ from app.calculator import (
calculate_weighted_pp,
clamp,
)
+from app.config import settings
from app.database.team import TeamMember
-from app.models.model import 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,
@@ -31,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,
@@ -89,10 +95,11 @@ 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
- position: int | None = Field(default=None) # multiplayer
+ # position: int | None = Field(default=None) # multiplayer
class Score(ScoreBase, table=True):
@@ -100,7 +107,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(
@@ -121,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()
@@ -163,6 +170,9 @@ class ScoreResp(ScoreBase):
maximum_statistics: ScoreStatistics | None = None
rank_global: int | None = None
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":
@@ -231,9 +241,22 @@ class ScoreResp(ScoreBase):
)
or None
)
+ s.current_user_attributes = CurrentUserAttributes(
+ pin=PinAttributes(is_pinned=bool(score.pinned_order), score_id=score.id)
+ )
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()
@@ -312,6 +335,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
@@ -329,6 +359,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)
)
@@ -461,12 +495,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)
)
@@ -474,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
@@ -577,8 +612,8 @@ async def process_user(
)
)
statistics.play_count += 1
- mouthly_playcount.playcount += 1
- statistics.play_time += int((score.ended_at - score.started_at).total_seconds())
+ mouthly_playcount.count += 1
+ statistics.play_time += length
statistics.count_100 += score.n100 + score.nkatu
statistics.count_300 += score.n300 + score.ngeki
statistics.count_50 += score.n50
@@ -588,7 +623,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):
@@ -616,9 +651,19 @@ 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)
+ 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,
@@ -630,7 +675,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,
@@ -647,6 +692,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)
@@ -678,4 +725,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 77b15c3..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
@@ -18,23 +19,36 @@ 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)
# 数据库依赖
+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
-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
+
+
+def get_redis_pubsub():
+ return redis_client.pubsub()
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/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/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/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/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/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())
+ )
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/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/metadata_hub.py b/app/models/metadata_hub.py
index 3206d03..8bf237d 100644
--- a/app/models/metadata_hub.py
+++ b/app/models/metadata_hub.py
@@ -3,10 +3,12 @@ from __future__ import annotations
from enum import IntEnum
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
+TOTAL_SCORE_DISTRIBUTION_BINS = 13
+
class _UserActivity(SignalRUnionMessage): ...
@@ -96,16 +98,14 @@ UserActivity = (
| ModdingBeatmap
| TestingBeatmap
| InDailyChallengeLobby
+ | PlayingDailyChallenge
)
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: UserActivity | None = None
+
+ status: OnlineStatus | None = None
@property
def pushable(self) -> bool:
@@ -126,3 +126,34 @@ class OnlineStatus(IntEnum):
OFFLINE = 0 # 隐身
DO_NOT_DISTURB = 1
ONLINE = 2
+
+
+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/models/model.py b/app/models/model.py
index bc00585..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
@@ -13,3 +15,41 @@ 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
+
+
+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/models/mods.py b/app/models/mods.py
index abcd2cd..ecedf6a 100644
--- a/app/models/mods.py
+++ b/app/models/mods.py
@@ -1,14 +1,16 @@
from __future__ import annotations
+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
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
@@ -129,10 +131,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
@@ -154,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/multiplayer_hub.py b/app/models/multiplayer_hub.py
new file mode 100644
index 0000000..90613de
--- /dev/null
+++ b/app/models/multiplayer_hub.py
@@ -0,0 +1,924 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+import asyncio
+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,
+ TypedDict,
+ cast,
+ override,
+)
+
+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
+from .room import (
+ DownloadState,
+ MatchType,
+ MultiplayerRoomState,
+ MultiplayerUserState,
+ QueueMode,
+ RoomCategory,
+ RoomStatus,
+)
+from .signalr import (
+ SignalRMeta,
+ SignalRUnionMessage,
+ UserState,
+)
+
+from pydantic import BaseModel, Field
+from sqlalchemy import update
+from sqlmodel import col
+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):
+ room_id: int = 0
+
+
+class MultiplayerRoomSettings(BaseModel):
+ name: str = "Unnamed Room"
+ playlist_item_id: Annotated[int, Field(default=0), SignalRMeta(use_abbr=False)]
+ password: str = ""
+ match_type: MatchType = MatchType.HEAD_TO_HEAD
+ queue_mode: QueueMode = QueueMode.HOST_ONLY
+ 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
+ download_progress: float | None = None
+
+
+class _MatchUserState(SignalRUnionMessage): ...
+
+
+class TeamVersusUserState(_MatchUserState):
+ team_id: int
+
+ union_type: ClassVar[Literal[0]] = 0
+
+
+MatchUserState = TeamVersusUserState
+
+
+class _MatchRoomState(SignalRUnionMessage): ...
+
+
+class MultiplayerTeam(BaseModel):
+ 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"),
+ ]
+ )
+
+ union_type: ClassVar[Literal[0]] = 0
+
+
+MatchRoomState = TeamVersusRoomState
+
+
+class PlaylistItem(BaseModel):
+ id: Annotated[int, Field(default=0), SignalRMeta(use_abbr=False)]
+ owner_id: int
+ beatmap_id: int
+ beatmap_checksum: str
+ ruleset_id: int
+ required_mods: list[APIMod] = Field(default_factory=list)
+ allowed_mods: list[APIMod] = Field(default_factory=list)
+ expired: bool
+ playlist_order: int
+ played_at: datetime | None = None
+ 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",
+ 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
+
+ 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)
+
+ 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
+
+ def clone(self) -> "PlaylistItem":
+ 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
+
+
+class _MultiplayerCountdown(SignalRUnionMessage):
+ id: int = 0
+ time_remaining: timedelta
+ is_exclusive: Annotated[
+ bool, Field(default=True), SignalRMeta(member_ignore=True)
+ ] = True
+
+
+class MatchStartCountdown(_MultiplayerCountdown):
+ union_type: ClassVar[Literal[0]] = 0
+
+
+class ForceGameplayStartCountdown(_MultiplayerCountdown):
+ union_type: ClassVar[Literal[1]] = 1
+
+
+class ServerShuttingDownCountdown(_MultiplayerCountdown):
+ union_type: ClassVar[Literal[2]] = 2
+
+
+MultiplayerCountdown = (
+ MatchStartCountdown | ForceGameplayStartCountdown | ServerShuttingDownCountdown
+)
+
+
+class MultiplayerRoomUser(BaseModel):
+ user_id: int
+ state: MultiplayerUserState = MultiplayerUserState.IDLE
+ availability: BeatmapAvailability = BeatmapAvailability(
+ state=DownloadState.UNKNOWN, download_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
+
+
+class MultiplayerRoom(BaseModel):
+ room_id: int
+ 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_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"):
+ self.server_room = room
+ 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.playlist_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.playlist_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
+ 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.playlist_order == idx:
+ continue
+ 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
+ )
+
+ 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.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([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")
+
+ if item.freestyle and len(item.allowed_mods) > 0:
+ raise InvokeException("Freestyle items cannot have allowed mods")
+
+ async with AsyncSession(engine) as session:
+ fetcher = await get_fetcher()
+ async with session:
+ 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:
+ raise InvokeException("Checksum mismatch")
+
+ item.validate_playlist_item_mods()
+ item.owner_id = user.user_id
+ 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)
+ 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:
+ fetcher = await get_fetcher()
+ async with session:
+ beatmap = await Beatmap.get_or_fetch(
+ session, fetcher, bid=item.beatmap_id
+ )
+ if item.beatmap_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"
+ )
+
+ 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
+
+ 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.beatmap_checksum
+ != existing_item.beatmap_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)
+
+ 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)
+ await self.update_current_item()
+
+ 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]
+
+
+@dataclass
+class CountdownInfo:
+ countdown: MultiplayerCountdown
+ duration: timedelta
+ task: asyncio.Task | None = None
+
+ def __init__(self, countdown: MultiplayerCountdown):
+ self.countdown = countdown
+ self.duration = (
+ countdown.time_remaining
+ if countdown.time_remaining > timedelta(seconds=0)
+ else timedelta(seconds=0)
+ )
+
+
+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): ...
+
+ @abstractmethod
+ def get_details(self) -> MatchStartedEventDetail: ...
+
+
+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): ...
+
+ @override
+ def get_details(self) -> MatchStartedEventDetail:
+ detail = MatchStartedEventDetail(room_type="head_to_head", team=None)
+ return detail
+
+
+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): ...
+
+ @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,
+ MatchType.TEAM_VERSUS: TeamVersusHandler,
+}
+
+
+@dataclass
+class ServerMultiplayerRoom:
+ room: MultiplayerRoom
+ category: RoomCategory
+ status: RoomStatus
+ start_at: datetime
+ hub: "MultiplayerHub"
+ 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:
+ 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())
+ 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.__class__)
+ 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 = self._tracked_countdown.get(countdown.id)
+ if info is None:
+ return
+ 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, typ: type[MultiplayerCountdown]):
+ for countdown in list(self._tracked_countdown.values()):
+ if isinstance(countdown.countdown, typ):
+ await self.stop_countdown(countdown.countdown)
+
+
+class _MatchServerEvent(SignalRUnionMessage): ...
+
+
+class CountdownStartedEvent(_MatchServerEvent):
+ countdown: MultiplayerCountdown
+
+ union_type: ClassVar[Literal[0]] = 0
+
+
+class CountdownStoppedEvent(_MatchServerEvent):
+ id: int
+
+ union_type: ClassVar[Literal[1]] = 1
+
+
+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/models/oauth.py b/app/models/oauth.py
index 22fcf63..6665965 100644
--- a/app/models/oauth.py
+++ b/app/models/oauth.py
@@ -1,7 +1,6 @@
# OAuth 相关模型
from __future__ import annotations
-from typing import List
from pydantic import BaseModel
@@ -39,18 +38,21 @@ class OAuthErrorResponse(BaseModel):
class RegistrationErrorResponse(BaseModel):
"""注册错误响应模型"""
+
form_error: dict
class UserRegistrationErrors(BaseModel):
"""用户注册错误模型"""
- username: List[str] = []
- user_email: List[str] = []
- password: List[str] = []
+
+ username: list[str] = []
+ user_email: list[str] = []
+ password: list[str] = []
class RegistrationRequestErrors(BaseModel):
"""注册请求错误模型"""
+
message: str | None = None
redirect: str | None = None
user: UserRegistrationErrors | None = None
diff --git a/app/models/room.py b/app/models/room.py
index 85aae24..3cba32f 100644
--- a/app/models/room.py
+++ b/app/models/room.py
@@ -1,15 +1,8 @@
from __future__ import annotations
-from datetime import datetime
from enum import Enum
-from app.database import User
-from app.database.beatmap import Beatmap
-from app.models.mods import APIMod
-
-from .model import UTCBaseModel
-
-from pydantic import BaseModel, Field
+from pydantic import BaseModel
class RoomCategory(str, Enum):
@@ -17,6 +10,7 @@ class RoomCategory(str, Enum):
SPOTLIGHT = "spotlight"
FEATURED_ARTIST = "featured_artist"
DAILY_CHALLENGE = "daily_challenge"
+ REALTIME = "realtime" # INTERNAL USE ONLY, DO NOT USE IN API
class MatchType(str, Enum):
@@ -42,18 +36,40 @@ class RoomStatus(str, Enum):
PLAYING = "playing"
-class PlaylistItem(UTCBaseModel):
- id: int | None
- owner_id: int
- ruleset_id: int
- expired: bool
- playlist_order: int | None
- played_at: datetime | None
- allowed_mods: list[APIMod] = Field(default_factory=list)
- required_mods: list[APIMod] = Field(default_factory=list)
- beatmap_id: int
- beatmap: Beatmap | None
- freestyle: bool
+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"
+
+ @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"
+ NOT_DOWNLOADED = "not_downloaded"
+ DOWNLOADING = "downloading"
+ IMPORTING = "importing"
+ LOCALLY_AVAILABLE = "locally_available"
class RoomPlaylistItemStats(BaseModel):
@@ -67,39 +83,7 @@ class RoomDifficultyRange(BaseModel):
max: float
-class ItemAttemptsCount(BaseModel):
- id: int
- attempts: int
- passed: bool
-
-
-class PlaylistAggregateScore(BaseModel):
- playlist_item_attempts: list[ItemAttemptsCount]
-
-
-class Room(UTCBaseModel):
- id: int | None
- name: str = ""
- password: str | None
- has_password: bool = False
- host: User | None
- category: RoomCategory = RoomCategory.NORMAL
- duration: int | None
- starts_at: datetime | None
- ends_at: datetime | None
- participant_count: int = 0
- recent_participants: list[User] = Field(default_factory=list)
- max_attempts: int | None
- playlist: list[PlaylistItem] = Field(default_factory=list)
- 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 PlaylistStatus(BaseModel):
+ count_active: int
+ count_total: int
+ ruleset_ids: list[int]
diff --git a/app/models/score.py b/app/models/score.py
index cef6b28..ab968e9 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):
@@ -14,13 +16,19 @@ 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
- def to_rosu(self) -> rosu.GameMode:
return {
GameMode.OSU: rosu.GameMode.Osu,
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]
@@ -29,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/models/signalr.py b/app/models/signalr.py
index 90ef95f..ffbaf6b 100644
--- a/app/models/signalr.py
+++ b/app/models/signalr.py
@@ -1,12 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass
-from enum import Enum
-from typing import Any, ClassVar
+from typing import ClassVar
from pydantic import (
BaseModel,
- BeforeValidator,
Field,
)
@@ -15,23 +13,7 @@ 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
-
-
-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))
+ use_abbr: bool = True
class SignalRUnionMessage(BaseModel):
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/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):
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/__init__.py b/app/router/__init__.py
index 1e87343..c5b5b79 100644
--- a/app/router/__init__.py
+++ b/app/router/__init__.py
@@ -2,16 +2,17 @@ from __future__ import annotations
from app.signalr import signalr_router as signalr_router
-from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401
- beatmap,
- beatmapset,
- me,
- relationship,
- 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 .file import file_router as file_router
+from .private import private_router as private_router
+from .v2.router import router as api_v2_router
-__all__ = ["api_router", "auth_router", "fetcher_router", "signalr_router"]
+__all__ = [
+ "api_v2_router",
+ "auth_router",
+ "fetcher_router",
+ "file_router",
+ "private_router",
+ "signalr_router",
+]
diff --git a/app/router/auth.py b/app/router/auth.py
index 7a2a14d..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
@@ -159,14 +163,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()
@@ -187,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=(
@@ -214,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",
@@ -225,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)
@@ -242,7 +278,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
)
@@ -253,15 +289,17 @@ async def oauth_token(
await store_token(
db,
user.id,
+ client_id,
+ scopes,
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 +333,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
)
@@ -305,19 +343,83 @@ 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,
+ 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,
)
+ 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/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/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/private/__init__.py b/app/router/private/__init__.py
new file mode 100644
index 0000000..f577a77
--- /dev/null
+++ b/app/router/private/__init__.py
@@ -0,0 +1,8 @@
+from __future__ import annotations
+
+from . import avatar # noqa: F401
+from .router import router as private_router
+
+__all__ = [
+ "private_router",
+]
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/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/app/router/room.py b/app/router/room.py
deleted file mode 100644
index 3a65617..0000000
--- a/app/router/room.py
+++ /dev/null
@@ -1,33 +0,0 @@
-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 .api_router import router
-
-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])
-async def get_all_rooms(
- mode: str = Query(
- None
- ), # TODO: lazer源码显示房间不会是除了open以外的其他状态,先放在这里
- status: str = Query(None),
- category: str = Query(None),
- db: AsyncSession = Depends(get_db),
- redis: Redis = Depends(get_redis),
-):
- all_room_ids = (await db.exec(select(RoomIndex).where(True))).all()
- roomsList: list[Room] = []
- for room_index in all_room_ids:
- dumped_room = await redis.get(str(room_index.id))
- if dumped_room:
- actual_room = Room.model_validate_json(str(dumped_room))
- if actual_room.status == status and actual_room.category == category:
- roomsList.append(actual_room)
- return roomsList
diff --git a/app/router/score.py b/app/router/score.py
deleted file mode 100644
index 2f1303e..0000000
--- a/app/router/score.py
+++ /dev/null
@@ -1,227 +0,0 @@
-from __future__ import annotations
-
-from app.database import Beatmap, 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.models.beatmap import BeatmapRankStatus
-from app.models.score import (
- INT_TO_MODE,
- GameMode,
- LeaderboardType,
- Rank,
- SoloScoreSubmissionInfo,
-)
-
-from .api_router import router
-
-from fastapi import Depends, Form, HTTPException, Query
-from pydantic import BaseModel
-from redis.asyncio import Redis
-from sqlalchemy.orm import joinedload
-from sqlmodel import col, select
-from sqlmodel.ext.asyncio.session import AsyncSession
-
-
-class BeatmapScores(BaseModel):
- scores: list[ScoreResp]
- userScore: ScoreResp | None = None
-
-
-@router.get(
- "/beatmaps/{beatmap}/scores", tags=["beatmap"], response_model=BeatmapScores
-)
-async def get_beatmap_scores(
- beatmap: int,
- mode: GameMode,
- 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),
- db: AsyncSession = Depends(get_db),
- limit: int = Query(50, ge=1, le=200),
-):
- if legacy_only:
- raise HTTPException(
- status_code=404, detail="this server only contains lazer scores"
- )
-
- all_scores, user_score = await get_leaderboard(
- db, beatmap, mode, type=type, user=current_user, limit=limit, mods=mods
- )
-
- return BeatmapScores(
- scores=[await ScoreResp.from_db(db, score) for score in all_scores],
- userScore=await ScoreResp.from_db(db, user_score) if user_score else None,
- )
-
-
-class BeatmapUserScore(BaseModel):
- position: int
- score: ScoreResp
-
-
-@router.get(
- "/beatmaps/{beatmap}/scores/users/{user}",
- tags=["beatmap"],
- response_model=BeatmapUserScore,
-)
-async def get_user_beatmap_score(
- beatmap: int,
- user: int,
- legacy_only: bool = Query(None),
- mode: str = Query(None),
- mods: str = Query(None), # TODO:添加mods筛选
- current_user: User = Depends(get_current_user),
- db: AsyncSession = Depends(get_db),
-):
- if legacy_only:
- raise HTTPException(
- status_code=404, detail="This server only contains non-legacy scores"
- )
- user_score = (
- await db.exec(
- select(Score)
- .where(
- Score.gamemode == mode if mode is not None else True,
- Score.beatmap_id == beatmap,
- Score.user_id == user,
- )
- .order_by(col(Score.total_score).desc())
- )
- ).first()
-
- if not user_score:
- raise HTTPException(
- status_code=404, detail=f"Cannot find user {user}'s score on this beatmap"
- )
- else:
- return BeatmapUserScore(
- position=user_score.position if user_score.position is not None else 0,
- score=await ScoreResp.from_db(db, user_score),
- )
-
-
-@router.get(
- "/beatmaps/{beatmap}/scores/users/{user}/all",
- tags=["beatmap"],
- response_model=list[ScoreResp],
-)
-async def get_user_all_beatmap_scores(
- beatmap: int,
- user: int,
- legacy_only: bool = Query(None),
- ruleset: str = Query(None),
- current_user: User = Depends(get_current_user),
- db: AsyncSession = Depends(get_db),
-):
- if legacy_only:
- raise HTTPException(
- status_code=404, detail="This server only contains non-legacy scores"
- )
- all_user_scores = (
- await db.exec(
- select(Score)
- .where(
- Score.gamemode == ruleset if ruleset is not None else True,
- Score.beatmap_id == beatmap,
- Score.user_id == user,
- )
- .order_by(col(Score.classic_total_score).desc())
- )
- ).all()
-
- return [await ScoreResp.from_db(db, score) for score in all_user_scores]
-
-
-@router.post(
- "/beatmaps/{beatmap}/solo/scores", tags=["beatmap"], response_model=ScoreTokenResp
-)
-async def create_solo_score(
- beatmap: int,
- version_hash: str = Form(""),
- beatmap_hash: str = Form(),
- ruleset_id: int = Form(..., ge=0, le=3),
- current_user: User = Depends(get_current_user),
- db: AsyncSession = Depends(get_db),
-):
- assert current_user.id
- async with db:
- score_token = ScoreToken(
- user_id=current_user.id,
- beatmap_id=beatmap,
- ruleset_id=INT_TO_MODE[ruleset_id],
- )
- db.add(score_token)
- await db.commit()
- await db.refresh(score_token)
- return ScoreTokenResp.from_db(score_token)
-
-
-@router.put(
- "/beatmaps/{beatmap}/solo/scores/{token}",
- tags=["beatmap"],
- response_model=ScoreResp,
-)
-async def submit_solo_score(
- beatmap: int,
- token: int,
- info: SoloScoreSubmissionInfo,
- current_user: User = Depends(get_current_user),
- db: AsyncSession = Depends(get_db),
- 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)
- )
- ).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)
diff --git a/app/router/v2/__init__.py b/app/router/v2/__init__.py
new file mode 100644
index 0000000..7f981ee
--- /dev/null
+++ b/app/router/v2/__init__.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+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 79%
rename from app/router/beatmap.py
rename to app/router/v2/beatmap.py
index 9574bdb..a621a6c 100644
--- a/app/router/beatmap.py
+++ b/app/router/v2/beatmap.py
@@ -17,9 +17,9 @@ from app.models.score import (
GameMode,
)
-from .api_router import router
+from .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),
):
@@ -74,9 +74,10 @@ 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),
- current_user: User = Depends(get_current_user),
+ b_ids: list[int] = Query(alias="ids[]", default_factory=list),
+ current_user: User = Security(get_current_user, scopes=["public"]),
db: AsyncSession = Depends(get_db),
+ fetcher: Fetcher = Depends(get_fetcher),
):
if not b_ids:
# select 50 beatmaps by last_updated
@@ -86,9 +87,29 @@ 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)
+ )
+ for beatmap in beatmaps:
+ await db.refresh(beatmap)
return BatchGetResp(
beatmaps=[
@@ -105,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/v2/beatmapset.py
similarity index 65%
rename from app/router/beatmapset.py
rename to app/router/v2/beatmapset.py
index c02b559..ef5faba 100644
--- a/app/router/beatmapset.py
+++ b/app/router/v2/beatmapset.py
@@ -2,47 +2,56 @@ 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
from app.fetcher import Fetcher
-from .api_router import router
+from .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 HTTPStatusError
+from httpx import HTTPError
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 = Security(get_current_user, scopes=["public"]),
+ db: AsyncSession = Depends(get_db),
+ fetcher: Fetcher = Depends(get_fetcher),
+):
+ 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
+
+
@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),
):
- 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"])
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(
@@ -59,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/v2/me.py
similarity index 82%
rename from app/router/me.py
rename to app/router/v2/me.py
index b6d7d26..b2fd7b6 100644
--- a/app/router/me.py
+++ b/app/router/v2/me.py
@@ -6,9 +6,9 @@ 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
+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/v2/misc.py b/app/router/v2/misc.py
new file mode 100644
index 0000000..06baf34
--- /dev/null
+++ b/app/router/v2/misc.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from app.config import settings
+
+from .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]
+ )
diff --git a/app/router/relationship.py b/app/router/v2/relationship.py
similarity index 91%
rename from app/router/relationship.py
rename to app/router/v2/relationship.py
index 02292c9..fc5c676 100644
--- a/app/router/relationship.py
+++ b/app/router/v2/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 .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/v2/room.py b/app/router/v2/room.py
new file mode 100644
index 0000000..217c716
--- /dev/null
+++ b/app/router/v2/room.py
@@ -0,0 +1,361 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Literal
+
+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 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 .router import router
+
+from fastapi import Depends, HTTPException, Query, Security
+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
+
+
+@router.get("/rooms", tags=["rooms"], response_model=list[RoomResp])
+async def get_all_rooms(
+ mode: Literal["open", "ended", "participated", "owned", None] = Query(
+ default="open"
+ ),
+ category: RoomCategory = Query(RoomCategory.NORMAL),
+ status: RoomStatus | None = Query(None),
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Security(get_current_user, scopes=["public"]),
+):
+ resp_list: list[RoomResp] = []
+ where_clauses: list[ColumnElement[bool]] = [col(Room.category) == category]
+ now = datetime.now(UTC)
+ 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))
+ )
+
+ db_rooms = (
+ (
+ await db.exec(
+ select(Room).where(
+ *where_clauses,
+ )
+ )
+ )
+ .unique()
+ .all()
+ )
+
+ for room in db_rooms:
+ resp = await RoomResp.from_db(room, db)
+ if category == RoomCategory.REALTIME:
+ 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)
+
+ return resp_list
+
+
+class APICreatedRoom(RoomResp):
+ error: str = ""
+
+
+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,
+ db: AsyncSession = Depends(get_db),
+ 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)
+ await _participate_room(db_room.id, user_id, db_room, db)
+ # 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
+
+
+@router.get("/rooms/{room}", tags=["room"], response_model=RoomResp)
+async def get_room(
+ room: int,
+ category: str = Query(default=""),
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Security(get_current_user, scopes=["*"]),
+ 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")
+ resp = await RoomResp.from_db(
+ db_room, include=["current_user_score"], session=db, user=current_user
+ )
+ return resp
+
+
+@router.delete("/rooms/{room}", tags=["room"])
+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")
+ else:
+ db_room.ends_at = datetime.now(UTC)
+ await db.commit()
+ 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),
+ 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)
+ 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")
+
+
+@router.delete("/rooms/{room}/users/{user}", tags=["room"])
+async def remove_user_from_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:
+ 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
+ else:
+ raise HTTPException(404, "Room not found")
+
+
+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 = Security(get_current_user, scopes=["public"]),
+):
+ 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(
+ 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
+ # resp.accuracy *= 100
+ aggs_resp.append(resp)
+ if agg.user_id == current_user.id:
+ user_agg = resp
+ return APILeaderboard(
+ 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 = 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),
+):
+ 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, 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]
+ 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/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/v2/score.py b/app/router/v2/score.py
new file mode 100644
index 0000000..acd3bb0
--- /dev/null
+++ b/app/router/v2/score.py
@@ -0,0 +1,770 @@
+from __future__ import annotations
+
+from datetime import UTC, date, datetime
+import time
+
+from app.calculator import clamp
+from app.config import settings
+from app.database import (
+ Beatmap,
+ Playlist,
+ Room,
+ Score,
+ ScoreResp,
+ ScoreToken,
+ 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,
+ 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.storage import get_storage_service
+from app.dependencies.user import get_current_user
+from app.fetcher import Fetcher
+from app.models.room import RoomCategory
+from app.models.score import (
+ INT_TO_MODE,
+ GameMode,
+ LeaderboardType,
+ Rank,
+ SoloScoreSubmissionInfo,
+)
+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, RedirectResponse
+from httpx import HTTPError
+from pydantic import BaseModel
+from redis.asyncio import Redis
+from sqlalchemy.orm import joinedload
+from sqlmodel import col, exists, func, select
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+READ_SCORE_TIMEOUT = 10
+
+
+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:
+ try:
+ 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.has_pp() | settings.enable_all_beatmap_leaderboard
+ )
+ beatmap_length = db_beatmap.total_length
+ score = await process_score(
+ current_user,
+ beatmap,
+ ranked,
+ score_token,
+ info,
+ fetcher,
+ db,
+ redis,
+ item_id,
+ room_id,
+ )
+ await db.refresh(current_user)
+ score_id = score.id
+ score_token.score_id = score_id
+ 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)
+
+
+class BeatmapScores(BaseModel):
+ scores: list[ScoreResp]
+ userScore: ScoreResp | None = None
+
+
+@router.get(
+ "/beatmaps/{beatmap}/scores", tags=["beatmap"], response_model=BeatmapScores
+)
+async def get_beatmap_scores(
+ beatmap: int,
+ mode: GameMode,
+ legacy_only: bool = Query(None), # TODO:加入对这个参数的查询
+ mods: list[str] = Query(default_factory=set, alias="mods[]"),
+ type: LeaderboardType = Query(LeaderboardType.GLOBAL),
+ current_user: User = Security(get_current_user, scopes=["public"]),
+ db: AsyncSession = Depends(get_db),
+ limit: int = Query(50, ge=1, le=200),
+):
+ if legacy_only:
+ raise HTTPException(
+ status_code=404, detail="this server only contains lazer scores"
+ )
+
+ all_scores, user_score = await get_leaderboard(
+ db, beatmap, mode, type=type, user=current_user, limit=limit, mods=mods
+ )
+
+ return BeatmapScores(
+ scores=[await ScoreResp.from_db(db, score) for score in all_scores],
+ userScore=await ScoreResp.from_db(db, user_score) if user_score else None,
+ )
+
+
+class BeatmapUserScore(BaseModel):
+ position: int
+ score: ScoreResp
+
+
+@router.get(
+ "/beatmaps/{beatmap}/scores/users/{user}",
+ tags=["beatmap"],
+ response_model=BeatmapUserScore,
+)
+async def get_user_beatmap_score(
+ beatmap: int,
+ user: int,
+ legacy_only: bool = Query(None),
+ mode: str = Query(None),
+ mods: str = Query(None), # TODO:添加mods筛选
+ current_user: User = Security(get_current_user, scopes=["public"]),
+ db: AsyncSession = Depends(get_db),
+):
+ if legacy_only:
+ raise HTTPException(
+ status_code=404, detail="This server only contains non-legacy scores"
+ )
+ user_score = (
+ await db.exec(
+ select(Score)
+ .where(
+ Score.gamemode == mode if mode is not None else True,
+ Score.beatmap_id == beatmap,
+ Score.user_id == user,
+ )
+ .order_by(col(Score.total_score).desc())
+ )
+ ).first()
+
+ if not user_score:
+ raise HTTPException(
+ 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=resp.rank_global or 0,
+ score=resp,
+ )
+
+
+@router.get(
+ "/beatmaps/{beatmap}/scores/users/{user}/all",
+ tags=["beatmap"],
+ response_model=list[ScoreResp],
+)
+async def get_user_all_beatmap_scores(
+ beatmap: int,
+ user: int,
+ legacy_only: bool = Query(None),
+ ruleset: str = Query(None),
+ current_user: User = Security(get_current_user, scopes=["public"]),
+ db: AsyncSession = Depends(get_db),
+):
+ if legacy_only:
+ raise HTTPException(
+ status_code=404, detail="This server only contains non-legacy scores"
+ )
+ all_user_scores = (
+ await db.exec(
+ select(Score)
+ .where(
+ Score.gamemode == ruleset if ruleset is not None else True,
+ Score.beatmap_id == beatmap,
+ Score.user_id == user,
+ )
+ .order_by(col(Score.classic_total_score).desc())
+ )
+ ).all()
+
+ return [await ScoreResp.from_db(db, score) for score in all_user_scores]
+
+
+@router.post(
+ "/beatmaps/{beatmap}/solo/scores", tags=["beatmap"], response_model=ScoreTokenResp
+)
+async def create_solo_score(
+ beatmap: int,
+ version_hash: str = Form(""),
+ beatmap_hash: str = Form(),
+ ruleset_id: int = Form(..., ge=0, le=3),
+ current_user: User = Security(get_current_user, scopes=["*"]),
+ db: AsyncSession = Depends(get_db),
+):
+ assert current_user.id
+ async with db:
+ score_token = ScoreToken(
+ user_id=current_user.id,
+ beatmap_id=beatmap,
+ ruleset_id=INT_TO_MODE[ruleset_id],
+ )
+ db.add(score_token)
+ await db.commit()
+ await db.refresh(score_token)
+ return ScoreTokenResp.from_db(score_token)
+
+
+@router.put(
+ "/beatmaps/{beatmap}/solo/scores/{token}",
+ tags=["beatmap"],
+ response_model=ScoreResp,
+)
+async def submit_solo_score(
+ beatmap: int,
+ token: int,
+ info: SoloScoreSubmissionInfo,
+ current_user: User = Security(get_current_user, scopes=["*"]),
+ db: AsyncSession = Depends(get_db),
+ redis: Redis = Depends(get_redis),
+ fetcher=Depends(get_fetcher),
+):
+ 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 = Security(get_current_user, scopes=["*"]),
+ session: AsyncSession = Depends(get_db),
+):
+ 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 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(
+ select(Playlist).where(
+ Playlist.id == playlist_id, Playlist.room_id == room_id
+ )
+ )
+ ).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"
+ )
+ if item.beatmap_id != beatmap_id:
+ raise HTTPException(
+ status_code=400, detail="Beatmap ID mismatch in playlist item"
+ )
+ 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:
+ 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 = Security(get_current_user, scopes=["*"]),
+ 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")
+
+ user_id = current_user.id
+ score_resp = await submit_score(
+ info,
+ item.beatmap_id,
+ token,
+ current_user,
+ session,
+ redis,
+ fetcher,
+ item.id,
+ room_id,
+ )
+ await process_playlist_best_score(
+ room_id,
+ playlist_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
+
+
+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 = Security(get_current_user, scopes=["public"]),
+ 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 = (
+ 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
+
+ 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,
+ 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 = Security(get_current_user, scopes=["*"]),
+ 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 = room.category != RoomCategory.REALTIME
+ 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 = Security(get_current_user, scopes=["*"]),
+ 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
+
+
+@router.put("/score-pins/{score}", status_code=204)
+async def pin_score(
+ score: int,
+ current_user: User = Security(get_current_user, scopes=["*"]),
+ 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 = Security(get_current_user, scopes=["*"]),
+ 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 = Security(get_current_user, scopes=["*"]),
+ 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()
+
+
+@router.get("/scores/{score_id}/download")
+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")
+
+ filepath = f"replays/{score.id}_{score.beatmap_id}_{score.user_id}_lazer_replay.osr"
+
+ if not await storage_service.is_exists(filepath):
+ 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()
+ 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/router/user.py b/app/router/v2/user.py
similarity index 63%
rename from app/router/user.py
rename to app/router/v2/user.py
index 089aa4f..95b8640 100644
--- a/app/router/user.py
+++ b/app/router/v2/user.py
@@ -1,5 +1,8 @@
from __future__ import annotations
+from datetime import UTC, datetime, timedelta
+from typing import Literal
+
from app.database import (
BeatmapPlaycounts,
BeatmapPlaycountsResp,
@@ -8,16 +11,18 @@ 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
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
+from fastapi import Depends, HTTPException, Query, Security
from pydantic import BaseModel
-from sqlmodel import select
+from sqlmodel import exists, false, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import col
@@ -31,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),
):
@@ -59,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(
@@ -86,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),
@@ -130,3 +137,59 @@ 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),
+ current_user: User = Security(get_current_user, scopes=["public"]),
+):
+ 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
+ )
+ 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()
+ 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()
+
+ 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/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..ec7f9d0
--- /dev/null
+++ b/app/service/daily_challenge.py
@@ -0,0 +1,121 @@
+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 .room import create_playlist_room
+
+from sqlmodel import col, select
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+
+async def create_daily_challenge_room(
+ beatmap: int, ruleset_id: int, duration: 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=duration,
+ )
+
+
+@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
+
+ now = datetime.now(UTC)
+ redis = get_redis()
+ key = f"daily_challenge:{now.date()}"
+ 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]
+ 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 {now}."
+ " 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)
+
+ 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)
+ )
+ 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/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/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/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/hub.py b/app/signalr/hub/hub.py
index f3c5b29..3c8fe98 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,
@@ -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:
@@ -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,
@@ -131,7 +146,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/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py
index 64232c0..f81aefa 100644
--- a/app/signalr/hub/metadata.py
+++ b/app/signalr/hub/metadata.py
@@ -1,18 +1,34 @@
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.database import Relationship, RelationshipType
-from app.database.lazer_user import User
+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 MetadataClientState, OnlineStatus, UserActivity
+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
-from sqlmodel import select
+from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers"
@@ -21,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]:
@@ -102,10 +140,29 @@ class MetadataHub(Hub[MetadataClientState]):
self.friend_presence_watchers_group(friend_id),
"FriendPresenceUpdated",
friend_id,
- friend_state if friend_state.pushable else None,
+ friend_state.for_push
+ if friend_state.pushable
+ else None,
)
)
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}", "")
@@ -161,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 72b4a52..e397031 100644
--- a/app/signalr/hub/multiplayer.py
+++ b/app/signalr/hub/multiplayer.py
@@ -1,6 +1,1247 @@
from __future__ import annotations
-from .hub import Hub
+import asyncio
+from datetime import UTC, datetime, timedelta
+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.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
+from app.log import logger
+from app.models.mods import APIMod
+from app.models.multiplayer_hub import (
+ BeatmapAvailability,
+ ForceGameplayStartCountdown,
+ GameplayAbortReason,
+ MatchRequest,
+ MatchServerEvent,
+ MatchStartCountdown,
+ MatchStartedEventDetail,
+ MultiplayerClientState,
+ MultiplayerRoom,
+ MultiplayerRoomSettings,
+ MultiplayerRoomUser,
+ PlaylistItem,
+ ServerMultiplayerRoom,
+ ServerShuttingDownCountdown,
+ StartMatchCountdownRequest,
+ StopCountdownRequest,
+)
+from app.models.room import (
+ DownloadState,
+ MatchType,
+ MultiplayerRoomState,
+ MultiplayerUserState,
+ RoomCategory,
+ RoomStatus,
+)
+from app.models.score import GameMode
+
+from .hub import Client, Hub
+
+from httpx import HTTPError
+from sqlalchemy import update
+from sqlmodel import col, exists, select
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+GAMEPLAY_LOAD_TIMEOUT = 30
-class MultiplayerHub(Hub): ...
+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:
+ return f"room:{room}"
+
+ @override
+ def create_state(self, client: Client) -> MultiplayerClientState:
+ return MultiplayerClientState(
+ connection_id=client.connection_id,
+ 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)
+ 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(
+ name=room.settings.name,
+ category=RoomCategory.REALTIME,
+ type=room.settings.match_type,
+ queue_mode=room.settings.queue_mode,
+ auto_skip=room.settings.auto_skip,
+ auto_start_duration=int(
+ room.settings.auto_start_duration.total_seconds()
+ ),
+ host_id=client.user_id,
+ status=RoomStatus.IDLE,
+ )
+ 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:
+ await Beatmap.get_or_fetch(
+ session, fetcher, bid=item.beatmap_id
+ )
+ except HTTPError:
+ raise InvokeException(
+ "Failed to fetch beatmap, please retry later"
+ )
+ await Playlist.add_to_db(item, room.room_id, session)
+
+ server_room = ServerMultiplayerRoom(
+ room=room,
+ category=RoomCategory.NORMAL,
+ start_at=starts_at,
+ hub=self,
+ )
+ 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
+ )
+
+ 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)
+ 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", user)
+ 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)
+
+ 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)
+
+ db_room = await session.get(Room, room_id)
+ if db_room is None:
+ raise InvokeException("Room does not exist in database")
+ 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
+ ):
+ 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_beatmap_availability(
+ room.room_id,
+ user,
+ 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
+
+ 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
+
+ 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
+
+ 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.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,
+ )
+
+ async def playlist_added(self, room: ServerMultiplayerRoom, item: PlaylistItem):
+ await self.broadcast_group_call(
+ self.group_id(room.room.room_id),
+ "PlaylistItemAdded",
+ 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
+ ):
+ 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,
+ )
+
+ 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):
+ fetcher = await get_fetcher()
+ 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:
+ 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(
+ 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:
+ 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,
+ ):
+ 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,
+ )
+
+ async def validate_user_stare(
+ self,
+ room: ServerMultiplayerRoom,
+ old: MultiplayerUserState,
+ new: MultiplayerUserState,
+ ):
+ 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.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]
+ )
+ 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:
+ 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
+ )
+ await self.change_room_state(room, MultiplayerRoomState.OPEN)
+ await self.broadcast_group_call(
+ 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(
+ 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")
+
+ # 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)
+
+ async def start_match(self, room: ServerMultiplayerRoom):
+ 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(
+ time_remaining=timedelta(seconds=GAMEPLAY_LOAD_TIMEOUT)
+ ),
+ 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:
+ raise InvokeException("Room is not ready for gameplay")
+ 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:
+ continue
+
+ if user.state in (
+ MultiplayerUserState.READY_FOR_GAMEPLAY,
+ 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:
+ 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),
+ )
+ 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
+ ):
+ await self.broadcast_group_call(
+ self.group_id(room.room.room_id),
+ "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 (
+ 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)
+
+ 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
+ )
+
+ 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
+
+ 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,
+ 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,
+ auto_start_duration=int(
+ room.room.settings.auto_start_duration.total_seconds()
+ ),
+ 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):
+ 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.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):
+ 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 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")
+
+ 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
+ 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.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):
+ 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
+ and room.state != MultiplayerRoomState.WAITING_FOR_LOAD
+ ):
+ raise InvokeException("Cannot abort a match that hasn't started.")
+
+ 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)
+
+ 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):
+ 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):
+ 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,
+ )
+
+ 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)
diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py
index b9a3c99..418228c 100644
--- a/app/signalr/hub/spectator.py
+++ b/app/signalr/hub/spectator.py
@@ -7,11 +7,13 @@ 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.models.beatmap import BeatmapRankStatus
+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 (
@@ -24,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
@@ -63,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,
@@ -135,8 +136,14 @@ def save_replay(
data.extend(struct.pack(" 0 for k, v in store.score.score_info.statistics.items()
- ):
+ 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
store.beatmap_status = None
@@ -296,7 +302,7 @@ class SpectatorHub(Hub[StoreClientState]):
score_record.has_replay = True
await session.commit()
await session.refresh(score_record)
- save_replay(
+ await save_replay(
ruleset_id=store.ruleset_id,
md5=store.checksum,
username=store.score.score_info.user.name,
diff --git a/app/signalr/packet.py b/app/signalr/packet.py
index be98c39..8949f4b 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
@@ -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() * 10_000_000)
elif isinstance(v, dict):
return {
cls.serialize_msgpack(k): cls.serialize_msgpack(value)
@@ -126,15 +128,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
@@ -209,7 +215,9 @@ 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 isinstance(v, list):
+ elif inspect.isclass(typ) and issubclass(typ, datetime.timedelta):
+ 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)
@@ -234,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"
@@ -292,36 +302,55 @@ class MsgpackProtocol:
class JSONProtocol:
@classmethod
- def serialize_to_json(cls, v: Any):
+ 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): 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
- 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[snake_to_camel(field, metadata.use_upper_case if metadata else False)] = (
- cls.serialize_to_json(getattr(v, field))
+ name = (
+ snake_to_camel(
+ field,
+ metadata.use_abbr if metadata else True,
+ )
+ if not is_union
+ else snake_to_pascal(
+ field,
+ metadata.use_abbr if metadata else True,
+ )
)
- if issubclass(v.__class__, SignalRUnionMessage):
+ 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,
@@ -339,7 +368,12 @@ class JSONProtocol:
)
if metadata and metadata.json_ignore:
continue
- value = v.get(snake_to_camel(field, not from_union))
+ 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
@@ -397,7 +431,18 @@ 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 isinstance(v, list):
+ 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 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/signalr/router.py b/app/signalr/router.py
index 72b22ac..cb63d6b 100644
--- a/app/signalr/router.py
+++ b/app/signalr/router.py
@@ -6,26 +6,26 @@ import time
from typing import Literal
import uuid
-from app.database import User
+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()
+router = APIRouter(prefix="/signalr", tags=["SignalR"])
@router.post("/{hub}/negotiate", response_model=NegotiateResponse)
async def negotiate(
hub: Literal["spectator", "multiplayer", "metadata"],
negotiate_version: int = Query(1, alias="negotiateVersion"),
- user: User = Depends(get_current_user),
+ user: DBUser = Depends(get_current_user),
):
connectionId = str(user.id)
connectionToken = f"{connectionId}:{uuid.uuid4()}"
@@ -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()
@@ -92,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)
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..b60eb35
--- /dev/null
+++ b/app/storage/local.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from app.config import settings
+
+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 f"{settings.server_url}file/{file_path.lstrip('/')}"
diff --git a/app/utils.py b/app/utils.py
index 0d759a1..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) -> str:
+def snake_to_camel(name: str, use_abbr: bool = True) -> str:
"""Convert a snake_case string to camelCase."""
if not name:
return name
@@ -47,12 +47,46 @@ 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:
+ 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)
diff --git a/docker-compose-osurx.yml b/docker-compose-osurx.yml
new file mode 100644
index 0000000..e2f5bbc
--- /dev/null
+++ b/docker-compose-osurx.yml
@@ -0,0 +1,79 @@
+version: '3.8'
+
+services:
+ app:
+ # or use
+ # image: mingxuangame/osu-lazer-api-osurx:latest
+ 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..8e2e181 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,50 +1,74 @@
-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:
+ # or use
+ # image: mingxuangame/osu-lazer-api:latest
+ 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/main.py b/main.py
index f5d20c1..8d14f94 100644
--- a/main.py
+++ b/main.py
@@ -4,29 +4,55 @@ 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.router import api_router, auth_router, fetcher_router, signalr_router
+from app.dependencies.scheduler import init_scheduler, stop_scheduler
+from app.log import logger
+from app.router import (
+ api_v2_router,
+ auth_router,
+ fetcher_router,
+ file_router,
+ private_router,
+ 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
+from fastapi.middleware.cors import CORSMiddleware
@asynccontextmanager
async def lifespan(app: FastAPI):
# on startup
- await create_tables()
+ await create_rx_statistics()
await get_fetcher() # 初始化 fetcher
+ init_scheduler()
+ await daily_challenge_job()
# on shutdown
yield
+ stop_scheduler()
await engine.dispose()
await redis_client.aclose()
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(file_router)
app.include_router(auth_router)
+app.include_router(private_router)
+# CORS 配置
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[str(settings.server_url)],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
@app.get("/")
@@ -41,114 +67,30 @@ async def health_check():
return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}
-# @app.get("/api/v2/friends")
-# async def get_friends():
-# return JSONResponse(
-# content=[
-# {
-# "id": 123456,
-# "username": "BestFriend",
-# "is_online": True,
-# "is_supporter": False,
-# "country": {"code": "US", "name": "United States"},
-# }
-# ]
-# )
-
-
-# @app.get("/api/v2/notifications")
-# async def get_notifications():
-# return JSONResponse(content={"notifications": [], "unread_count": 0})
-
-
-# @app.post("/api/v2/chat/ack")
-# async def chat_ack():
-# return JSONResponse(content={"status": "ok"})
-
-
-# @app.get("/api/v2/users/{user_id}/{mode}")
-# async def get_user_mode(user_id: int, mode: str):
-# return JSONResponse(
-# content={
-# "id": user_id,
-# "username": "测试测试测",
-# "statistics": {
-# "level": {"current": 97, "progress": 96},
-# "pp": 114514,
-# "global_rank": 666,
-# "country_rank": 1,
-# "hit_accuracy": 100,
-# },
-# "country": {"code": "JP", "name": "Japan"},
-# }
-# )
-
-
-# @app.get("/api/v2/me")
-# async def get_me():
-# return JSONResponse(
-# content={
-# "id": 15651670,
-# "username": "Googujiang",
-# "is_online": True,
-# "country": {"code": "JP", "name": "Japan"},
-# "statistics": {
-# "level": {"current": 97, "progress": 96},
-# "pp": 2826.26,
-# "global_rank": 298026,
-# "country_rank": 11220,
-# "hit_accuracy": 95.7168,
-# },
-# }
-# )
-
-
-# @app.post("/signalr/metadata/negotiate")
-# async def metadata_negotiate(negotiateVersion: int = 1):
-# return JSONResponse(
-# content={
-# "connectionId": "abc123",
-# "availableTransports": [
-# {"transport": "WebSockets", "transferFormats": ["Text", "Binary"]}
-# ],
-# }
-# )
-
-
-# @app.post("/signalr/spectator/negotiate")
-# async def spectator_negotiate(negotiateVersion: int = 1):
-# return JSONResponse(
-# content={
-# "connectionId": "spec456",
-# "availableTransports": [
-# {"transport": "WebSockets", "transferFormats": ["Text", "Binary"]}
-# ],
-# }
-# )
-
-
-# @app.post("/signalr/multiplayer/negotiate")
-# async def multiplayer_negotiate(negotiateVersion: int = 1):
-# return JSONResponse(
-# content={
-# "connectionId": "multi789",
-# "availableTransports": [
-# {"transport": "WebSockets", "transferFormats": ["Text", "Binary"]}
-# ],
-# }
-# )
-
+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 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__":
- from app.log import logger # noqa: F401
-
import uvicorn
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/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/migrations/versions/.gitkeep b/migrations/versions/.gitkeep
deleted file mode 100644
index e69de29..0000000
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 ###
diff --git a/migrations/versions/1178d0758ebf_beatmapset_support_favourite_count.py b/migrations/versions/319e5f841dcf_score_support_pin_score.py
similarity index 56%
rename from migrations/versions/1178d0758ebf_beatmapset_support_favourite_count.py
rename to migrations/versions/319e5f841dcf_score_support_pin_score.py
index 84bae15..ceacdec 100644
--- a/migrations/versions/1178d0758ebf_beatmapset_support_favourite_count.py
+++ b/migrations/versions/319e5f841dcf_score_support_pin_score.py
@@ -1,8 +1,8 @@
-"""beatmapset: support favourite count
+"""score: support pin score
-Revision ID: 1178d0758ebf
-Revises:
-Create Date: 2025-08-01 04:05:09.882800
+Revision ID: 319e5f841dcf
+Revises: 19cdc9ce4dcb
+Create Date: 2025-08-10 14:07:51.749025
"""
@@ -12,11 +12,10 @@ 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
+revision: str = "319e5f841dcf"
+down_revision: str | Sequence[str] | None = "19cdc9ce4dcb"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
@@ -24,17 +23,12 @@ 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")
+ 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.add_column(
- "beatmapsets",
- sa.Column(
- "favourite_count", mysql.INTEGER(), autoincrement=False, nullable=False
- ),
- )
+ op.drop_column("scores", "pinned_order")
# ### 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/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/migrations/versions/aa582c13f905_count_add_replays_watched_counts.py b/migrations/versions/aa582c13f905_count_add_replays_watched_counts.py
new file mode 100644
index 0000000..e470345
--- /dev/null
+++ b/migrations/versions/aa582c13f905_count_add_replays_watched_counts.py
@@ -0,0 +1,90 @@
+"""count: add replays_watched_counts
+
+Revision ID: aa582c13f905
+Revises: 319e5f841dcf
+Create Date: 2025-08-11 08:03:33.739398
+
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "aa582c13f905"
+down_revision: str | Sequence[str] | None = "319e5f841dcf"
+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(
+ "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 ###
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 ###
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/pyproject.toml b/pyproject.toml
index cd90947..d29db87 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,8 +5,11 @@ 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",
"bcrypt>=4.1.2",
"cryptography>=41.0.7",
"fastapi>=0.104.1",
@@ -14,12 +17,13 @@ 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",
"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",
@@ -103,4 +107,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/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/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
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",
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()
diff --git a/uv.lock b/uv.lock
index 3fc7d3c..54f2c03 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.12"
[manifest]
@@ -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"
@@ -57,6 +184,27 @@ 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 = "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"
@@ -107,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"
@@ -280,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"
@@ -390,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"
@@ -477,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"
@@ -491,8 +811,11 @@ name = "osu-lazer-api"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
+ { name = "aioboto3" },
+ { name = "aiofiles" },
{ name = "aiomysql" },
{ name = "alembic" },
+ { name = "apscheduler" },
{ name = "bcrypt" },
{ name = "cryptography" },
{ name = "fastapi" },
@@ -500,12 +823,13 @@ dependencies = [
{ name = "loguru" },
{ name = "msgpack-lazer-api" },
{ name = "passlib", extra = ["bcrypt"] },
+ { name = "pillow" },
{ name = "pydantic", extra = ["email"] },
+ { name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "redis" },
- { name = "rosu-pp-py" },
{ name = "sqlalchemy" },
{ name = "sqlmodel" },
{ name = "uvicorn", extra = ["standard"] },
@@ -516,12 +840,16 @@ 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" },
{ name = "bcrypt", specifier = ">=4.1.2" },
{ name = "cryptography", specifier = ">=41.0.7" },
{ name = "fastapi", specifier = ">=0.104.1" },
@@ -529,12 +857,13 @@ 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" },
{ 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" },
@@ -545,6 +874,7 @@ 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]]
@@ -561,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"
@@ -586,6 +982,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"
@@ -666,6 +1119,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"
@@ -675,6 +1142,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"
@@ -747,32 +1226,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"
@@ -810,6 +1263,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"
@@ -883,6 +1348,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"
@@ -904,6 +1490,36 @@ 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 = "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"
@@ -1068,3 +1684,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" },
+]