feat(v1-api): support api v1

This commit is contained in:
MingxuanGame
2025-08-14 09:11:53 +00:00
parent c98ea9c723
commit a8906b8194
15 changed files with 802 additions and 18 deletions

View File

@@ -6,15 +6,21 @@ from .auth import router as auth_router
from .fetcher import fetcher_router as fetcher_router
from .file import file_router as file_router
from .private import private_router as private_router
from .redirect import redirect_router as redirect_router
from .redirect import (
redirect_api_router as redirect_api_router,
redirect_router as redirect_router,
)
from .v1.router import router as api_v1_router
from .v2.router import router as api_v2_router
__all__ = [
"api_v1_router",
"api_v2_router",
"auth_router",
"fetcher_router",
"file_router",
"private_router",
"redirect_api_router",
"redirect_router",
"signalr_router",
]

View File

@@ -4,7 +4,7 @@ import urllib.parse
from app.config import settings
from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
redirect_router = APIRouter(include_in_schema=False)
@@ -28,3 +28,20 @@ async def redirect(request: Request):
redirect_url,
status_code=301,
)
redirect_api_router = APIRouter(prefix="/api", include_in_schema=False)
@redirect_api_router.get("/{path}")
async def redirect_to_api_root(request: Request, path: str):
if path in {
"get_beatmaps",
"get_user",
"get_scores",
"get_user_best",
"get_user_recent",
"get_replay",
}:
return RedirectResponse(f"/api/v1/{path}?{request.url.query}", status_code=302)
raise HTTPException(404, detail="Not Found")

View File

@@ -0,0 +1,6 @@
from __future__ import annotations
from . import beatmap, replay, score, user # noqa: F401
from .router import router as api_v1_router
__all__ = ["api_v1_router"]

226
app/router/v1/beatmap.py Normal file
View File

@@ -0,0 +1,226 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from app.database.beatmap import Beatmap, calculate_beatmap_attributes
from app.database.beatmap_playcounts import BeatmapPlaycounts
from app.database.beatmapset import Beatmapset
from app.database.favourite_beatmapset import FavouriteBeatmapset
from app.database.score import Score
from app.dependencies.database import get_db, get_redis
from app.dependencies.fetcher import get_fetcher
from app.fetcher import Fetcher
from app.models.beatmap import BeatmapRankStatus, Genre, Language
from app.models.mods import int_to_mods
from app.models.score import MODE_TO_INT, GameMode
from .router import AllStrModel, router
from fastapi import Depends, Query
from redis.asyncio import Redis
from sqlmodel import col, func, select
from sqlmodel.ext.asyncio.session import AsyncSession
class V1Beatmap(AllStrModel):
approved: BeatmapRankStatus
submit_date: datetime
approved_date: datetime | None = None
last_update: datetime
artist: str
artist_unicode: str
beatmap_id: int
beatmapset_id: int
bpm: float
creator: str
creator_id: int
difficultyrating: float
diff_aim: float | None = None
diff_speed: float | None = None
diff_size: float # CS
diff_overall: float # OD
diff_approach: float # AR
diff_drain: float # HP
hit_length: int
source: str
genre_id: Genre
language_id: Language
title: str
title_unicode: str
total_length: int
version: str
file_md5: str
mode: int
tags: str
favourite_count: int
rating: float
playcount: int
passcount: int
count_normal: int
count_slider: int
count_spinner: int
max_combo: int | None = None
storyboard: bool
video: bool
download_unavailable: bool
audio_unavailable: bool
@classmethod
async def from_db(
cls,
session: AsyncSession,
db_beatmap: Beatmap,
diff_aim: float | None = None,
diff_speed: float | None = None,
) -> "V1Beatmap":
return cls(
approved=db_beatmap.beatmap_status,
submit_date=db_beatmap.beatmapset.submitted_date,
approved_date=db_beatmap.beatmapset.ranked_date,
last_update=db_beatmap.last_updated,
artist=db_beatmap.beatmapset.artist,
beatmap_id=db_beatmap.id,
beatmapset_id=db_beatmap.beatmapset.id,
bpm=db_beatmap.bpm,
creator=db_beatmap.beatmapset.creator,
creator_id=db_beatmap.beatmapset.user_id,
difficultyrating=db_beatmap.difficulty_rating,
diff_aim=diff_aim,
diff_speed=diff_speed,
diff_size=db_beatmap.cs,
diff_overall=db_beatmap.accuracy,
diff_approach=db_beatmap.ar,
diff_drain=db_beatmap.drain,
hit_length=db_beatmap.hit_length,
source=db_beatmap.beatmapset.source,
genre_id=db_beatmap.beatmapset.beatmap_genre,
language_id=db_beatmap.beatmapset.beatmap_language,
title=db_beatmap.beatmapset.title,
total_length=db_beatmap.total_length,
version=db_beatmap.version,
file_md5=db_beatmap.checksum,
mode=MODE_TO_INT[db_beatmap.mode],
tags=db_beatmap.beatmapset.tags,
favourite_count=(
await session.exec(
select(func.count())
.select_from(FavouriteBeatmapset)
.where(
FavouriteBeatmapset.beatmapset_id == db_beatmap.beatmapset.id
)
)
).one(),
rating=0, # TODO
playcount=(
await session.exec(
select(func.count())
.select_from(BeatmapPlaycounts)
.where(BeatmapPlaycounts.beatmap_id == db_beatmap.id)
)
).one(),
passcount=(
await session.exec(
select(func.count())
.select_from(Score)
.where(
Score.beatmap_id == db_beatmap.id,
col(Score.passed).is_(True),
)
)
).one(),
count_normal=db_beatmap.count_circles,
count_slider=db_beatmap.count_sliders,
count_spinner=db_beatmap.count_spinners,
max_combo=db_beatmap.max_combo,
storyboard=db_beatmap.beatmapset.storyboard,
video=db_beatmap.beatmapset.video,
download_unavailable=db_beatmap.beatmapset.download_disabled,
audio_unavailable=db_beatmap.beatmapset.download_disabled,
artist_unicode=db_beatmap.beatmapset.artist_unicode,
title_unicode=db_beatmap.beatmapset.title_unicode,
)
@router.get(
"/get_beatmaps",
name="获取谱面",
response_model=list[V1Beatmap],
description="根据指定条件搜索谱面。",
)
async def get_beatmaps(
since: datetime | None = Query(None, description="自指定时间后拥有排行榜的谱面"),
beatmapset_id: int | None = Query(None, alias="s", description="谱面集 ID"),
beatmap_id: int | None = Query(None, alias="b", description="谱面 ID"),
user: str | None = Query(None, alias="u", description="谱师"),
type: Literal["string", "id"] | None = Query(
None, description="用户类型string 用户名称 / id 用户 ID"
),
ruleset_id: int | None = Query(
None, alias="m", description="Ruleset ID", ge=0, le=3
), # TODO
convert: bool = Query(False, alias="a", description="转谱"), # TODO
checksum: str | None = Query(None, alias="h", description="谱面文件 MD5"),
limit: int = Query(500, ge=1, le=500, description="返回结果数量限制"),
mods: int = Query(0, description="应用到谱面属性的 MOD"),
session: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
fetcher: Fetcher = Depends(get_fetcher),
):
beatmaps: list[Beatmap] = []
results = []
if beatmap_id is not None:
beatmaps.append(await Beatmap.get_or_fetch(session, fetcher, beatmap_id))
elif checksum is not None:
beatmaps.append(await Beatmap.get_or_fetch(session, fetcher, md5=checksum))
elif beatmapset_id is not None:
beatmapset = await Beatmapset.get_or_fetch(session, fetcher, beatmapset_id)
await beatmapset.awaitable_attrs.beatmaps
if len(beatmapset.beatmaps) > limit:
beatmaps = beatmapset.beatmaps[:limit]
else:
beatmaps = beatmapset.beatmaps
elif user is not None:
where = (
Beatmapset.user_id == user
if type == "id" or user.isdigit()
else Beatmapset.creator == user
)
beatmapsets = (await session.exec(select(Beatmapset).where(where))).all()
for beatmapset in beatmapsets:
if len(beatmaps) >= limit:
break
beatmaps.extend(beatmapset.beatmaps)
elif since is not None:
beatmapsets = (
await session.exec(
select(Beatmapset)
.where(col(Beatmapset.ranked_date) > since)
.limit(limit)
)
).all()
for beatmapset in beatmapsets:
if len(beatmaps) >= limit:
break
beatmaps.extend(beatmapset.beatmaps)
for beatmap in beatmaps:
if beatmap.mode == GameMode.OSU:
try:
attrs = await calculate_beatmap_attributes(
beatmap.id,
beatmap.mode,
sorted(int_to_mods(mods), key=lambda m: m["acronym"]),
redis,
fetcher,
)
results.append(
await V1Beatmap.from_db(
session, beatmap, attrs.aim_difficulty, attrs.speed_difficulty
)
)
continue
except Exception:
...
results.append(await V1Beatmap.from_db(session, beatmap, None, None))
return results

100
app/router/v1/replay.py Normal file
View File

@@ -0,0 +1,100 @@
from __future__ import annotations
import base64
from datetime import date
from typing import Literal
from app.database.counts import ReplayWatchedCount
from app.database.score import Score
from app.dependencies.database import get_db
from app.dependencies.storage import get_storage_service
from app.models.mods import int_to_mods
from app.models.score import INT_TO_MODE
from app.storage import StorageService
from .router import router
from fastapi import Depends, HTTPException, Query
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
class ReplayModel(BaseModel):
content: str
encoding: Literal["base64"] = "base64"
@router.get(
"/get_replay",
response_model=ReplayModel,
name="获取回放文件",
description="获取指定谱面的回放文件。",
)
async def download_replay(
beatmap: int = Query(..., alias="b", description="谱面 ID"),
user: str = Query(..., alias="u", description="用户"),
ruleset_id: int | None = Query(
None, alias="m", description="Ruleset ID", ge=0, le=3
),
score_id: int | None = Query(None, alias="s", description="成绩 ID"),
type: Literal["string", "id"] | None = Query(
None, description="用户类型string 用户名称 / id 用户 ID"
),
mods: int = Query(0, description="成绩的 MOD"),
session: AsyncSession = Depends(get_db),
storage_service: StorageService = Depends(get_storage_service),
):
mods_ = int_to_mods(mods)
if score_id is not None:
score_record = await session.get(Score, score_id)
if score_record is None:
raise HTTPException(status_code=404, detail="Score not found")
else:
score_record = (
await session.exec(
select(Score).where(
Score.beatmap_id == beatmap,
Score.user_id == user
if type == "id" or user.isdigit()
else Score.user.username == user,
Score.mods == mods_,
Score.gamemode == INT_TO_MODE[ruleset_id]
if ruleset_id is not None
else True,
)
)
).first()
if score_record is None:
raise HTTPException(status_code=404, detail="Score not found")
filepath = (
f"replays/{score_record.id}_{score_record.beatmap_id}"
f"_{score_record.user_id}_lazer_replay.osr"
)
if not await storage_service.is_exists(filepath):
raise HTTPException(status_code=404, detail="Replay file not found")
replay_watched_count = (
await session.exec(
select(ReplayWatchedCount).where(
ReplayWatchedCount.user_id == score_record.user_id,
ReplayWatchedCount.year == date.today().year,
ReplayWatchedCount.month == date.today().month,
)
)
).first()
if replay_watched_count is None:
replay_watched_count = ReplayWatchedCount(
user_id=score_record.user_id,
year=date.today().year,
month=date.today().month,
)
session.add(replay_watched_count)
replay_watched_count.count += 1
await session.commit()
data = await storage_service.read_file(filepath)
return ReplayModel(
content=base64.b64encode(data).decode("utf-8"), encoding="base64"
)

25
app/router/v1/router.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from app.dependencies.user import v1_api_key
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_serializer
router = APIRouter(
prefix="/api/v1", dependencies=[Depends(v1_api_key)], tags=["V1 API"]
)
class AllStrModel(BaseModel):
@field_serializer("*", when_used="json")
def serialize_datetime(self, v, _info):
if isinstance(v, Enum):
return str(v.value)
elif isinstance(v, datetime):
return v.strftime("%Y-%m-%d %H:%M:%S")
elif isinstance(v, bool):
return "1" if v else "0"
return str(v)

171
app/router/v1/score.py Normal file
View File

@@ -0,0 +1,171 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Literal
from app.database.pp_best_score import PPBestScore
from app.database.score import Score, get_leaderboard
from app.dependencies.database import get_db
from app.models.mods import int_to_mods, mods_to_int
from app.models.score import INT_TO_MODE, LeaderboardType
from .router import AllStrModel, router
from fastapi import Depends, Query
from sqlalchemy.orm import joinedload
from sqlmodel import col, exists, select
from sqlmodel.ext.asyncio.session import AsyncSession
class V1Score(AllStrModel):
beatmap_id: int | None = None
username: str | None = None
score_id: int
score: int
maxcombo: int | None = None
count50: int
count100: int
count300: int
countmiss: int
countkatu: int
countgeki: int
perfect: bool
enabled_mods: int
user_id: int
date: datetime
rank: str
pp: float
replay_available: bool
@classmethod
async def from_db(cls, score: Score):
return cls(
beatmap_id=score.beatmap_id,
username=score.user.username,
score_id=score.id,
score=score.total_score,
maxcombo=score.max_combo,
count50=score.n50,
count100=score.n100,
count300=score.n300,
countmiss=score.nmiss,
countkatu=score.nkatu,
countgeki=score.ngeki,
perfect=score.is_perfect_combo,
enabled_mods=mods_to_int(score.mods),
user_id=score.user_id,
date=score.ended_at,
rank=score.rank,
pp=score.pp,
replay_available=score.has_replay,
)
@router.get(
"/get_user_best",
response_model=list[V1Score],
name="获取用户最好成绩",
description="获取指定用户的最好成绩。",
)
async def get_user_best(
user: str = Query(..., alias="u", description="用户"),
ruleset_id: int = Query(0, alias="m", description="Ruleset ID", ge=0, le=3),
type: Literal["string", "id"] | None = Query(
None, description="用户类型string 用户名称 / id 用户 ID"
),
limit: int = Query(10, ge=1, le=100, description="返回的成绩数量"),
session: AsyncSession = Depends(get_db),
):
scores = (
await session.exec(
select(Score)
.where(
Score.user_id == user
if type == "id" or user.isdigit()
else Score.user.username == user,
Score.gamemode == INT_TO_MODE[ruleset_id],
exists().where(col(PPBestScore.score_id) == Score.id),
)
.order_by(col(Score.pp).desc())
.options(joinedload(Score.beatmap))
.limit(limit)
)
).all()
return [await V1Score.from_db(score) for score in scores]
@router.get(
"/get_user_recent",
response_model=list[V1Score],
name="获取用户最近成绩",
description="获取指定用户的最近成绩。",
)
async def get_user_recent(
user: str = Query(..., alias="u", description="用户"),
ruleset_id: int = Query(0, alias="m", description="Ruleset ID", ge=0, le=3),
type: Literal["string", "id"] | None = Query(
None, description="用户类型string 用户名称 / id 用户 ID"
),
limit: int = Query(10, ge=1, le=100, description="返回的成绩数量"),
session: AsyncSession = Depends(get_db),
):
scores = (
await session.exec(
select(Score)
.where(
Score.user_id == user
if type == "id" or user.isdigit()
else Score.user.username == user,
Score.gamemode == INT_TO_MODE[ruleset_id],
Score.ended_at > datetime.now(UTC) - timedelta(hours=24),
)
.order_by(col(Score.pp).desc())
.options(joinedload(Score.beatmap))
.limit(limit)
)
).all()
return [await V1Score.from_db(score) for score in scores]
@router.get(
"/get_scores",
response_model=list[V1Score],
name="获取成绩",
description="获取指定谱面的成绩。",
)
async def get_scores(
user: str | None = Query(None, alias="u", description="用户"),
beatmap_id: int = Query(alias="b", description="谱面 ID"),
ruleset_id: int = Query(0, alias="m", description="Ruleset ID", ge=0, le=3),
type: Literal["string", "id"] | None = Query(
None, description="用户类型string 用户名称 / id 用户 ID"
),
limit: int = Query(10, ge=1, le=100, description="返回的成绩数量"),
mods: int = Query(0, description="成绩的 MOD"),
session: AsyncSession = Depends(get_db),
):
if user is not None:
scores = (
await session.exec(
select(Score)
.where(
Score.gamemode == INT_TO_MODE[ruleset_id],
Score.beatmap_id == beatmap_id,
Score.user_id == user
if type == "id" or user.isdigit()
else Score.user.username == user,
)
.options(joinedload(Score.beatmap))
.order_by(col(Score.classic_total_score).desc())
)
).all()
else:
scores, _ = await get_leaderboard(
session,
beatmap_id,
INT_TO_MODE[ruleset_id],
LeaderboardType.GLOBAL,
[mod["acronym"] for mod in int_to_mods(mods)],
limit=limit,
)
return [await V1Score.from_db(score) for score in scores]

118
app/router/v1/user.py Normal file
View File

@@ -0,0 +1,118 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from app.database.lazer_user import User
from app.database.statistics import UserStatistics, UserStatisticsResp
from app.dependencies.database import get_db
from app.models.score import INT_TO_MODE, GameMode
from .router import AllStrModel, router
from fastapi import Depends, Query
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
class V1User(AllStrModel):
user_id: int
username: str
join_date: datetime
count300: int
count100: int
count50: int
playcount: int
ranked_score: int
total_score: int
pp_rank: int
level: float
pp_raw: float
accuracy: float
count_rank_ss: int
count_rank_ssh: int
count_rank_s: int
count_rank_sh: int
count_rank_a: int
country: str
total_seconds_played: int
pp_country_rank: int
events: list[dict]
@classmethod
async def from_db(
cls, session: AsyncSession, db_user: User, ruleset: GameMode | None = None
) -> "V1User":
ruleset = ruleset or db_user.playmode
current_statistics: UserStatistics | None = None
for i in await db_user.awaitable_attrs.statistics:
if i.mode == ruleset:
current_statistics = i
break
if current_statistics:
statistics = await UserStatisticsResp.from_db(
current_statistics, session, db_user.country_code
)
else:
statistics = None
return cls(
user_id=db_user.id,
username=db_user.username,
join_date=db_user.join_date,
count300=statistics.count_300 if statistics else 0,
count100=statistics.count_100 if statistics else 0,
count50=statistics.count_50 if statistics else 0,
playcount=statistics.play_count if statistics else 0,
ranked_score=statistics.ranked_score if statistics else 0,
total_score=statistics.total_score if statistics else 0,
pp_rank=statistics.global_rank if statistics else 0,
level=current_statistics.level_current if current_statistics else 0,
pp_raw=statistics.pp if statistics else 0.0,
accuracy=statistics.hit_accuracy if statistics else 0,
count_rank_ss=current_statistics.grade_ss if current_statistics else 0,
count_rank_ssh=current_statistics.grade_ssh if current_statistics else 0,
count_rank_s=current_statistics.grade_s if current_statistics else 0,
count_rank_sh=current_statistics.grade_sh if current_statistics else 0,
count_rank_a=current_statistics.grade_a if current_statistics else 0,
country=db_user.country_code,
total_seconds_played=statistics.play_time if statistics else 0,
pp_country_rank=statistics.country_rank if statistics else 0,
events=[], # TODO
)
@router.get(
"/get_user",
response_model=list[V1User],
name="获取用户信息",
description="获取指定用户的信息。",
)
async def get_user(
user: str = Query(..., alias="u", description="用户"),
ruleset_id: int | None = Query(
None, alias="m", description="Ruleset ID", ge=0, le=3
),
type: Literal["string", "id"] | None = Query(
None, description="用户类型string 用户名称 / id 用户 ID"
),
event_days: int = Query(
default=1, ge=1, le=31, description="从现在起所有事件的最大天数"
),
session: AsyncSession = Depends(get_db),
):
db_user = (
await session.exec(
select(User).where(
User.id == user
if type == "id" or user.isdigit()
else User.username == user,
)
)
).first()
if not db_user:
return []
return [
await V1User.from_db(
session, db_user, INT_TO_MODE[ruleset_id] if ruleset_id else None
)
]

View File

@@ -4,8 +4,8 @@ import asyncio
import hashlib
import json
from app.calculator import calculate_beatmap_attribute
from app.database import Beatmap, BeatmapResp, User
from app.database.beatmap import calculate_beatmap_attributes
from app.dependencies.database import get_db, get_redis
from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user
@@ -195,16 +195,11 @@ async def get_beatmap_attributes(
)
if await redis.exists(key):
return BeatmapAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType]
try:
resp = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id)
try:
attr = await asyncio.get_event_loop().run_in_executor(
None, calculate_beatmap_attribute, resp, ruleset, mods_
)
except rosu.ConvertError as e: # pyright: ignore[reportAttributeAccessIssue]
raise HTTPException(status_code=400, detail=str(e))
await redis.set(key, attr.model_dump_json())
return attr
return await calculate_beatmap_attributes(
beatmap_id, ruleset, mods_, redis, fetcher
)
except HTTPStatusError:
raise HTTPException(status_code=404, detail="Beatmap not found")
except rosu.ConvertError as e: # pyright: ignore[reportAttributeAccessIssue]
raise HTTPException(status_code=400, detail=str(e)) from e