feat(pp-calculator): support other pp calculators (#57)
New configurations:
- CALCULATOR="rosu": specific pp calculator
- CALCULATOR_CONFIG='{}': argument passed through into calculator
This commit is contained in:
17
.github/scripts/generate_config_doc.py
vendored
17
.github/scripts/generate_config_doc.py
vendored
@@ -1,11 +1,12 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
from inspect import isclass
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
from types import NoneType, UnionType
|
from types import NoneType, UnionType
|
||||||
from typing import Any, Union, get_origin
|
from typing import Any, Literal, Union, get_origin
|
||||||
|
|
||||||
from pydantic import AliasChoices, BaseModel, HttpUrl
|
from pydantic import AliasChoices, BaseModel, HttpUrl
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
@@ -64,6 +65,7 @@ BASE_TYPE_MAPPING = {
|
|||||||
dict: "object",
|
dict: "object",
|
||||||
NoneType: "null",
|
NoneType: "null",
|
||||||
HttpUrl: "string (url)",
|
HttpUrl: "string (url)",
|
||||||
|
Any: "any",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -81,9 +83,16 @@ def mapping_type(typ: type) -> str:
|
|||||||
if len(args) == 1:
|
if len(args) == 1:
|
||||||
return f"array[{mapping_type(args[0])}]"
|
return f"array[{mapping_type(args[0])}]"
|
||||||
return "array"
|
return "array"
|
||||||
if issubclass(typ, Enum):
|
elif get_origin(typ) is dict:
|
||||||
|
args = typ.__args__
|
||||||
|
if len(args) == 2:
|
||||||
|
return f"object[{mapping_type(args[0])}, {mapping_type(args[1])}]"
|
||||||
|
return "object"
|
||||||
|
elif get_origin(typ) is Literal:
|
||||||
|
return f"enum({', '.join([str(n) for n in typ.__args__])})"
|
||||||
|
elif isclass(typ) and issubclass(typ, Enum):
|
||||||
return f"enum({', '.join([e.value for e in typ])})"
|
return f"enum({', '.join([e.value for e in typ])})"
|
||||||
elif issubclass(typ, BaseSettings):
|
elif isclass(typ) and issubclass(typ, BaseSettings):
|
||||||
return typ.__name__
|
return typ.__name__
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
@@ -126,7 +135,7 @@ doc.extend(
|
|||||||
[
|
[
|
||||||
module.SPECTATOR_DOC,
|
module.SPECTATOR_DOC,
|
||||||
"",
|
"",
|
||||||
f"> 上次生成:{datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%d %H:%M:%S %Z')}"
|
f"> 上次生成:{datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%d %H:%M:%S %Z')} "
|
||||||
f"于提交 {f'[`{commit}`](https://github.com/GooGuTeam/g0v0-server/commit/{commit})' if commit != 'unknown' else 'unknown'}", # noqa: E501
|
f"于提交 {f'[`{commit}`](https://github.com/GooGuTeam/g0v0-server/commit/{commit})' if commit != 'unknown' else 'unknown'}", # noqa: E501
|
||||||
"",
|
"",
|
||||||
"> **注意: 在生产环境中,请务必更改默认的密钥和密码!**",
|
"> **注意: 在生产环境中,请务必更改默认的密钥和密码!**",
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from copy import deepcopy
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
import importlib
|
||||||
import math
|
import math
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.calculators.performance import PerformanceCalculator
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.log import log
|
from app.log import log
|
||||||
from app.models.beatmap import BeatmapAttributes
|
|
||||||
from app.models.mods import APIMod, parse_enum_to_str
|
|
||||||
from app.models.score import GameMode
|
from app.models.score import GameMode
|
||||||
|
|
||||||
from osupyparser import HitObject, OsuFile
|
from osupyparser import HitObject, OsuFile
|
||||||
@@ -16,23 +15,32 @@ from redis.asyncio import Redis
|
|||||||
from sqlmodel import col, exists, select
|
from sqlmodel import col, exists, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
logger = log("Calculator")
|
|
||||||
|
|
||||||
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:
|
if TYPE_CHECKING:
|
||||||
from app.database.score import Score
|
from app.database.score import Score
|
||||||
from app.fetcher import Fetcher
|
from app.fetcher import Fetcher
|
||||||
|
|
||||||
|
|
||||||
|
logger = log("Calculator")
|
||||||
|
|
||||||
|
CALCULATOR: PerformanceCalculator | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_calculator():
|
||||||
|
global CALCULATOR
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(f"app.calculators.performance.{settings.calculator}")
|
||||||
|
CALCULATOR = module.PerformanceCalculator(**settings.calculator_config)
|
||||||
|
except (ImportError, AttributeError) as e:
|
||||||
|
raise ImportError(f"Failed to import performance calculator for {settings.calculator}") from e
|
||||||
|
return CALCULATOR
|
||||||
|
|
||||||
|
|
||||||
|
def get_calculator() -> PerformanceCalculator:
|
||||||
|
if CALCULATOR is None:
|
||||||
|
raise RuntimeError("Performance calculator is not initialized")
|
||||||
|
return CALCULATOR
|
||||||
|
|
||||||
|
|
||||||
def clamp[T: int | float](n: T, min_value: T, max_value: T) -> T:
|
def clamp[T: int | float](n: T, min_value: T, max_value: T) -> T:
|
||||||
if n < min_value:
|
if n < min_value:
|
||||||
return min_value
|
return min_value
|
||||||
@@ -42,29 +50,6 @@ def clamp[T: int | float](n: T, min_value: T, max_value: T) -> T:
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
def calculate_beatmap_attribute(
|
|
||||||
beatmap: str,
|
|
||||||
gamemode: GameMode | None = None,
|
|
||||||
mods: int | list[APIMod] | list[str] = 0,
|
|
||||||
) -> BeatmapAttributes:
|
|
||||||
map = rosu.Beatmap(content=beatmap)
|
|
||||||
if gamemode is not None:
|
|
||||||
map.convert(gamemode.to_rosu(), mods) # pyright: ignore[reportArgumentType]
|
|
||||||
diff = rosu.Difficulty(mods=mods).calculate(map)
|
|
||||||
return BeatmapAttributes(
|
|
||||||
star_rating=diff.stars,
|
|
||||||
max_combo=diff.max_combo,
|
|
||||||
aim_difficulty=diff.aim,
|
|
||||||
aim_difficult_slider_count=diff.aim_difficult_slider_count,
|
|
||||||
speed_difficulty=diff.speed,
|
|
||||||
speed_note_count=diff.speed_note_count,
|
|
||||||
slider_factor=diff.slider_factor,
|
|
||||||
aim_difficult_strain_count=diff.aim_difficult_strain_count,
|
|
||||||
speed_difficult_strain_count=diff.speed_difficult_strain_count,
|
|
||||||
mono_stamina_factor=diff.stamina,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def calculate_pp(score: "Score", beatmap: str, session: AsyncSession) -> float:
|
async def calculate_pp(score: "Score", beatmap: str, session: AsyncSession) -> float:
|
||||||
from app.database.beatmap import BannedBeatmaps
|
from app.database.beatmap import BannedBeatmaps
|
||||||
|
|
||||||
@@ -83,41 +68,13 @@ async def calculate_pp(score: "Score", beatmap: str, session: AsyncSession) -> f
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Error checking if beatmap {score.beatmap_id} is suspicious")
|
logger.exception(f"Error checking if beatmap {score.beatmap_id} is suspicious")
|
||||||
|
|
||||||
# 使用线程池执行计算密集型操作以避免阻塞事件循环
|
attrs = await get_calculator().calculate_performance(beatmap, score)
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
|
|
||||||
def _calculate_pp_sync():
|
|
||||||
map = rosu.Beatmap(content=beatmap)
|
|
||||||
mods = deepcopy(score.mods.copy())
|
|
||||||
parse_enum_to_str(int(score.gamemode), mods)
|
|
||||||
map.convert(score.gamemode.to_rosu(), mods) # pyright: ignore[reportArgumentType]
|
|
||||||
perf = rosu.Performance(
|
|
||||||
mods=mods,
|
|
||||||
lazer=True,
|
|
||||||
accuracy=clamp(score.accuracy * 100, 0, 100),
|
|
||||||
combo=score.max_combo,
|
|
||||||
large_tick_hits=score.nlarge_tick_hit or 0,
|
|
||||||
slider_end_hits=score.nslider_tail_hit or 0,
|
|
||||||
small_tick_hits=score.nsmall_tick_hit or 0,
|
|
||||||
n_geki=score.ngeki,
|
|
||||||
n_katu=score.nkatu,
|
|
||||||
n300=score.n300,
|
|
||||||
n100=score.n100,
|
|
||||||
n50=score.n50,
|
|
||||||
misses=score.nmiss,
|
|
||||||
)
|
|
||||||
return perf.calculate(map)
|
|
||||||
|
|
||||||
# 在线程池中执行计算
|
|
||||||
attrs = await loop.run_in_executor(None, _calculate_pp_sync)
|
|
||||||
pp = attrs.pp
|
pp = attrs.pp
|
||||||
|
|
||||||
# mrekk bp1: 2048pp; ppy-sb top1 rxbp1: 2198pp
|
if settings.suspicious_score_check and (pp > 3000):
|
||||||
if settings.suspicious_score_check and ((attrs.difficulty.stars > 25 and score.accuracy < 0.8) or pp > 3000):
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"User {score.user_id} played {score.beatmap_id} "
|
f"User {score.user_id} played {score.beatmap_id} "
|
||||||
f"(star={attrs.difficulty.stars}) with {pp=} "
|
f"with {pp=} "
|
||||||
f"acc={score.accuracy}. The score is suspicious and return 0pp"
|
f"acc={score.accuracy}. The score is suspicious and return 0pp"
|
||||||
f"({score.id=})"
|
f"({score.id=})"
|
||||||
)
|
)
|
||||||
@@ -170,74 +127,6 @@ async def pre_fetch_and_calculate_pp(
|
|||||||
return await calculate_pp(score, beatmap_raw, session), True
|
return await calculate_pp(score, beatmap_raw, session), True
|
||||||
|
|
||||||
|
|
||||||
async def batch_calculate_pp(
|
|
||||||
scores_data: list[tuple["Score", int]], session: AsyncSession, redis, fetcher
|
|
||||||
) -> list[float]:
|
|
||||||
"""
|
|
||||||
批量计算PP:适用于重新计算或批量处理场景
|
|
||||||
Args:
|
|
||||||
scores_data: [(score, beatmap_id), ...] 的列表
|
|
||||||
Returns:
|
|
||||||
对应的PP值列表
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from app.database.beatmap import BannedBeatmaps
|
|
||||||
|
|
||||||
if not scores_data:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 提取所有唯一的beatmap_id
|
|
||||||
unique_beatmap_ids = list({beatmap_id for _, beatmap_id in scores_data})
|
|
||||||
|
|
||||||
# 批量检查被封禁的beatmap
|
|
||||||
banned_beatmaps = set()
|
|
||||||
if settings.suspicious_score_check:
|
|
||||||
banned_results = await session.exec(
|
|
||||||
select(BannedBeatmaps.beatmap_id).where(col(BannedBeatmaps.beatmap_id).in_(unique_beatmap_ids))
|
|
||||||
)
|
|
||||||
banned_beatmaps = set(banned_results.all())
|
|
||||||
|
|
||||||
# 并发获取所有需要的beatmap原始文件
|
|
||||||
async def fetch_beatmap_safe(beatmap_id: int) -> tuple[int, str | None]:
|
|
||||||
if beatmap_id in banned_beatmaps:
|
|
||||||
return beatmap_id, None
|
|
||||||
try:
|
|
||||||
content = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id)
|
|
||||||
return beatmap_id, content
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to fetch beatmap {beatmap_id}: {e}")
|
|
||||||
return beatmap_id, None
|
|
||||||
|
|
||||||
# 并发获取所有beatmap文件
|
|
||||||
fetch_tasks = [fetch_beatmap_safe(bid) for bid in unique_beatmap_ids]
|
|
||||||
fetch_results = await asyncio.gather(*fetch_tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
# 构建beatmap_id -> content的映射
|
|
||||||
beatmap_contents = {}
|
|
||||||
for result in fetch_results:
|
|
||||||
if isinstance(result, tuple):
|
|
||||||
beatmap_id, content = result
|
|
||||||
beatmap_contents[beatmap_id] = content
|
|
||||||
|
|
||||||
# 为每个score计算PP
|
|
||||||
pp_results = []
|
|
||||||
for score, beatmap_id in scores_data:
|
|
||||||
beatmap_content = beatmap_contents.get(beatmap_id)
|
|
||||||
if beatmap_content is None:
|
|
||||||
pp_results.append(0.0)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
pp = await calculate_pp(score, beatmap_content, session)
|
|
||||||
pp_results.append(pp)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to calculate PP for score {score.id}: {e}")
|
|
||||||
pp_results.append(0.0)
|
|
||||||
|
|
||||||
return pp_results
|
|
||||||
|
|
||||||
|
|
||||||
# https://osu.ppy.sh/wiki/Gameplay/Score/Total_score
|
# https://osu.ppy.sh/wiki/Gameplay/Score/Total_score
|
||||||
def calculate_level_to_score(n: int) -> float:
|
def calculate_level_to_score(n: int) -> float:
|
||||||
if n <= 100:
|
if n <= 100:
|
||||||
|
|||||||
3
app/calculators/performance/__init__.py
Normal file
3
app/calculators/performance/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ._base import CalculateError, ConvertError, DifficultyError, PerformanceCalculator, PerformanceError
|
||||||
|
|
||||||
|
__all__ = ["CalculateError", "ConvertError", "DifficultyError", "PerformanceCalculator", "PerformanceError"]
|
||||||
37
app/calculators/performance/_base.py
Normal file
37
app/calculators/performance/_base.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import abc
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.models.mods import APIMod
|
||||||
|
from app.models.performance import BeatmapAttributes, PerformanceAttributes
|
||||||
|
from app.models.score import GameMode
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.database.score import Score
|
||||||
|
|
||||||
|
|
||||||
|
class CalculateError(Exception):
|
||||||
|
"""An error occurred during performance calculation."""
|
||||||
|
|
||||||
|
|
||||||
|
class DifficultyError(CalculateError):
|
||||||
|
"""The difficulty could not be calculated."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConvertError(DifficultyError):
|
||||||
|
"""A beatmap cannot be converted to the specified game mode."""
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceError(CalculateError):
|
||||||
|
"""The performance could not be calculated."""
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceCalculator(abc.ABC):
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def calculate_performance(self, beatmap_raw: str, score: "Score") -> PerformanceAttributes:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def calculate_difficulty(
|
||||||
|
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
|
||||||
|
) -> BeatmapAttributes:
|
||||||
|
raise NotImplementedError
|
||||||
88
app/calculators/performance/performance_server.py
Normal file
88
app/calculators/performance/performance_server.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.models.mods import APIMod
|
||||||
|
from app.models.performance import (
|
||||||
|
DIFFICULTY_CLASS,
|
||||||
|
PERFORMANCE_CLASS,
|
||||||
|
BeatmapAttributes,
|
||||||
|
PerformanceAttributes,
|
||||||
|
)
|
||||||
|
from app.models.score import GameMode
|
||||||
|
|
||||||
|
from ._base import (
|
||||||
|
CalculateError,
|
||||||
|
DifficultyError,
|
||||||
|
PerformanceCalculator as BasePerformanceCalculator,
|
||||||
|
PerformanceError,
|
||||||
|
)
|
||||||
|
|
||||||
|
from httpx import AsyncClient, HTTPError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.database.score import Score
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceCalculator(BasePerformanceCalculator):
|
||||||
|
def __init__(self, server_url: str = "http://localhost:5225") -> None:
|
||||||
|
self.server_url = server_url
|
||||||
|
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.server_url}/performance",
|
||||||
|
json={
|
||||||
|
"beatmap_id": score.beatmap_id,
|
||||||
|
"beatmap_file": beatmap_raw,
|
||||||
|
"checksum": score.map_md5,
|
||||||
|
"accuracy": score.accuracy,
|
||||||
|
"combo": score.max_combo,
|
||||||
|
"mods": score.mods,
|
||||||
|
"statistics": {
|
||||||
|
"great": score.n300,
|
||||||
|
"ok": score.n100,
|
||||||
|
"meh": score.n50,
|
||||||
|
"miss": score.nmiss,
|
||||||
|
"perfect": score.ngeki,
|
||||||
|
"good": score.nkatu,
|
||||||
|
"large_tick_hit": score.nlarge_tick_hit or 0,
|
||||||
|
"large_tick_miss": score.nlarge_tick_miss or 0,
|
||||||
|
"small_tick_hit": score.nsmall_tick_hit or 0,
|
||||||
|
"slider_tail_hit": score.nslider_tail_hit or 0,
|
||||||
|
},
|
||||||
|
"ruleset": score.gamemode.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise PerformanceError(f"Failed to calculate performance: {resp.text}")
|
||||||
|
data = resp.json()
|
||||||
|
return PERFORMANCE_CLASS.get(score.gamemode, PerformanceAttributes).model_validate(data)
|
||||||
|
except HTTPError as e:
|
||||||
|
raise PerformanceError(f"Failed to calculate performance: {e}") from e
|
||||||
|
except Exception as e:
|
||||||
|
raise CalculateError(f"Unknown error: {e}") from e
|
||||||
|
|
||||||
|
async def calculate_difficulty(
|
||||||
|
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
|
||||||
|
) -> BeatmapAttributes:
|
||||||
|
# https://github.com/GooGuTeam/osu-performance-server#post-difficulty
|
||||||
|
async with AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.server_url}/difficulty",
|
||||||
|
json={
|
||||||
|
"beatmap_file": beatmap_raw,
|
||||||
|
"mods": mods or [],
|
||||||
|
"ruleset": int(gamemode) if gamemode else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise DifficultyError(f"Failed to calculate difficulty: {resp.text}")
|
||||||
|
data = resp.json()
|
||||||
|
ruleset_id = data.pop("ruleset", "osu")
|
||||||
|
return DIFFICULTY_CLASS.get(GameMode(ruleset_id), BeatmapAttributes).model_validate(data)
|
||||||
|
except HTTPError as e:
|
||||||
|
raise DifficultyError(f"Failed to calculate difficulty: {e}") from e
|
||||||
|
except Exception as e:
|
||||||
|
raise DifficultyError(f"Unknown error: {e}") from e
|
||||||
169
app/calculators/performance/rosu.py
Normal file
169
app/calculators/performance/rosu.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
from asyncio import get_event_loop
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.calculator import clamp
|
||||||
|
from app.models.mods import APIMod, parse_enum_to_str
|
||||||
|
from app.models.performance import (
|
||||||
|
DIFFICULTY_CLASS,
|
||||||
|
PERFORMANCE_CLASS,
|
||||||
|
BeatmapAttributes,
|
||||||
|
ManiaPerformanceAttributes,
|
||||||
|
OsuBeatmapAttributes,
|
||||||
|
OsuPerformanceAttributes,
|
||||||
|
PerformanceAttributes,
|
||||||
|
TaikoBeatmapAttributes,
|
||||||
|
TaikoPerformanceAttributes,
|
||||||
|
)
|
||||||
|
from app.models.score import GameMode
|
||||||
|
|
||||||
|
from ._base import (
|
||||||
|
CalculateError,
|
||||||
|
ConvertError,
|
||||||
|
DifficultyError,
|
||||||
|
PerformanceCalculator as BasePerformanceCalculator,
|
||||||
|
PerformanceError,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.database.score import Score
|
||||||
|
|
||||||
|
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"
|
||||||
|
" gu: uv add git+https://github.com/GooGuTeam/gu-pp-py.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceCalculator(BasePerformanceCalculator):
|
||||||
|
@classmethod
|
||||||
|
def _to_rosu_mode(cls, mode: GameMode) -> 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,
|
||||||
|
GameMode.TAIKORX: rosu.GameMode.Taiko,
|
||||||
|
GameMode.FRUITSRX: rosu.GameMode.Catch,
|
||||||
|
}[mode]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_rosu_mode(cls, mode: rosu.GameMode) -> GameMode:
|
||||||
|
return {
|
||||||
|
rosu.GameMode.Osu: GameMode.OSU,
|
||||||
|
rosu.GameMode.Taiko: GameMode.TAIKO,
|
||||||
|
rosu.GameMode.Catch: GameMode.FRUITS,
|
||||||
|
rosu.GameMode.Mania: GameMode.MANIA,
|
||||||
|
}[mode]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _perf_attr_to_model(cls, attr: rosu.PerformanceAttributes, gamemode: GameMode) -> PerformanceAttributes:
|
||||||
|
attr_class = PERFORMANCE_CLASS.get(gamemode, PerformanceAttributes)
|
||||||
|
|
||||||
|
if attr_class is OsuPerformanceAttributes:
|
||||||
|
return OsuPerformanceAttributes(
|
||||||
|
pp=attr.pp,
|
||||||
|
aim=attr.pp_aim or 0,
|
||||||
|
speed=attr.pp_speed or 0,
|
||||||
|
accuracy=attr.pp_accuracy or 0,
|
||||||
|
flashlight=attr.pp_flashlight or 0,
|
||||||
|
effective_miss_count=attr.effective_miss_count or 0,
|
||||||
|
speed_deviation=attr.speed_deviation,
|
||||||
|
)
|
||||||
|
elif attr_class is TaikoPerformanceAttributes:
|
||||||
|
return TaikoPerformanceAttributes(
|
||||||
|
pp=attr.pp,
|
||||||
|
difficulty=attr.pp_difficulty or 0,
|
||||||
|
accuracy=attr.pp_accuracy or 0,
|
||||||
|
estimated_unstable_rate=attr.estimated_unstable_rate,
|
||||||
|
)
|
||||||
|
elif attr_class is ManiaPerformanceAttributes:
|
||||||
|
return ManiaPerformanceAttributes(
|
||||||
|
pp=attr.pp,
|
||||||
|
difficulty=attr.pp_difficulty or 0,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return PerformanceAttributes(pp=attr.pp)
|
||||||
|
|
||||||
|
async def calculate_performance(self, beatmap_raw: str, score: "Score") -> PerformanceAttributes:
|
||||||
|
try:
|
||||||
|
map = rosu.Beatmap(content=beatmap_raw)
|
||||||
|
mods = deepcopy(score.mods.copy())
|
||||||
|
parse_enum_to_str(int(score.gamemode), mods)
|
||||||
|
map.convert(self._to_rosu_mode(score.gamemode), mods) # pyright: ignore[reportArgumentType]
|
||||||
|
perf = rosu.Performance(
|
||||||
|
mods=mods,
|
||||||
|
lazer=True,
|
||||||
|
accuracy=clamp(score.accuracy * 100, 0, 100),
|
||||||
|
combo=score.max_combo,
|
||||||
|
large_tick_hits=score.nlarge_tick_hit or 0,
|
||||||
|
slider_end_hits=score.nslider_tail_hit or 0,
|
||||||
|
small_tick_hits=score.nsmall_tick_hit or 0,
|
||||||
|
n_geki=score.ngeki,
|
||||||
|
n_katu=score.nkatu,
|
||||||
|
n300=score.n300,
|
||||||
|
n100=score.n100,
|
||||||
|
n50=score.n50,
|
||||||
|
misses=score.nmiss,
|
||||||
|
)
|
||||||
|
attr = await get_event_loop().run_in_executor(None, perf.calculate, map)
|
||||||
|
return self._perf_attr_to_model(attr, score.gamemode.to_base_ruleset())
|
||||||
|
except rosu.ParseError as e: # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
raise PerformanceError(f"Beatmap parse error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise CalculateError(f"Unknown error: {e}") from e
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _diff_attr_to_model(cls, diff: rosu.DifficultyAttributes, gamemode: GameMode) -> BeatmapAttributes:
|
||||||
|
attr_class = DIFFICULTY_CLASS.get(gamemode, BeatmapAttributes)
|
||||||
|
|
||||||
|
if attr_class is OsuBeatmapAttributes:
|
||||||
|
return OsuBeatmapAttributes(
|
||||||
|
star_rating=diff.stars,
|
||||||
|
max_combo=diff.max_combo,
|
||||||
|
aim_difficulty=diff.aim or 0,
|
||||||
|
aim_difficult_slider_count=diff.aim_difficult_slider_count or 0,
|
||||||
|
speed_difficulty=diff.speed or 0,
|
||||||
|
speed_note_count=diff.speed_note_count or 0,
|
||||||
|
slider_factor=diff.slider_factor or 0,
|
||||||
|
aim_difficult_strain_count=diff.aim_difficult_strain_count or 0,
|
||||||
|
speed_difficult_strain_count=diff.speed_difficult_strain_count or 0,
|
||||||
|
flashlight_difficulty=diff.flashlight or 0,
|
||||||
|
)
|
||||||
|
elif attr_class is TaikoBeatmapAttributes:
|
||||||
|
return TaikoBeatmapAttributes(
|
||||||
|
star_rating=diff.stars,
|
||||||
|
max_combo=diff.max_combo,
|
||||||
|
rhythm_difficulty=diff.rhythm or 0,
|
||||||
|
mono_stamina_factor=diff.stamina or 0,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return BeatmapAttributes(
|
||||||
|
star_rating=diff.stars,
|
||||||
|
max_combo=diff.max_combo,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def calculate_difficulty(
|
||||||
|
self, beatmap_raw: str, mods: list[APIMod] | None = None, gamemode: GameMode | None = None
|
||||||
|
) -> BeatmapAttributes:
|
||||||
|
try:
|
||||||
|
map = rosu.Beatmap(content=beatmap_raw)
|
||||||
|
if gamemode is not None:
|
||||||
|
map.convert(self._to_rosu_mode(gamemode), mods) # pyright: ignore[reportArgumentType]
|
||||||
|
diff_calculator = rosu.Difficulty(mods=mods)
|
||||||
|
diff = await get_event_loop().run_in_executor(None, diff_calculator.calculate, map)
|
||||||
|
return self._diff_attr_to_model(
|
||||||
|
diff, gamemode.to_base_ruleset() if gamemode else self._from_rosu_mode(diff.mode)
|
||||||
|
)
|
||||||
|
except rosu.ConvertError as e: # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
raise ConvertError(f"Beatmap convert error: {e}")
|
||||||
|
except rosu.ParseError as e: # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
raise DifficultyError(f"Beatmap parse error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise CalculateError(f"Unknown error: {e}") from e
|
||||||
@@ -103,6 +103,23 @@ STORAGE_SETTINGS='{
|
|||||||
"s3_public_url_base": "https://your-custom-domain.com"
|
"s3_public_url_base": "https://your-custom-domain.com"
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
""",
|
||||||
|
"表现计算设置": """配置表现分计算器及其参数。
|
||||||
|
|
||||||
|
### rosu-pp-py (默认)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CALCULATOR="rosu"
|
||||||
|
CALCULATOR_CONFIG='{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### [osu-performance-server](https://github.com/GooGuTeam/osu-performance-server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CALCULATOR="performance_server"
|
||||||
|
CALCULATOR_CONFIG='{
|
||||||
|
"server_url": "http://localhost:5225"
|
||||||
|
}'
|
||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -486,6 +503,21 @@ STORAGE_SETTINGS='{
|
|||||||
"游戏设置",
|
"游戏设置",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 表现计算设置
|
||||||
|
calculator: Annotated[
|
||||||
|
Literal["rosu", "performance_server"],
|
||||||
|
Field(default="rosu", description="表现分计算器"),
|
||||||
|
"表现计算设置",
|
||||||
|
]
|
||||||
|
calculator_config: Annotated[
|
||||||
|
dict[str, Any],
|
||||||
|
Field(
|
||||||
|
default={},
|
||||||
|
description="表现分计算器配置 (JSON 格式),具体配置项请参考上方",
|
||||||
|
),
|
||||||
|
"表现计算设置",
|
||||||
|
]
|
||||||
|
|
||||||
# 谱面缓存设置
|
# 谱面缓存设置
|
||||||
enable_beatmap_preload: Annotated[
|
enable_beatmap_preload: Annotated[
|
||||||
bool,
|
bool,
|
||||||
@@ -612,7 +644,7 @@ STORAGE_SETTINGS='{
|
|||||||
# 反作弊设置
|
# 反作弊设置
|
||||||
suspicious_score_check: Annotated[
|
suspicious_score_check: Annotated[
|
||||||
bool,
|
bool,
|
||||||
Field(default=True, description="启用可疑分数检查(star>25&acc<80 或 pp>3000)"),
|
Field(default=True, description="启用可疑分数检查(pp>3000)"),
|
||||||
"反作弊设置",
|
"反作弊设置",
|
||||||
]
|
]
|
||||||
banned_name: Annotated[
|
banned_name: Annotated[
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import asyncio
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.calculator import calculate_beatmap_attribute
|
from app.calculator import get_calculator
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database.beatmap_tags import BeatmapTagVote
|
from app.database.beatmap_tags import BeatmapTagVote
|
||||||
from app.database.failtime import FailTime, FailTimeResp
|
from app.database.failtime import FailTime, FailTimeResp
|
||||||
from app.models.beatmap import BeatmapAttributes, BeatmapRankStatus
|
from app.models.beatmap import BeatmapRankStatus
|
||||||
from app.models.mods import APIMod
|
from app.models.mods import APIMod
|
||||||
|
from app.models.performance import DIFFICULTY_CLASS, BeatmapAttributes
|
||||||
from app.models.score import GameMode
|
from app.models.score import GameMode
|
||||||
|
|
||||||
from .beatmap_playcounts import BeatmapPlaycounts
|
from .beatmap_playcounts import BeatmapPlaycounts
|
||||||
@@ -247,10 +247,13 @@ async def calculate_beatmap_attributes(
|
|||||||
redis: Redis,
|
redis: Redis,
|
||||||
fetcher: "Fetcher",
|
fetcher: "Fetcher",
|
||||||
):
|
):
|
||||||
|
attr_class = DIFFICULTY_CLASS.get(ruleset, BeatmapAttributes)
|
||||||
|
|
||||||
key = f"beatmap:{beatmap_id}:{ruleset}:{hashlib.sha256(str(mods_).encode()).hexdigest()}:attributes"
|
key = f"beatmap:{beatmap_id}:{ruleset}:{hashlib.sha256(str(mods_).encode()).hexdigest()}:attributes"
|
||||||
if await redis.exists(key):
|
if await redis.exists(key):
|
||||||
return BeatmapAttributes.model_validate_json(await redis.get(key))
|
return attr_class.model_validate_json(await redis.get(key))
|
||||||
resp = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id)
|
resp = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id)
|
||||||
attr = await asyncio.get_event_loop().run_in_executor(None, calculate_beatmap_attribute, resp, ruleset, mods_)
|
|
||||||
|
attr = await get_calculator().calculate_difficulty(resp, mods_, ruleset)
|
||||||
await redis.set(key, attr.model_dump_json())
|
await redis.set(key, attr.model_dump_json())
|
||||||
return attr
|
return attr
|
||||||
|
|||||||
@@ -69,23 +69,6 @@ class Language(IntEnum):
|
|||||||
OTHER = 14
|
OTHER = 14
|
||||||
|
|
||||||
|
|
||||||
class BeatmapAttributes(BaseModel):
|
|
||||||
star_rating: float
|
|
||||||
max_combo: int
|
|
||||||
|
|
||||||
# osu
|
|
||||||
aim_difficulty: float | None = None
|
|
||||||
aim_difficult_slider_count: float | None = None
|
|
||||||
speed_difficulty: float | None = None
|
|
||||||
speed_note_count: float | None = None
|
|
||||||
slider_factor: float | None = None
|
|
||||||
aim_difficult_strain_count: float | None = None
|
|
||||||
speed_difficult_strain_count: float | None = None
|
|
||||||
|
|
||||||
# taiko
|
|
||||||
mono_stamina_factor: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_list(v: Any):
|
def _parse_list(v: Any):
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
return v.split(".")
|
return v.split(".")
|
||||||
|
|||||||
81
app/models/performance.py
Normal file
81
app/models/performance.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from app.models.score import GameMode
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceAttributes(BaseModel):
|
||||||
|
pp: float
|
||||||
|
|
||||||
|
|
||||||
|
# https://github.com/ppy/osu/blob/9ebc5b0a35452e50bd408af1db62cfc22a57b1f4/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
|
||||||
|
class OsuPerformanceAttributes(PerformanceAttributes):
|
||||||
|
aim: float
|
||||||
|
speed: float
|
||||||
|
accuracy: float
|
||||||
|
flashlight: float
|
||||||
|
effective_miss_count: float
|
||||||
|
speed_deviation: float | None = None
|
||||||
|
|
||||||
|
# 2025 Q3 update
|
||||||
|
# combo_based_estimated_miss_count: int
|
||||||
|
# score_based_estimated_miss_count: int | None = None
|
||||||
|
# aim_estimated_slider_breaks: int
|
||||||
|
# speed_estimated_slider_breaks: int
|
||||||
|
|
||||||
|
|
||||||
|
# https://github.com/ppy/osu/blob/9ebc5b0a35452e50bd408af1db62cfc22a57b1f4/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs
|
||||||
|
class TaikoPerformanceAttributes(PerformanceAttributes):
|
||||||
|
difficulty: float
|
||||||
|
accuracy: float
|
||||||
|
estimated_unstable_rate: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# https://github.com/ppy/osu/blob/9ebc5b0a35452e50bd408af1db62cfc22a57b1f4/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs
|
||||||
|
class ManiaPerformanceAttributes(PerformanceAttributes):
|
||||||
|
difficulty: float
|
||||||
|
|
||||||
|
|
||||||
|
PERFORMANCE_CLASS: dict[GameMode, type[PerformanceAttributes]] = {
|
||||||
|
GameMode.OSU: OsuPerformanceAttributes,
|
||||||
|
GameMode.MANIA: ManiaPerformanceAttributes,
|
||||||
|
GameMode.TAIKO: TaikoPerformanceAttributes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BeatmapAttributes(BaseModel):
|
||||||
|
star_rating: float
|
||||||
|
max_combo: int
|
||||||
|
|
||||||
|
|
||||||
|
# https://github.com/ppy/osu/blob/9ebc5b0a35452e50bd408af1db62cfc22a57b1f4/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
|
||||||
|
class OsuBeatmapAttributes(BeatmapAttributes):
|
||||||
|
aim_difficulty: float
|
||||||
|
aim_difficult_slider_count: float
|
||||||
|
speed_difficulty: float
|
||||||
|
speed_note_count: float
|
||||||
|
flashlight_difficulty: float | None = None
|
||||||
|
slider_factor: float
|
||||||
|
aim_difficult_strain_count: float
|
||||||
|
speed_difficult_strain_count: float
|
||||||
|
|
||||||
|
# 2025 Q3 update
|
||||||
|
# aim_top_weighted_slider_factor: float
|
||||||
|
# speed_top_weighted_slider_factor: float
|
||||||
|
# nested_score_per_object: float
|
||||||
|
# legacy_score_base_multiplier: float
|
||||||
|
# maximum_legacy_combo_score: float
|
||||||
|
|
||||||
|
|
||||||
|
# https://github.com/ppy/osu/blob/9ebc5b0a35452e50bd408af1db62cfc22a57b1f4/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
|
||||||
|
class TaikoBeatmapAttributes(BeatmapAttributes):
|
||||||
|
rhythm_difficulty: float
|
||||||
|
mono_stamina_factor: float
|
||||||
|
|
||||||
|
# 2025 Q3 update
|
||||||
|
# consistency_factor: float
|
||||||
|
|
||||||
|
|
||||||
|
DIFFICULTY_CLASS: dict[GameMode, type[BeatmapAttributes]] = {
|
||||||
|
GameMode.OSU: OsuBeatmapAttributes,
|
||||||
|
GameMode.TAIKO: TaikoBeatmapAttributes,
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
from typing import Literal, TypedDict, cast
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
@@ -7,9 +7,6 @@ from .mods import API_MODS, APIMod
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator
|
from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import rosu_pp_py as rosu
|
|
||||||
|
|
||||||
|
|
||||||
class GameMode(str, Enum):
|
class GameMode(str, Enum):
|
||||||
OSU = "osu"
|
OSU = "osu"
|
||||||
@@ -21,20 +18,6 @@ class GameMode(str, Enum):
|
|||||||
TAIKORX = "taikorx"
|
TAIKORX = "taikorx"
|
||||||
FRUITSRX = "fruitsrx"
|
FRUITSRX = "fruitsrx"
|
||||||
|
|
||||||
def to_rosu(self) -> "rosu.GameMode":
|
|
||||||
import rosu_pp_py as rosu
|
|
||||||
|
|
||||||
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,
|
|
||||||
GameMode.TAIKORX: rosu.GameMode.Taiko,
|
|
||||||
GameMode.FRUITSRX: rosu.GameMode.Catch,
|
|
||||||
}[self]
|
|
||||||
|
|
||||||
def __int__(self) -> int:
|
def __int__(self) -> int:
|
||||||
return {
|
return {
|
||||||
GameMode.OSU: 0,
|
GameMode.OSU: 0,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.dependencies.database import Database, Redis
|
|||||||
from app.dependencies.fetcher import Fetcher
|
from app.dependencies.fetcher import Fetcher
|
||||||
from app.models.beatmap import BeatmapRankStatus, Genre, Language
|
from app.models.beatmap import BeatmapRankStatus, Genre, Language
|
||||||
from app.models.mods import int_to_mods
|
from app.models.mods import int_to_mods
|
||||||
|
from app.models.performance import OsuBeatmapAttributes
|
||||||
from app.models.score import GameMode
|
from app.models.score import GameMode
|
||||||
|
|
||||||
from .router import AllStrModel, router
|
from .router import AllStrModel, router
|
||||||
@@ -193,7 +194,12 @@ async def get_beatmaps(
|
|||||||
redis,
|
redis,
|
||||||
fetcher,
|
fetcher,
|
||||||
)
|
)
|
||||||
results.append(await V1Beatmap.from_db(session, beatmap, attrs.aim_difficulty, attrs.speed_difficulty))
|
aim_diff = None
|
||||||
|
speed_diff = None
|
||||||
|
if isinstance(attrs, OsuBeatmapAttributes):
|
||||||
|
aim_diff = attrs.aim_difficulty
|
||||||
|
speed_diff = attrs.speed_difficulty
|
||||||
|
results.append(await V1Beatmap.from_db(session, beatmap, aim_diff, speed_diff))
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
|
from app.calculators.performance import ConvertError
|
||||||
from app.database import Beatmap, BeatmapResp, User
|
from app.database import Beatmap, BeatmapResp, User
|
||||||
from app.database.beatmap import calculate_beatmap_attributes
|
from app.database.beatmap import calculate_beatmap_attributes
|
||||||
from app.dependencies.database import Database, Redis
|
from app.dependencies.database import Database, Redis
|
||||||
from app.dependencies.fetcher import Fetcher
|
from app.dependencies.fetcher import Fetcher
|
||||||
from app.dependencies.user import get_current_user
|
from app.dependencies.user import get_current_user
|
||||||
from app.helpers.asset_proxy_helper import asset_proxy_response
|
from app.helpers.asset_proxy_helper import asset_proxy_response
|
||||||
from app.models.beatmap import BeatmapAttributes
|
|
||||||
from app.models.mods import APIMod, int_to_mods
|
from app.models.mods import APIMod, int_to_mods
|
||||||
|
from app.models.performance import BeatmapAttributes, OsuBeatmapAttributes, TaikoBeatmapAttributes
|
||||||
from app.models.score import (
|
from app.models.score import (
|
||||||
GameMode,
|
GameMode,
|
||||||
)
|
)
|
||||||
@@ -20,7 +21,6 @@ from .router import router
|
|||||||
from fastapi import HTTPException, Path, Query, Security
|
from fastapi import HTTPException, Path, Query, Security
|
||||||
from httpx import HTTPError, HTTPStatusError
|
from httpx import HTTPError, HTTPStatusError
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import rosu_pp_py as rosu
|
|
||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ async def batch_get_beatmaps(
|
|||||||
"/beatmaps/{beatmap_id}/attributes",
|
"/beatmaps/{beatmap_id}/attributes",
|
||||||
tags=["谱面"],
|
tags=["谱面"],
|
||||||
name="计算谱面属性",
|
name="计算谱面属性",
|
||||||
response_model=BeatmapAttributes,
|
response_model=BeatmapAttributes | OsuBeatmapAttributes | TaikoBeatmapAttributes,
|
||||||
description=("计算谱面指定 mods / ruleset 下谱面的难度属性 (难度/PP 相关属性)。"),
|
description=("计算谱面指定 mods / ruleset 下谱面的难度属性 (难度/PP 相关属性)。"),
|
||||||
)
|
)
|
||||||
async def get_beatmap_attributes(
|
async def get_beatmap_attributes(
|
||||||
@@ -171,5 +171,5 @@ async def get_beatmap_attributes(
|
|||||||
return await calculate_beatmap_attributes(beatmap_id, ruleset, mods_, redis, fetcher)
|
return await calculate_beatmap_attributes(beatmap_id, ruleset, mods_, redis, fetcher)
|
||||||
except HTTPStatusError:
|
except HTTPStatusError:
|
||||||
raise HTTPException(status_code=404, detail="Beatmap not found")
|
raise HTTPException(status_code=404, detail="Beatmap not found")
|
||||||
except rosu.ConvertError as e: # pyright: ignore[reportAttributeAccessIssue]
|
except ConvertError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ services:
|
|||||||
- ENABLE_ALL_MODS_PP=true
|
- ENABLE_ALL_MODS_PP=true
|
||||||
- ENABLE_SUPPORTER_FOR_ALL_USERS=true
|
- ENABLE_SUPPORTER_FOR_ALL_USERS=true
|
||||||
- ENABLE_ALL_BEATMAP_LEADERBOARD=true
|
- ENABLE_ALL_BEATMAP_LEADERBOARD=true
|
||||||
|
# - CALCULATOR=performance_server
|
||||||
|
# - CALCULATOR_CONFIG='{"server_url":"http://performance-server:8080"}'
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -109,6 +111,15 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- osu-network
|
- 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
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ services:
|
|||||||
- MYSQL_HOST=mysql
|
- MYSQL_HOST=mysql
|
||||||
- MYSQL_PORT=3306
|
- MYSQL_PORT=3306
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
|
# - CALCULATOR=performance_server
|
||||||
|
# - CALCULATOR_CONFIG='{"server_url":"http://performance-server:8080"}'
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -102,6 +104,15 @@ services:
|
|||||||
- osu-network
|
- osu-network
|
||||||
command: redis-server --appendonly yes
|
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
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|||||||
4
main.py
4
main.py
@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.calculator import init_calculator
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import User
|
from app.database import User
|
||||||
from app.dependencies.database import (
|
from app.dependencies.database import (
|
||||||
@@ -58,10 +59,11 @@ import sentry_sdk
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI): # noqa: ARG001
|
async def lifespan(app: FastAPI): # noqa: ARG001
|
||||||
# === on startup ===
|
# === on startup ===
|
||||||
# init mods and achievements
|
# init mods, achievements and performance calculator
|
||||||
init_mods()
|
init_mods()
|
||||||
init_ranked_mods()
|
init_ranked_mods()
|
||||||
load_achievements()
|
load_achievements()
|
||||||
|
init_calculator()
|
||||||
|
|
||||||
# init rate limiter
|
# init rate limiter
|
||||||
await FastAPILimiter.init(redis_rate_limit_client)
|
await FastAPILimiter.init(redis_rate_limit_client)
|
||||||
|
|||||||
Reference in New Issue
Block a user