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" }, +]