feat(performance-point): switch performance calculator to performance-server (#80)

* feat(config): make `performance_server` as default calculator

* deploy(docker): use osu-performance-server

* docs(readme): add ruleset download instructions

* chore(dev): update development environment

* feat(dev): update development environment setup and service startup order

* fix(deps): move `rosu-pp-py` to `project.optional-dependencies`

* feat(beatmap): handle deleted beatmaps

* feat(performance-server): add a long timeout for calculation

* feat(recalculate): enhance CLI arguments for performance, leaderboard, and rating recalculations with CSV output support

* fix(recalculate): resolve reviews

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(beatmapsync): resolve too long line

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
MingxuanGame
2025-11-09 01:59:09 +08:00
committed by GitHub
parent 293e57aea3
commit 0be3e903d4
20 changed files with 942 additions and 264 deletions

View File

@@ -107,6 +107,6 @@
80,
8080
],
"postCreateCommand": "uv sync --dev && uv run alembic upgrade head && uv run pre-commit install && cd spectator-server && dotnet restore",
"postCreateCommand": "uv sync --dev --all-extras && uv run alembic upgrade head && uv run pre-commit install && cd spectator-server && dotnet restore && cd ../performance-server && dotnet restore",
"remoteUser": "vscode"
}

View File

@@ -22,6 +22,7 @@ services:
REDIS_URL: redis://redis:6379/0
OSU_CLIENT_ID: "5"
OSU_CLIENT_SECRET: "FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk"
CALCULATOR_CONFIG: '{"server_url":"http://localhost:8090"}'
# Spectator Server 环境变量
SAVE_REPLAYS: "0"
@@ -34,11 +35,12 @@ services:
SHARED_INTEROP_SECRET: "dev-interop-secret"
SENTRY_DSN: "https://5840d8cb8d2b4d238369443bedef1d74@glitchtip.g0v0.top/4"
USE_LEGACY_RSA_AUTH: "0"
# .NET 环境变量
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
RULESETS_PATH: "/workspaces/osu_lazer_api/rulesets"
mysql:
image: mysql:8.0
restart: unless-stopped

View File

@@ -1,7 +1,7 @@
#!/bin/bash
# 开发环境启动脚本
# 同时启动 FastAPI Spectator Server
# 按依赖顺序启动Performance Server → FastAPI Spectator Server
set -e
@@ -16,27 +16,92 @@ fi
echo "🚀 启动开发环境..."
# 启动 FastAPI 服务器
# 清理函数
cleanup() {
echo "🛑 正在停止服务..."
[ ! -z "$SPECTATOR_PID" ] && kill $SPECTATOR_PID 2>/dev/null || true
[ ! -z "$FASTAPI_PID" ] && kill $FASTAPI_PID 2>/dev/null || true
[ ! -z "$PERFORMANCE_PID" ] && kill $PERFORMANCE_PID 2>/dev/null || true
exit ${1:-0}
}
# 捕获中断信号和错误
trap 'cleanup 1' INT TERM ERR
# 健康检查函数
wait_for_service() {
local url=$1
local service_name=$2
local pre_sleep=$3
local max_attempts=30
local attempt=0
echo "等待 $service_name 启动..."
if [ ! -z "$pre_sleep" ]; then
sleep $pre_sleep
fi
while [ $attempt -lt $max_attempts ]; do
# 使用 curl 检查,添加 10 秒超时,区分连接失败和 HTTP 错误
http_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 5 "$url" 2>/dev/null || echo "000")
if [ "$http_code" = "200" ] || [ "$http_code" = "404" ]; then
echo "$service_name 已就绪 (HTTP $http_code)"
return 0
elif [ "$http_code" = "000" ]; then
# 连接被拒绝或超时,服务还在启动中
echo "$service_name 正在启动... (尝试 $((attempt + 1))/$max_attempts)"
else
# 其他 HTTP 状态码
echo " ⚠️ $service_name 返回 HTTP $http_code (尝试 $((attempt + 1))/$max_attempts)"
fi
attempt=$((attempt + 1))
sleep 2
done
echo "$service_name 启动超时"
return 1
}
# 1. 启动 Performance Server (最底层依赖)
echo "启动 Performance Server..."
cd /workspaces/osu_lazer_api/performance-server
dotnet run --project PerformanceServer --urls "http://0.0.0.0:8090" &
PERFORMANCE_PID=$!
# 等待 Performance Server 就绪
if ! wait_for_service "http://localhost:8090" "Performance Server"; then
echo "Performance Server 启动失败,停止启动流程"
cleanup 1
fi
# 2. 启动 FastAPI 服务器 (依赖 Performance Server)
echo "启动 FastAPI 服务器..."
cd /workspaces/osu_lazer_api
uv run uvicorn main:app --host 0.0.0.0 --port 8000 --reload &
FASTAPI_PID=$!
# 启动 Spectator Server
# 等待 FastAPI 就绪
if ! wait_for_service "http://localhost:8000/health" "FastAPI"; then
echo "FastAPI 启动失败,停止启动流程"
cleanup 1
fi
# 3. 启动 Spectator Server (依赖 FastAPI)
echo "启动 Spectator Server..."
cd /workspaces/osu_lazer_api/spectator-server
dotnet run --project osu.Server.Spectator --urls "http://0.0.0.0:8086" &
SPECTATOR_PID=$!
echo "✅ 服务已启动:"
echo ""
echo "✅ 所有服务已启动:"
echo " - FastAPI: http://localhost:8000"
echo " - Spectator Server: http://localhost:8086"
echo " - Performance Server: http://localhost:8090"
echo " - Nginx (统一入口): http://localhost:8080"
echo ""
echo "按 Ctrl+C 停止所有服务"
# 等待用户中断
trap 'echo "🛑 正在停止服务..."; kill $FASTAPI_PID $SPECTATOR_PID; exit 0' INT
# 保持脚本运行
wait

View File

@@ -1,57 +0,0 @@
name: Build and Push Docker Image (osu!RX)
on:
push:
branches: [ main ]
paths-ignore:
- '*.md'
- '**/*.md'
- 'docs/**'
env:
IMAGE_NAME: mingxuangame/g0v0-server
jobs:
build-and-push-osurx:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=osurx
type=sha,prefix=osurx-
- name: Build and push Docker image (osu!RX)
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile-osurx
platforms: linux/amd64, linux/arm64
push: true
tags: |
${{ env.IMAGE_NAME }}:osurx
${{ env.IMAGE_NAME }}:osurx-${{ github.sha }}
cache-from: type=gha,scope=osurx
cache-to: type=gha,mode=max,scope=osurx

3
.gitignore vendored
View File

@@ -216,7 +216,7 @@ bancho.py-master/*
storage/
replays/
osu-master/*
rulesets/
geoip/*
newrelic.ini
logs/
@@ -230,3 +230,4 @@ config/*
!config/
!config/.gitkeep
osu-web-master/*
performance-server

View File

@@ -6,11 +6,18 @@
git clone https://github.com/GooGuTeam/g0v0-server.git
```
此外,您还需要 clone 一个 spectator-server 到 g0v0-server 的文件夹。
此外,您还需要
- clone 旁观服务器到 g0v0-server 的文件夹。
```bash
git clone https://github.com/GooGuTeam/osu-server-spectator.git spectator-server
```
- clone 表现分计算器到 g0v0-server 的文件夹。
```bash
git clone https://github.com/GooGuTeam/osu-performance-server.git performance-server
```
- 下载并放置自定义规则集 DLL 到 `rulesets/` 目录(如果需要)。
## 开发环境

View File

@@ -1,55 +0,0 @@
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 ./
RUN uv sync --frozen --no-dev
RUN uv pip install git+https://github.com/GooGuTeam/gu-pp-py.git
COPY alembic.ini ./
COPY tools/ ./tools/
COPY migrations/ ./migrations/
COPY static/ ./app/static/
COPY app/ ./app/
COPY main.py ./
# ---
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
RUN mkdir -p /app/logs
VOLUME ["/app/logs"]
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"]

View File

@@ -56,14 +56,16 @@ Go to [custom-rulesets](https://github.com/GooGuTeam/custom-rulesets) to downloa
```bash
cp .env.example .env
```
3. Start the service
3. (Optional) Download rulesets
Go to [custom-rulesets](https://github.com/GooGuTeam/custom-rulesets/releases/latest) to download the custom rulesets modified for g0v0-server. Place the downloaded DLLs into the `rulesets/` directory to enable custom ruleset support in the spectator server and performance calculator.
4. Start the service
```bash
# Standard server
docker-compose -f docker-compose.yml up -d
# Enable osu!RX and osu!AP statistics (Gu pp algorithm based on ppy-sb pp algorithm)
docker-compose -f docker-compose-osurx.yml up -d
```
4. Connect to the server from the game
5. Connect to the server from the game
Use a [custom osu!lazer client](https://github.com/GooGuTeam/osu), or use [LazerAuthlibInjection](https://github.com/MingxuanGame/LazerAuthlibInjection), and change the server settings to the server's address.

View File

@@ -60,7 +60,11 @@ cd g0v0-server
cp .env.example .env
```
3. 启动服务
3. (可选)下载 rulesets
前往 [custom-rulesets](https://github.com/GooGuTeam/custom-rulesets/releases/latest) 下载为 g0v0-server 修改的自定义 ruleset。将下载的 DLL 放入 `rulesets/` 目录,以在旁观服务器和表现分计算器中启用自定义 ruleset 支持。
4. 启动服务
```bash
# 标准服务器
docker-compose -f docker-compose.yml up -d
@@ -68,7 +72,7 @@ docker-compose -f docker-compose.yml up -d
docker-compose -f docker-compose-osurx.yml up -d
```
4. 通过游戏连接服务器
5. 通过游戏连接服务器
使用[自定义的 osu!lazer 客户端](https://github.com/GooGuTeam/osu),或者使用 [LazerAuthlibInjection](https://github.com/MingxuanGame/LazerAuthlibInjection),修改服务器设置为服务器的 IP

View File

@@ -83,7 +83,7 @@ class PerformanceServerPerformanceCalculator(BasePerformanceCalculator):
async def calculate_performance(self, beatmap_raw: str, score: "Score") -> PerformanceAttributes:
# https://github.com/GooGuTeam/osu-performance-server#post-performance
async with AsyncClient() as client:
async with AsyncClient(timeout=15) as client:
try:
resp = await client.post(
f"{self.server_url}/performance",
@@ -121,7 +121,7 @@ class PerformanceServerPerformanceCalculator(BasePerformanceCalculator):
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
) -> DifficultyAttributes:
# https://github.com/GooGuTeam/osu-performance-server#post-difficulty
async with AsyncClient() as client:
async with AsyncClient(timeout=15) as client:
try:
resp = await client.post(
f"{self.server_url}/difficulty",

View File

@@ -114,14 +114,7 @@ STORAGE_SETTINGS='{
""",
"表现计算设置": """配置表现分计算器及其参数。
### rosu-pp-py (默认)
```bash
CALCULATOR="rosu"
CALCULATOR_CONFIG='{}'
```
### [osu-performance-server](https://github.com/GooGuTeam/osu-performance-server)
### [osu-performance-server](https://github.com/GooGuTeam/osu-performance-server) (默认)
```bash
CALCULATOR="performance_server"
@@ -129,6 +122,13 @@ CALCULATOR_CONFIG='{
"server_url": "http://localhost:5225"
}'
```
### rosu-pp-py
```bash
CALCULATOR="rosu"
CALCULATOR_CONFIG='{}'
```
""",
}
},
@@ -533,13 +533,13 @@ CALCULATOR_CONFIG='{
# 表现计算设置
calculator: Annotated[
Literal["rosu", "performance_server"],
Field(default="rosu", description="表现分计算器"),
Field(default="performance_server", description="表现分计算器"),
"表现计算设置",
]
calculator_config: Annotated[
dict[str, Any],
Field(
default={},
default={"server_url": "http://localhost:5225"},
description="表现分计算器配置 (JSON 格式),具体配置项请参考上方",
),
"表现计算设置",

View File

@@ -160,6 +160,7 @@ class BeatmapResp(BeatmapBase):
failtimes: FailTimeResp | None = None
top_tag_ids: list[APIBeatmapTag] | None = None
current_user_tag_ids: list[int] | None = None
is_deleted: bool = False
@classmethod
async def from_db(
@@ -184,6 +185,7 @@ class BeatmapResp(BeatmapBase):
beatmap_["status"] = beatmap_status.name.lower()
beatmap_["ranked"] = beatmap_status.value
beatmap_["mode_int"] = int(beatmap.mode)
beatmap_["is_deleted"] = beatmap.deleted_at is not None
if not from_set:
beatmap_["beatmapset"] = await BeatmapsetResp.from_db(beatmap.beatmapset, session=session, user=user)
if beatmap.failtimes is not None:

View File

@@ -23,11 +23,13 @@ class BeatmapRawFetcher(BaseFetcher):
resp = await self._request(req_url)
if resp.status_code >= 400:
continue
if not resp.text:
continue
return resp.text
raise HTTPError("Failed to fetch beatmap")
async def _request(self, url: str) -> Response:
async with AsyncClient() as client:
async with AsyncClient(timeout=15) as client:
response = await client.get(
url,
)

View File

@@ -182,6 +182,7 @@ class BeatmapsetUpdateService:
logger.error(f"failed to add missing beatmapset {missing}: {e}")
if total > 0:
logger.opt(colors=True).info(f"added {total} missing beatmapset")
await session.commit()
self._adding_missing = False
async def add(self, beatmapset: BeatmapsetResp, calculate_next_sync: bool = True):
@@ -397,7 +398,15 @@ class BeatmapsetUpdateService:
existing_beatmap = await session.get(Beatmap, change.beatmap_id)
if existing_beatmap:
await session.merge(new_db_beatmap)
if change.type == BeatmapChangeType.MAP_DELETED:
existing_beatmap.deleted_at = utcnow()
await session.commit()
else:
if change.type == BeatmapChangeType.MAP_DELETED:
logger.opt(colors=True).warning(
f"<g>[beatmap: {change.beatmap_id}]</g> MAP_DELETED received "
f"but beatmap not found in database; deletion skipped"
)
if change.type != BeatmapChangeType.STATUS_CHANGED:
await _process_update_or_delete_beatmaps(change.beatmap_id)
await get_beatmapset_cache_service(get_redis()).invalidate_beatmap_lookup_cache(change.beatmap_id)

View File

@@ -3,10 +3,10 @@ version: '3.8'
services:
app:
# or use
# image: mingxuangame/g0v0-server:osurx
# image: mingxuangame/g0v0-server:latest
build:
context: .
dockerfile: Dockerfile-osurx
dockerfile: Dockerfile
container_name: osu_api_server_osurx
environment:
- MYSQL_HOST=mysql
@@ -17,8 +17,7 @@ services:
- ENABLE_ALL_MODS_PP=true
- ENABLE_SUPPORTER_FOR_ALL_USERS=true
- ENABLE_ALL_BEATMAP_LEADERBOARD=true
# - CALCULATOR=performance_server
# - CALCULATOR_CONFIG='{"server_url":"http://performance-server:8080"}'
- CALCULATOR_CONFIG='{"server_url":"http://performance-server:8080"}'
env_file:
- .env
depends_on:
@@ -79,7 +78,7 @@ services:
command: redis-server --appendonly yes
spectator:
image: ghcr.io/googuteam/osu-server-spectator-custom-rulesets:master
image: ghcr.io/googuteam/osu-server-spectator:master
pull_policy: never
environment:
- REPLAY_UPLOAD_THREADS=${REPLAY_UPLOAD_THREADS:-1}
@@ -98,6 +97,8 @@ services:
- mysql
- redis
restart: unless-stopped
volumes:
- ./rulesets:/data/rulesets
networks:
- osu-network
@@ -111,14 +112,16 @@ services:
networks:
- osu-network
# performance-server:
# image: ghcr.io/googuteam/osu-performance-server-osurx:custom-rulesets
# container_name: performance_server_osurx
# environment:
# - SAVE_BEATMAP_FILES=false
# restart: unless-stopped
# networks:
# - osu-network
performance-server:
image: ghcr.io/googuteam/osu-performance-server-osurx
container_name: performance_server_osurx
environment:
- SAVE_BEATMAP_FILES=false
restart: unless-stopped
networks:
- osu-network
volumes:
- ./rulesets:/data/rulesets
volumes:
mysql_data:

View File

@@ -14,8 +14,7 @@ services:
- MYSQL_HOST=mysql
- MYSQL_PORT=3306
- REDIS_URL=redis://redis:6379
# - CALCULATOR=performance_server
# - CALCULATOR_CONFIG='{"server_url":"http://performance-server:8080"}'
- CALCULATOR_CONFIG='{"server_url":"http://performance-server:8080"}'
env_file:
- .env
depends_on:
@@ -23,6 +22,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
performance-server:
condition: service_healthy
volumes:
- ./replays:/app/replays
- ./storage:/app/storage
@@ -56,7 +57,7 @@ services:
- osu-network
spectator:
image: ghcr.io/googuteam/osu-server-spectator-custom-rulesets:master
image: ghcr.io/googuteam/osu-server-spectator:master
pull_policy: never
environment:
- REPLAY_UPLOAD_THREADS=${REPLAY_UPLOAD_THREADS:-1}
@@ -75,6 +76,8 @@ services:
- mysql
- redis
restart: unless-stopped
volumes:
- ./rulesets:/data/rulesets
networks:
- osu-network
@@ -104,14 +107,16 @@ services:
- osu-network
command: redis-server --appendonly yes
# performance-server:
# image: ghcr.io/googuteam/osu-performance-server:custom-rulesets
# container_name: performance_server
# environment:
# - SAVE_BEATMAP_FILES=false
# restart: unless-stopped
# networks:
# - osu-network
performance-server:
image: ghcr.io/googuteam/osu-performance-server:latest
container_name: performance_server
environment:
- SAVE_BEATMAP_FILES=false
volumes:
- ./rulesets:/data/rulesets
restart: unless-stopped
networks:
- osu-network
volumes:
mysql_data:

View File

@@ -1,10 +1,13 @@
{
"folders": [
{
"path": "."
},
{
"path": "spectator-server"
}
]
"folders": [
{
"path": "."
},
{
"path": "spectator-server"
},
{
"path": "performance-server"
}
]
}

View File

@@ -31,7 +31,6 @@ dependencies = [
"python-jose[cryptography]>=3.3.0",
"python-multipart>=0.0.6",
"redis>=5.0.1",
"rosu-pp-py>=3.1.0",
"sentry-sdk[fastapi,httpx,loguru,sqlalchemy]>=2.34.1",
"sqlalchemy>=2.0.23",
"sqlmodel>=0.0.24",
@@ -40,6 +39,11 @@ dependencies = [
]
authors = [{ name = "GooGuTeam" }]
[project.optional-dependencies]
rosu = [
"rosu-pp-py>=3.1.0",
]
[tool.ruff]
line-length = 120
target-version = "py312"

View File

@@ -3,20 +3,23 @@ from __future__ import annotations
import argparse
import asyncio
from collections.abc import Awaitable, Sequence
import csv
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
import os
from pathlib import Path
import sys
import warnings
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.calculator import calculate_pp, calculate_score_to_level, init_calculator
from app.calculators.performance import CalculateError
from app.config import settings
from app.const import BANCHOBOT_ID
from app.database import TotalScoreBestScore, UserStatistics
from app.database.beatmap import Beatmap
from app.database.beatmap import Beatmap, calculate_beatmap_attributes
from app.database.best_scores import BestScore
from app.database.score import Score, calculate_playtime, calculate_user_pp
from app.dependencies.database import engine, get_redis
@@ -38,40 +41,44 @@ warnings.filterwarnings("ignore")
@dataclass(frozen=True)
class RecalculateConfig:
class GlobalConfig:
dry_run: bool
concurrency: int
output_csv: str | None
@dataclass(frozen=True)
class PerformanceConfig:
user_ids: set[int]
modes: set[GameMode]
mods: set[str]
beatmap_ids: set[int]
beatmapset_ids: set[int]
dry_run: bool
concurrency: int
recalculate_all: bool
def parse_cli_args(argv: list[str]) -> RecalculateConfig:
@dataclass(frozen=True)
class LeaderboardConfig:
user_ids: set[int]
modes: set[GameMode]
mods: set[str]
beatmap_ids: set[int]
beatmapset_ids: set[int]
recalculate_all: bool
@dataclass(frozen=True)
class RatingConfig:
modes: set[GameMode]
beatmap_ids: set[int]
beatmapset_ids: set[int]
recalculate_all: bool
def parse_cli_args(
argv: list[str],
) -> tuple[str, GlobalConfig, PerformanceConfig | LeaderboardConfig | RatingConfig | None]:
parser = argparse.ArgumentParser(description="Recalculate stored performance data")
parser.add_argument("--user-id", dest="user_ids", action="append", type=int, help="Filter by user id")
parser.add_argument(
"--mode",
dest="modes",
action="append",
help="Filter by game mode (accepts names like osu, taiko or numeric ids)",
)
parser.add_argument(
"--mod",
dest="mods",
action="append",
help="Filter by mod acronym (can be passed multiple times or comma separated)",
)
parser.add_argument("--beatmap-id", dest="beatmap_ids", action="append", type=int, help="Filter by beatmap id")
parser.add_argument(
"--beatmapset-id",
dest="beatmapset_ids",
action="append",
type=int,
help="Filter by beatmapset id",
)
parser.add_argument("--dry-run", dest="dry_run", action="store_true", help="Execute without committing changes")
parser.add_argument(
"--concurrency",
@@ -81,54 +88,340 @@ def parse_cli_args(argv: list[str]) -> RecalculateConfig:
help="Maximum number of concurrent recalculation tasks",
)
parser.add_argument(
"--output-csv",
dest="output_csv",
type=str,
help="Output results to a CSV file at the specified path",
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# performance subcommand
perf_parser = subparsers.add_parser("performance", help="Recalculate performance points (pp) and best scores")
perf_parser.add_argument("--user-id", dest="user_ids", action="append", type=int, help="Filter by user id")
perf_parser.add_argument(
"--mode",
dest="modes",
action="append",
help="Filter by game mode (accepts names like osu, taiko or numeric ids)",
)
perf_parser.add_argument(
"--mod",
dest="mods",
action="append",
help="Filter by mod acronym (can be passed multiple times or comma separated)",
)
perf_parser.add_argument("--beatmap-id", dest="beatmap_ids", action="append", type=int, help="Filter by beatmap id")
perf_parser.add_argument(
"--beatmapset-id",
dest="beatmapset_ids",
action="append",
type=int,
help="Filter by beatmapset id",
)
perf_parser.add_argument(
"--all",
dest="recalculate_all",
action="store_true",
help="Recalculate all users across all modes (ignores filter requirement)",
)
# leaderboard subcommand
lead_parser = subparsers.add_parser("leaderboard", help="Recalculate leaderboard (TotalScoreBestScore)")
lead_parser.add_argument("--user-id", dest="user_ids", action="append", type=int, help="Filter by user id")
lead_parser.add_argument(
"--mode",
dest="modes",
action="append",
help="Filter by game mode (accepts names like osu, taiko or numeric ids)",
)
lead_parser.add_argument(
"--mod",
dest="mods",
action="append",
help="Filter by mod acronym (can be passed multiple times or comma separated)",
)
lead_parser.add_argument("--beatmap-id", dest="beatmap_ids", action="append", type=int, help="Filter by beatmap id")
lead_parser.add_argument(
"--beatmapset-id",
dest="beatmapset_ids",
action="append",
type=int,
help="Filter by beatmapset id",
)
lead_parser.add_argument(
"--all",
dest="recalculate_all",
action="store_true",
help="Recalculate all users across all modes (ignores filter requirement)",
)
# rating subcommand
rating_parser = subparsers.add_parser("rating", help="Recalculate beatmap difficulty ratings")
rating_parser.add_argument(
"--mode",
dest="modes",
action="append",
help="Filter by game mode (accepts names like osu, taiko or numeric ids)",
)
rating_parser.add_argument(
"--beatmap-id", dest="beatmap_ids", action="append", type=int, help="Filter by beatmap id"
)
rating_parser.add_argument(
"--beatmapset-id",
dest="beatmapset_ids",
action="append",
type=int,
help="Filter by beatmapset id",
)
rating_parser.add_argument(
"--all",
dest="recalculate_all",
action="store_true",
help="Recalculate all beatmaps",
)
# all subcommand
subparsers.add_parser("all", help="Execute performance, leaderboard, and rating with --all")
args = parser.parse_args(argv)
if not args.recalculate_all and not any(
(
args.user_ids,
args.modes,
args.mods,
args.beatmap_ids,
args.beatmapset_ids,
)
):
if not args.command:
parser.print_help(sys.stderr)
parser.exit(1, "\nNo filters provided; please specify at least one target option.\n")
parser.exit(1, "\nNo command specified.\n")
user_ids = set(args.user_ids or [])
modes: set[GameMode] = set()
for raw in args.modes or []:
for piece in raw.split(","):
piece = piece.strip()
if not piece:
continue
mode = GameMode.parse(piece)
if mode is None:
parser.error(f"Unknown game mode: {piece}")
modes.add(mode)
mods = {mod.strip().upper() for raw in args.mods or [] for mod in raw.split(",") if mod.strip()}
beatmap_ids = set(args.beatmap_ids or [])
beatmapset_ids = set(args.beatmapset_ids or [])
concurrency = max(1, args.concurrency)
return RecalculateConfig(
user_ids=user_ids,
modes=modes,
mods=mods,
beatmap_ids=beatmap_ids,
beatmapset_ids=beatmapset_ids,
global_config = GlobalConfig(
dry_run=args.dry_run,
concurrency=concurrency,
recalculate_all=args.recalculate_all,
concurrency=max(1, args.concurrency),
output_csv=args.output_csv,
)
if args.command == "all":
return args.command, global_config, None
if args.command in ("performance", "leaderboard"):
if not args.recalculate_all and not any(
(
args.user_ids,
args.modes,
args.mods,
args.beatmap_ids,
args.beatmapset_ids,
)
):
parser.error(
f"\n{args.command}: No filters provided; please specify at least one target option or use --all.\n"
)
user_ids = set(args.user_ids or [])
modes: set[GameMode] = set()
for raw in args.modes or []:
for piece in raw.split(","):
piece = piece.strip()
if not piece:
continue
mode = GameMode.parse(piece)
if mode is None:
parser.error(f"Unknown game mode: {piece}")
modes.add(mode)
mods = {mod.strip().upper() for raw in args.mods or [] for mod in raw.split(",") if mod.strip()}
beatmap_ids = set(args.beatmap_ids or [])
beatmapset_ids = set(args.beatmapset_ids or [])
if args.command == "performance":
return (
args.command,
global_config,
PerformanceConfig(
user_ids=user_ids,
modes=modes,
mods=mods,
beatmap_ids=beatmap_ids,
beatmapset_ids=beatmapset_ids,
recalculate_all=args.recalculate_all,
),
)
else: # leaderboard
return (
args.command,
global_config,
LeaderboardConfig(
user_ids=user_ids,
modes=modes,
mods=mods,
beatmap_ids=beatmap_ids,
beatmapset_ids=beatmapset_ids,
recalculate_all=args.recalculate_all,
),
)
elif args.command == "rating":
if not args.recalculate_all and not any(
(
args.modes,
args.beatmap_ids,
args.beatmapset_ids,
)
):
parser.error("\nrating: No filters provided; please specify at least one target option or use --all.\n")
rating_modes: set[GameMode] = set()
for raw in args.modes or []:
for piece in raw.split(","):
piece = piece.strip()
if not piece:
continue
mode = GameMode.parse(piece)
if mode is None:
parser.error(f"Unknown game mode: {piece}")
rating_modes.add(mode)
beatmap_ids = set(args.beatmap_ids or [])
beatmapset_ids = set(args.beatmapset_ids or [])
return (
args.command,
global_config,
RatingConfig(
modes=rating_modes,
beatmap_ids=beatmap_ids,
beatmapset_ids=beatmapset_ids,
recalculate_all=args.recalculate_all,
),
)
return args.command, global_config, None
class CSVWriter:
"""Helper class to write recalculation results to CSV files."""
def __init__(self, csv_path: str | None):
self.csv_path = csv_path
self.file = None
self.writer = None
self.lock = asyncio.Lock()
async def __aenter__(self):
if self.csv_path:
# Create directory if it doesn't exist
Path(self.csv_path).parent.mkdir(parents=True, exist_ok=True)
self.file = open(self.csv_path, "w", newline="", encoding="utf-8") # noqa: ASYNC230, SIM115
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.file:
self.file.close()
self.writer = None
async def write_performance(
self,
user_id: int,
mode: str,
recalculated: int,
failed: int,
old_pp: float,
new_pp: float,
old_acc: float,
new_acc: float,
):
"""Write performance recalculation result."""
if not self.file:
return
async with self.lock:
if not self.writer:
self.writer = csv.writer(self.file)
self.writer.writerow(
[
"type",
"user_id",
"mode",
"recalculated",
"failed",
"old_pp",
"new_pp",
"pp_diff",
"old_acc",
"new_acc",
"acc_diff",
]
)
self.writer.writerow(
[
"performance",
user_id,
mode,
recalculated,
failed,
f"{old_pp:.2f}",
f"{new_pp:.2f}",
f"{new_pp - old_pp:.2f}",
f"{old_acc:.2f}",
f"{new_acc:.2f}",
f"{new_acc - old_acc:.2f}",
]
)
self.file.flush()
async def write_leaderboard(self, user_id: int, mode: str, count: int, changes: dict[str, int]):
"""Write leaderboard recalculation result."""
if not self.file:
return
async with self.lock:
if not self.writer:
self.writer = csv.writer(self.file)
self.writer.writerow(
[
"type",
"user_id",
"mode",
"entries",
"ranked_score_diff",
"max_combo_diff",
"ss_diff",
"ssh_diff",
"s_diff",
"sh_diff",
"a_diff",
]
)
self.writer.writerow(
[
"leaderboard",
user_id,
mode,
count,
changes["ranked_score"],
changes["maximum_combo"],
changes["grade_ss"],
changes["grade_ssh"],
changes["grade_s"],
changes["grade_sh"],
changes["grade_a"],
]
)
self.file.flush()
async def write_rating(self, beatmap_id: int, old_rating: float, new_rating: float):
"""Write beatmap rating recalculation result."""
if not self.file:
return
async with self.lock:
if not self.writer:
self.writer = csv.writer(self.file)
self.writer.writerow(["type", "beatmap_id", "old_rating", "new_rating", "rating_diff"])
self.writer.writerow(
["rating", beatmap_id, f"{old_rating:.2f}", f"{new_rating:.2f}", f"{new_rating - old_rating:.2f}"]
)
self.file.flush()
async def run_in_batches(coros: Sequence[Awaitable[None]], batch_size: int) -> None:
tasks = list(coros)
@@ -168,7 +461,9 @@ def _retry_wait_seconds(exc: HTTPError) -> float | None:
return max(delay, 1.0)
async def determine_targets(config: RecalculateConfig) -> dict[tuple[int, GameMode], set[int] | None]:
async def determine_targets(
config: PerformanceConfig | LeaderboardConfig,
) -> dict[tuple[int, GameMode], set[int] | None]:
targets: dict[tuple[int, GameMode], set[int] | None] = {}
if config.mods or config.beatmap_ids or config.beatmapset_ids:
await _populate_targets_from_scores(config, targets)
@@ -188,7 +483,7 @@ async def determine_targets(config: RecalculateConfig) -> dict[tuple[int, GameMo
async def _populate_targets_from_scores(
config: RecalculateConfig,
config: PerformanceConfig | LeaderboardConfig,
targets: dict[tuple[int, GameMode], set[int] | None],
) -> None:
async with AsyncSession(engine, expire_on_commit=False, autoflush=False) as session:
@@ -218,7 +513,7 @@ async def _populate_targets_from_scores(
async def _populate_targets_from_statistics(
config: RecalculateConfig,
config: PerformanceConfig | LeaderboardConfig,
targets: dict[tuple[int, GameMode], set[int] | None],
user_filter: set[int] | None,
) -> None:
@@ -425,15 +720,17 @@ async def _recalculate_statistics(
statistics.level_current = calculate_score_to_level(statistics.total_score)
async def recalculate_user_mode(
async def recalculate_user_mode_performance(
user_id: int,
gamemode: GameMode,
score_filter: set[int] | None,
config: RecalculateConfig,
global_config: GlobalConfig,
fetcher: Fetcher,
redis: Redis,
semaphore: asyncio.Semaphore,
csv_writer: CSVWriter | None = None,
) -> None:
"""Recalculate performance points and best scores (without TotalScoreBestScore)."""
async with semaphore, AsyncSession(engine, expire_on_commit=False, autoflush=False) as session:
try:
statistics = (
@@ -477,7 +774,6 @@ async def recalculate_user_mode(
recalculated += 1
best_scores = build_best_scores(user_id, gamemode, passed_scores)
total_best_scores = build_total_score_best_scores(passed_scores)
await session.execute(
delete(BestScore).where(
@@ -485,14 +781,7 @@ async def recalculate_user_mode(
col(BestScore.gamemode) == gamemode,
)
)
await session.execute(
delete(TotalScoreBestScore).where(
col(TotalScoreBestScore.user_id) == user_id,
col(TotalScoreBestScore.gamemode) == gamemode,
)
)
session.add_all(best_scores)
session.add_all(total_best_scores)
await session.flush()
await _recalculate_statistics(statistics, session, scores)
@@ -510,7 +799,7 @@ async def recalculate_user_mode(
"pp {old_pp:.2f} -> {new_pp:.2f} | acc {old_acc:.2f} -> {new_acc:.2f}"
)
if config.dry_run:
if global_config.dry_run:
await session.rollback()
logger.info(
message.format(
@@ -538,13 +827,237 @@ async def recalculate_user_mode(
new_acc=new_acc,
)
)
# Write to CSV if enabled
if csv_writer:
await csv_writer.write_performance(
user_id, str(gamemode), recalculated, failed, old_pp, new_pp, old_acc, new_acc
)
except Exception:
if session.in_transaction():
await session.rollback()
logger.exception(f"Failed to process user {user_id} mode {gamemode}")
async def recalculate(config: RecalculateConfig) -> None:
async def recalculate_user_mode_leaderboard(
user_id: int,
gamemode: GameMode,
score_filter: set[int] | None,
global_config: GlobalConfig,
semaphore: asyncio.Semaphore,
csv_writer: CSVWriter | None = None,
) -> None:
"""Recalculate leaderboard (TotalScoreBestScore only)."""
async with semaphore, AsyncSession(engine, expire_on_commit=False, autoflush=False) as session:
try:
# Get statistics
statistics = (
await session.exec(
select(UserStatistics).where(
UserStatistics.user_id == user_id,
UserStatistics.mode == gamemode,
)
)
).first()
if statistics is None:
logger.warning(f"No statistics found for user {user_id} mode {gamemode}")
return
previous_data = {
"ranked_score": statistics.ranked_score,
"maximum_combo": statistics.maximum_combo,
"grade_ss": statistics.grade_ss,
"grade_ssh": statistics.grade_ssh,
"grade_s": statistics.grade_s,
"grade_sh": statistics.grade_sh,
"grade_a": statistics.grade_a,
}
score_stmt = (
select(Score)
.where(Score.user_id == user_id, Score.gamemode == gamemode)
.options(joinedload(Score.beatmap))
)
result = await session.exec(score_stmt)
scores: list[Score] = list(result)
passed_scores = [score for score in scores if score.passed]
target_set = score_filter if score_filter is not None else {score.id for score in passed_scores}
if score_filter is not None and not target_set:
logger.info(f"User {user_id} mode {gamemode}: no scores matched filters")
return
total_best_scores = build_total_score_best_scores(passed_scores)
await session.execute(
delete(TotalScoreBestScore).where(
col(TotalScoreBestScore.user_id) == user_id,
col(TotalScoreBestScore.gamemode) == gamemode,
)
)
session.add_all(total_best_scores)
await session.flush()
# Recalculate statistics using the helper function
await _recalculate_statistics(statistics, session, scores)
await session.flush()
changes = {
"ranked_score": statistics.ranked_score - previous_data["ranked_score"],
"maximum_combo": statistics.maximum_combo - previous_data["maximum_combo"],
"grade_ss": statistics.grade_ss - previous_data["grade_ss"],
"grade_ssh": statistics.grade_ssh - previous_data["grade_ssh"],
"grade_s": statistics.grade_s - previous_data["grade_s"],
"grade_sh": statistics.grade_sh - previous_data["grade_sh"],
"grade_a": statistics.grade_a - previous_data["grade_a"],
}
message = (
"Dry-run | user {user_id} mode {mode} | {count} leaderboard entries | "
"ranked_score: {ranked_score:+d} | max_combo: {max_combo:+d} | "
"SS: {ss:+d} | SSH: {ssh:+d} | S: {s:+d} | SH: {sh:+d} | A: {a:+d}"
)
success_message = (
"Recalculated leaderboard | user {user_id} mode {mode} | {count} entries | "
"ranked_score: {ranked_score:+d} | max_combo: {max_combo:+d} | "
"SS: {ss:+d} | SSH: {ssh:+d} | S: {s:+d} | SH: {sh:+d} | A: {a:+d}"
)
if global_config.dry_run:
await session.rollback()
logger.info(
message.format(
user_id=user_id,
mode=gamemode,
count=len(total_best_scores),
ranked_score=changes["ranked_score"],
max_combo=changes["maximum_combo"],
ss=changes["grade_ss"],
ssh=changes["grade_ssh"],
s=changes["grade_s"],
sh=changes["grade_sh"],
a=changes["grade_a"],
)
)
else:
await session.commit()
logger.success(
success_message.format(
user_id=user_id,
mode=gamemode,
count=len(total_best_scores),
ranked_score=changes["ranked_score"],
max_combo=changes["maximum_combo"],
ss=changes["grade_ss"],
ssh=changes["grade_ssh"],
s=changes["grade_s"],
sh=changes["grade_sh"],
a=changes["grade_a"],
)
)
# Write to CSV if enabled
if csv_writer:
await csv_writer.write_leaderboard(user_id, str(gamemode), len(total_best_scores), changes)
except Exception:
if session.in_transaction():
await session.rollback()
logger.exception(f"Failed to process leaderboard for user {user_id} mode {gamemode}")
async def recalculate_beatmap_rating(
beatmap_id: int,
global_config: GlobalConfig,
fetcher: Fetcher,
redis: Redis,
semaphore: asyncio.Semaphore,
csv_writer: CSVWriter | None = None,
) -> None:
"""Recalculate difficulty rating for a beatmap."""
async with semaphore, AsyncSession(engine, expire_on_commit=False, autoflush=False) as session:
try:
beatmap = await session.get(Beatmap, beatmap_id)
if beatmap is None:
logger.warning(f"Beatmap {beatmap_id} not found")
return
if beatmap.deleted_at is not None:
logger.warning(f"Beatmap {beatmap_id} is deleted; skipping")
return
old_rating = beatmap.difficulty_rating
attempts = 10
while attempts > 0:
try:
ruleset = GameMode(beatmap.mode) if isinstance(beatmap.mode, int) else beatmap.mode
attributes = await calculate_beatmap_attributes(beatmap_id, ruleset, [], redis, fetcher)
beatmap.difficulty_rating = attributes.star_rating
break
except CalculateError as exc:
attempts -= 1
if attempts > 0:
logger.warning(
f"CalculateError for beatmap {beatmap_id} (attempts remaining: {attempts}); retrying..."
)
await asyncio.sleep(1)
else:
logger.error(f"Failed to calculate rating for beatmap {beatmap_id} after 10 attempts: {exc}")
return
except HTTPError as exc:
wait = _retry_wait_seconds(exc)
if wait is not None:
logger.warning(
f"Rate limited while calculating rating for beatmap {beatmap_id}; "
f"waiting {wait:.1f}s before retry"
)
await asyncio.sleep(wait)
continue
attempts -= 1
if attempts > 0:
await asyncio.sleep(2)
else:
logger.exception(f"Failed to calculate rating for beatmap {beatmap_id} after multiple attempts")
return
except Exception:
logger.exception(f"Unexpected error calculating rating for beatmap {beatmap_id}")
return
new_rating = beatmap.difficulty_rating
message = "Dry-run | beatmap {beatmap_id} | rating {old_rating:.2f} -> {new_rating:.2f}"
success_message = "Recalculated beatmap {beatmap_id} | rating {old_rating:.2f} -> {new_rating:.2f}"
if global_config.dry_run:
await session.rollback()
logger.info(
message.format(
beatmap_id=beatmap_id,
old_rating=old_rating,
new_rating=new_rating,
)
)
else:
await session.commit()
logger.success(
success_message.format(
beatmap_id=beatmap_id,
old_rating=old_rating,
new_rating=new_rating,
)
)
# Write to CSV if enabled
if csv_writer:
await csv_writer.write_rating(beatmap_id, old_rating, new_rating)
except Exception:
if session.in_transaction():
await session.rollback()
logger.exception(f"Failed to process beatmap {beatmap_id}")
async def recalculate_performance(
config: PerformanceConfig,
global_config: GlobalConfig,
) -> None:
"""Execute performance recalculation."""
fetcher = await get_fetcher()
redis = get_redis()
@@ -555,27 +1068,184 @@ async def recalculate(config: RecalculateConfig) -> None:
targets = await determine_targets(config)
if not targets:
logger.info("No targets matched the provided filters; nothing to recalculate")
await engine.dispose()
return
scope = "full" if config.recalculate_all else "filtered"
logger.info(
"Recalculating {} user/mode pairs ({}) | dry-run={} | concurrency={}",
"Recalculating performance for {} user/mode pairs ({}) | dry-run={} | concurrency={}",
len(targets),
scope,
config.dry_run,
config.concurrency,
global_config.dry_run,
global_config.concurrency,
)
semaphore = asyncio.Semaphore(config.concurrency)
coroutines = [
recalculate_user_mode(user_id, mode, score_ids, config, fetcher, redis, semaphore)
for (user_id, mode), score_ids in targets.items()
]
await run_in_batches(coroutines, config.concurrency)
async with CSVWriter(global_config.output_csv) as csv_writer:
semaphore = asyncio.Semaphore(global_config.concurrency)
coroutines = [
recalculate_user_mode_performance(
user_id, mode, score_ids, global_config, fetcher, redis, semaphore, csv_writer
)
for (user_id, mode), score_ids in targets.items()
]
await run_in_batches(coroutines, global_config.concurrency)
async def recalculate_leaderboard(
config: LeaderboardConfig,
global_config: GlobalConfig,
) -> None:
"""Execute leaderboard recalculation."""
targets = await determine_targets(config)
if not targets:
logger.info("No targets matched the provided filters; nothing to recalculate")
return
scope = "full" if config.recalculate_all else "filtered"
logger.info(
"Recalculating leaderboard for {} user/mode pairs ({}) | dry-run={} | concurrency={}",
len(targets),
scope,
global_config.dry_run,
global_config.concurrency,
)
async with CSVWriter(global_config.output_csv) as csv_writer:
semaphore = asyncio.Semaphore(global_config.concurrency)
coroutines = [
recalculate_user_mode_leaderboard(user_id, mode, score_ids, global_config, semaphore, csv_writer)
for (user_id, mode), score_ids in targets.items()
]
await run_in_batches(coroutines, global_config.concurrency)
async def recalculate_rating(
config: RatingConfig,
global_config: GlobalConfig,
) -> None:
"""Execute beatmap rating recalculation."""
fetcher = await get_fetcher()
redis = get_redis()
await init_calculator()
# Determine beatmaps to recalculate
async with AsyncSession(engine, expire_on_commit=False, autoflush=False) as session:
stmt = select(Beatmap.id)
if not config.recalculate_all:
if config.beatmap_ids:
stmt = stmt.where(col(Beatmap.id).in_(list(config.beatmap_ids)))
if config.beatmapset_ids:
stmt = stmt.where(col(Beatmap.beatmapset_id).in_(list(config.beatmapset_ids)))
if config.modes:
stmt = stmt.where(col(Beatmap.mode).in_(list(config.modes)))
result = await session.exec(stmt)
beatmap_ids = list(result)
if not beatmap_ids:
logger.info("No beatmaps matched the provided filters; nothing to recalculate")
return
scope = "full" if config.recalculate_all else "filtered"
logger.info(
"Recalculating rating for {} beatmaps ({}) | dry-run={} | concurrency={}",
len(beatmap_ids),
scope,
global_config.dry_run,
global_config.concurrency,
)
async with CSVWriter(global_config.output_csv) as csv_writer:
semaphore = asyncio.Semaphore(global_config.concurrency)
coroutines = [
recalculate_beatmap_rating(beatmap_id, global_config, fetcher, redis, semaphore, csv_writer)
for beatmap_id in beatmap_ids
]
await run_in_batches(coroutines, global_config.concurrency)
def _get_csv_path_for_subcommand(base_path: str | None, subcommand: str) -> str | None:
"""Generate a CSV path with subcommand name inserted before extension."""
if base_path is None:
return None
path = Path(base_path)
# Insert subcommand name before the extension
# e.g., "results.csv" -> "results.performance.csv"
new_name = f"{path.stem}.{subcommand}{path.suffix}"
if path.parent == Path("."):
return new_name
return str(path.parent / new_name)
async def main() -> None:
"""Main entry point."""
command, global_config, sub_config = parse_cli_args(sys.argv[1:])
if command == "all":
logger.info("Executing all recalculations (performance, leaderboard, rating) with --all")
# Rating
rating_config = RatingConfig(
modes=set(),
beatmap_ids=set(),
beatmapset_ids=set(),
recalculate_all=True,
)
rating_csv_path = _get_csv_path_for_subcommand(global_config.output_csv, "rating")
rating_global_config = GlobalConfig(
dry_run=global_config.dry_run,
concurrency=global_config.concurrency,
output_csv=rating_csv_path,
)
await recalculate_rating(rating_config, rating_global_config)
# Performance
perf_config = PerformanceConfig(
user_ids=set(),
modes=set(),
mods=set(),
beatmap_ids=set(),
beatmapset_ids=set(),
recalculate_all=True,
)
perf_csv_path = _get_csv_path_for_subcommand(global_config.output_csv, "performance")
perf_global_config = GlobalConfig(
dry_run=global_config.dry_run,
concurrency=global_config.concurrency,
output_csv=perf_csv_path,
)
await recalculate_performance(perf_config, perf_global_config)
# Leaderboard
lead_config = LeaderboardConfig(
user_ids=set(),
modes=set(),
mods=set(),
beatmap_ids=set(),
beatmapset_ids=set(),
recalculate_all=True,
)
lead_csv_path = _get_csv_path_for_subcommand(global_config.output_csv, "leaderboard")
lead_global_config = GlobalConfig(
dry_run=global_config.dry_run,
concurrency=global_config.concurrency,
output_csv=lead_csv_path,
)
await recalculate_leaderboard(lead_config, lead_global_config)
elif command == "performance":
assert isinstance(sub_config, PerformanceConfig)
await recalculate_performance(sub_config, global_config)
elif command == "leaderboard":
assert isinstance(sub_config, LeaderboardConfig)
await recalculate_leaderboard(sub_config, global_config)
elif command == "rating":
assert isinstance(sub_config, RatingConfig)
await recalculate_rating(sub_config, global_config)
await engine.dispose()
if __name__ == "__main__":
config = parse_cli_args(sys.argv[1:])
asyncio.run(recalculate(config))
asyncio.run(main())

15
uv.lock generated
View File

@@ -745,7 +745,6 @@ dependencies = [
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "redis" },
{ name = "rosu-pp-py" },
{ name = "sentry-sdk", extra = ["fastapi", "httpx", "loguru", "sqlalchemy"] },
{ name = "sqlalchemy" },
{ name = "sqlmodel" },
@@ -753,6 +752,11 @@ dependencies = [
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
rosu = [
{ name = "rosu-pp-py" },
]
[package.dev-dependencies]
dev = [
{ name = "datamodel-code-generator" },
@@ -790,13 +794,14 @@ requires-dist = [
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
{ name = "python-multipart", specifier = ">=0.0.6" },
{ name = "redis", specifier = ">=5.0.1" },
{ name = "rosu-pp-py", specifier = ">=3.1.0" },
{ name = "rosu-pp-py", marker = "extra == 'rosu'", specifier = ">=3.1.0" },
{ name = "sentry-sdk", extras = ["fastapi", "httpx", "loguru", "sqlalchemy"], specifier = ">=2.34.1" },
{ name = "sqlalchemy", specifier = ">=2.0.23" },
{ name = "sqlmodel", specifier = ">=0.0.24" },
{ name = "tinycss2", specifier = ">=1.4.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
]
provides-extras = ["rosu"]
[package.metadata.requires-dev]
dev = [
@@ -830,6 +835,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
{ url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" },
{ url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" },
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
@@ -839,6 +846,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
@@ -846,6 +855,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]