refactor(router): restructure

This commit is contained in:
MingxuanGame
2025-08-11 14:38:45 +00:00
parent 867b99cca5
commit d8c607137a
14 changed files with 36 additions and 33 deletions

19
app/router/v2/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from app.signalr import signalr_router as signalr_router
from . import ( # pyright: ignore[reportUnusedImport] # noqa: F401
beatmap,
beatmapset,
me,
misc,
relationship,
room,
score,
user,
)
from .router import router as api_v2_router
__all__ = [
"api_v2_router",
]

170
app/router/v2/beatmap.py Normal file
View File

@@ -0,0 +1,170 @@
from __future__ import annotations
import asyncio
import hashlib
import json
from app.calculator import calculate_beatmap_attribute
from app.database import Beatmap, BeatmapResp, User
from app.dependencies.database import get_db, get_redis
from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user
from app.fetcher import Fetcher
from app.models.beatmap import BeatmapAttributes
from app.models.mods import APIMod, int_to_mods
from app.models.score import (
INT_TO_MODE,
GameMode,
)
from .router import router
from fastapi import Depends, HTTPException, Query, Security
from httpx import HTTPError, HTTPStatusError
from pydantic import BaseModel
from redis.asyncio import Redis
import rosu_pp_py as rosu
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
@router.get("/beatmaps/lookup", tags=["beatmap"], response_model=BeatmapResp)
async def lookup_beatmap(
id: int | None = Query(default=None, alias="id"),
md5: str | None = Query(default=None, alias="checksum"),
filename: str | None = Query(default=None, alias="filename"),
current_user: User = Security(get_current_user, scopes=["public"]),
db: AsyncSession = Depends(get_db),
fetcher: Fetcher = Depends(get_fetcher),
):
if id is None and md5 is None and filename is None:
raise HTTPException(
status_code=400,
detail="At least one of 'id', 'checksum', or 'filename' must be provided.",
)
try:
beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=id, md5=md5)
except HTTPError:
raise HTTPException(status_code=404, detail="Beatmap not found")
if beatmap is None:
raise HTTPException(status_code=404, detail="Beatmap not found")
return await BeatmapResp.from_db(beatmap, session=db, user=current_user)
@router.get("/beatmaps/{bid}", tags=["beatmap"], response_model=BeatmapResp)
async def get_beatmap(
bid: int,
current_user: User = Security(get_current_user, scopes=["public"]),
db: AsyncSession = Depends(get_db),
fetcher: Fetcher = Depends(get_fetcher),
):
try:
beatmap = await Beatmap.get_or_fetch(db, fetcher, bid)
return await BeatmapResp.from_db(beatmap, session=db, user=current_user)
except HTTPError:
raise HTTPException(status_code=404, detail="Beatmap not found")
class BatchGetResp(BaseModel):
beatmaps: list[BeatmapResp]
@router.get("/beatmaps", tags=["beatmap"], response_model=BatchGetResp)
@router.get("/beatmaps/", tags=["beatmap"], response_model=BatchGetResp)
async def batch_get_beatmaps(
b_ids: list[int] = Query(alias="ids[]", default_factory=list),
current_user: User = Security(get_current_user, scopes=["public"]),
db: AsyncSession = Depends(get_db),
fetcher: Fetcher = Depends(get_fetcher),
):
if not b_ids:
# select 50 beatmaps by last_updated
beatmaps = (
await db.exec(
select(Beatmap).order_by(col(Beatmap.last_updated).desc()).limit(50)
)
).all()
else:
beatmaps = list(
(
await db.exec(
select(Beatmap).where(col(Beatmap.id).in_(b_ids)).limit(50)
)
).all()
)
not_found_beatmaps = [
bid for bid in b_ids if bid not in [bm.id for bm in beatmaps]
]
beatmaps.extend(
beatmap
for beatmap in await asyncio.gather(
*[
Beatmap.get_or_fetch(db, fetcher, bid=bid)
for bid in not_found_beatmaps
],
return_exceptions=True,
)
if isinstance(beatmap, Beatmap)
)
for beatmap in beatmaps:
await db.refresh(beatmap)
return BatchGetResp(
beatmaps=[
await BeatmapResp.from_db(bm, session=db, user=current_user)
for bm in beatmaps
]
)
@router.post(
"/beatmaps/{beatmap}/attributes",
tags=["beatmap"],
response_model=BeatmapAttributes,
)
async def get_beatmap_attributes(
beatmap: int,
current_user: User = Security(get_current_user, scopes=["public"]),
mods: list[str] = Query(default_factory=list),
ruleset: GameMode | None = Query(default=None),
ruleset_id: int | None = Query(default=None),
redis: Redis = Depends(get_redis),
db: AsyncSession = Depends(get_db),
fetcher: Fetcher = Depends(get_fetcher),
):
mods_ = []
if mods and mods[0].isdigit():
mods_ = int_to_mods(int(mods[0]))
else:
for i in mods:
try:
mods_.append(json.loads(i))
except json.JSONDecodeError:
mods_.append(APIMod(acronym=i, settings={}))
mods_.sort(key=lambda x: x["acronym"])
if ruleset_id is not None and ruleset is None:
ruleset = INT_TO_MODE[ruleset_id]
if ruleset is None:
beatmap_db = await Beatmap.get_or_fetch(db, fetcher, beatmap)
ruleset = beatmap_db.mode
key = (
f"beatmap:{beatmap}:{ruleset}:"
f"{hashlib.md5(str(mods_).encode()).hexdigest()}: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)
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
except HTTPStatusError:
raise HTTPException(status_code=404, detail="Beatmap not found")

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from typing import Literal
from app.database import Beatmap, Beatmapset, BeatmapsetResp, FavouriteBeatmapset, User
from app.dependencies.database import get_db
from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user
from app.fetcher import Fetcher
from .router import router
from fastapi import Depends, Form, HTTPException, Query, Security
from fastapi.responses import RedirectResponse
from httpx import HTTPError
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@router.get("/beatmapsets/lookup", tags=["beatmapset"], response_model=BeatmapsetResp)
async def lookup_beatmapset(
beatmap_id: int = Query(),
current_user: User = Security(get_current_user, scopes=["public"]),
db: AsyncSession = Depends(get_db),
fetcher: Fetcher = Depends(get_fetcher),
):
beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap_id)
resp = await BeatmapsetResp.from_db(
beatmap.beatmapset, session=db, user=current_user
)
return resp
@router.get("/beatmapsets/{sid}", tags=["beatmapset"], response_model=BeatmapsetResp)
async def get_beatmapset(
sid: int,
current_user: User = Security(get_current_user, scopes=["public"]),
db: AsyncSession = Depends(get_db),
fetcher: Fetcher = Depends(get_fetcher),
):
try:
beatmapset = await Beatmapset.get_or_fetch(db, fetcher, sid)
return await BeatmapsetResp.from_db(
beatmapset, session=db, include=["recent_favourites"], user=current_user
)
except HTTPError:
raise HTTPException(status_code=404, detail="Beatmapset not found")
@router.get("/beatmapsets/{beatmapset}/download", tags=["beatmapset"])
async def download_beatmapset(
beatmapset: int,
no_video: bool = Query(True, alias="noVideo"),
current_user: User = Security(get_current_user, scopes=["*"]),
):
if current_user.country_code == "CN":
return RedirectResponse(
f"https://txy1.sayobot.cn/beatmaps/download/"
f"{'novideo' if no_video else 'full'}/{beatmapset}?server=auto"
)
else:
return RedirectResponse(
f"https://api.nerinyan.moe/d/{beatmapset}?noVideo={no_video}"
)
@router.post("/beatmapsets/{beatmapset}/favourites", tags=["beatmapset"])
async def favourite_beatmapset(
beatmapset: int,
action: Literal["favourite", "unfavourite"] = Form(),
current_user: User = Security(get_current_user, scopes=["*"]),
db: AsyncSession = Depends(get_db),
):
existing_favourite = (
await db.exec(
select(FavouriteBeatmapset).where(
FavouriteBeatmapset.user_id == current_user.id,
FavouriteBeatmapset.beatmapset_id == beatmapset,
)
)
).first()
if (action == "favourite" and existing_favourite) or (
action == "unfavourite" and not existing_favourite
):
return
if action == "favourite":
favourite = FavouriteBeatmapset(
user_id=current_user.id, beatmapset_id=beatmapset
)
db.add(favourite)
else:
await db.delete(existing_favourite)
await db.commit()

27
app/router/v2/me.py Normal file
View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from app.database import User, UserResp
from app.database.lazer_user import ALL_INCLUDED
from app.dependencies import get_current_user
from app.dependencies.database import get_db
from app.models.score import GameMode
from .router import router
from fastapi import Depends, Security
from sqlmodel.ext.asyncio.session import AsyncSession
@router.get("/me/{ruleset}", response_model=UserResp)
@router.get("/me/", response_model=UserResp)
async def get_user_info_default(
ruleset: GameMode | None = None,
current_user: User = Security(get_current_user, scopes=["identify"]),
session: AsyncSession = Depends(get_db),
):
return await UserResp.from_db(
current_user,
session,
ALL_INCLUDED,
ruleset,
)

25
app/router/v2/misc.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from datetime import UTC, datetime
from app.config import settings
from .router import router
from pydantic import BaseModel
class Background(BaseModel):
url: str
class BackgroundsResp(BaseModel):
ends_at: datetime = datetime(year=9999, month=12, day=31, tzinfo=UTC)
backgrounds: list[Background]
@router.get("/seasonal-backgrounds", response_model=BackgroundsResp)
async def get_seasonal_backgrounds():
return BackgroundsResp(
backgrounds=[Background(url=url) for url in settings.seasonal_backgrounds]
)

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
from app.database import Relationship, RelationshipResp, RelationshipType, User
from app.dependencies.database import get_db
from app.dependencies.user import get_current_user
from .router import router
from fastapi import Depends, HTTPException, Query, Request, Security
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@router.get("/friends", tags=["relationship"], response_model=list[RelationshipResp])
@router.get("/blocks", tags=["relationship"], response_model=list[RelationshipResp])
async def get_relationship(
request: Request,
current_user: User = Security(get_current_user, scopes=["friends.read"]),
db: AsyncSession = Depends(get_db),
):
relationship_type = (
RelationshipType.FOLLOW
if request.url.path.endswith("/friends")
else RelationshipType.BLOCK
)
relationships = await db.exec(
select(Relationship).where(
Relationship.user_id == current_user.id,
Relationship.type == relationship_type,
)
)
return [await RelationshipResp.from_db(db, rel) for rel in relationships.unique()]
class AddFriendResp(BaseModel):
user_relation: RelationshipResp
@router.post("/friends", tags=["relationship"], response_model=AddFriendResp)
@router.post("/blocks", tags=["relationship"])
async def add_relationship(
request: Request,
target: int = Query(),
current_user: User = Security(get_current_user, scopes=["*"]),
db: AsyncSession = Depends(get_db),
):
relationship_type = (
RelationshipType.FOLLOW
if request.url.path.endswith("/friends")
else RelationshipType.BLOCK
)
if target == current_user.id:
raise HTTPException(422, "Cannot add relationship to yourself")
relationship = (
await db.exec(
select(Relationship).where(
Relationship.user_id == current_user.id,
Relationship.target_id == target,
)
)
).first()
if relationship:
relationship.type = relationship_type
# 这里原来如何是 block 也会修改为 follow
# 与 ppy/osu-web 的行为保持一致
else:
relationship = Relationship(
user_id=current_user.id,
target_id=target,
type=relationship_type,
)
db.add(relationship)
origin_type = relationship.type
if origin_type == RelationshipType.BLOCK:
target_relationship = (
await db.exec(
select(Relationship).where(
Relationship.user_id == target,
Relationship.target_id == current_user.id,
)
)
).first()
if target_relationship and target_relationship.type == RelationshipType.FOLLOW:
await db.delete(target_relationship)
current_user_id = current_user.id
await db.commit()
if origin_type == RelationshipType.FOLLOW:
relationship = (
await db.exec(
select(Relationship).where(
Relationship.user_id == current_user_id,
Relationship.target_id == target,
)
)
).first()
assert relationship, "Relationship should exist after commit"
return AddFriendResp(
user_relation=await RelationshipResp.from_db(db, relationship)
)
@router.delete("/friends/{target}", tags=["relationship"])
@router.delete("/blocks/{target}", tags=["relationship"])
async def delete_relationship(
request: Request,
target: int,
current_user: User = Security(get_current_user, scopes=["*"]),
db: AsyncSession = Depends(get_db),
):
relationship_type = (
RelationshipType.BLOCK
if "/blocks/" in request.url.path
else RelationshipType.FOLLOW
)
relationship = (
await db.exec(
select(Relationship).where(
Relationship.user_id == current_user.id,
Relationship.target_id == target,
)
)
).first()
if not relationship:
raise HTTPException(404, "Relationship not found")
if relationship.type != relationship_type:
raise HTTPException(422, "Relationship type mismatch")
await db.delete(relationship)
await db.commit()

361
app/router/v2/room.py Normal file
View File

@@ -0,0 +1,361 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Literal
from app.database.beatmap import Beatmap, BeatmapResp
from app.database.beatmapset import BeatmapsetResp
from app.database.lazer_user import User, UserResp
from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp
from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp
from app.database.playlists import Playlist, PlaylistResp
from app.database.room import APIUploadedRoom, Room, RoomResp
from app.database.room_participated_user import RoomParticipatedUser
from app.database.score import Score
from app.dependencies.database import get_db, get_redis
from app.dependencies.user import get_current_user
from app.models.room import RoomCategory, RoomStatus
from app.service.room import create_playlist_room_from_api
from app.signalr.hub import MultiplayerHubs
from .router import router
from fastapi import Depends, HTTPException, Query, Security
from pydantic import BaseModel, Field
from redis.asyncio import Redis
from sqlalchemy.sql.elements import ColumnElement
from sqlmodel import col, exists, select
from sqlmodel.ext.asyncio.session import AsyncSession
@router.get("/rooms", tags=["rooms"], response_model=list[RoomResp])
async def get_all_rooms(
mode: Literal["open", "ended", "participated", "owned", None] = Query(
default="open"
),
category: RoomCategory = Query(RoomCategory.NORMAL),
status: RoomStatus | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Security(get_current_user, scopes=["public"]),
):
resp_list: list[RoomResp] = []
where_clauses: list[ColumnElement[bool]] = [col(Room.category) == category]
now = datetime.now(UTC)
if status is not None:
where_clauses.append(col(Room.status) == status)
if mode == "open":
where_clauses.append(
(col(Room.ends_at).is_(None))
| (col(Room.ends_at) > now.replace(tzinfo=UTC))
)
if category == RoomCategory.REALTIME:
where_clauses.append(col(Room.id).in_(MultiplayerHubs.rooms.keys()))
if mode == "participated":
where_clauses.append(
exists().where(
col(RoomParticipatedUser.room_id) == Room.id,
col(RoomParticipatedUser.user_id) == current_user.id,
)
)
if mode == "owned":
where_clauses.append(col(Room.host_id) == current_user.id)
if mode == "ended":
where_clauses.append(
(col(Room.ends_at).is_not(None))
& (col(Room.ends_at) < now.replace(tzinfo=UTC))
)
db_rooms = (
(
await db.exec(
select(Room).where(
*where_clauses,
)
)
)
.unique()
.all()
)
for room in db_rooms:
resp = await RoomResp.from_db(room, db)
if category == RoomCategory.REALTIME:
mp_room = MultiplayerHubs.rooms.get(room.id)
resp.has_password = (
bool(mp_room.room.settings.password.strip())
if mp_room is not None
else False
)
resp.category = RoomCategory.NORMAL
resp_list.append(resp)
return resp_list
class APICreatedRoom(RoomResp):
error: str = ""
async def _participate_room(
room_id: int, user_id: int, db_room: Room, session: AsyncSession
):
participated_user = (
await session.exec(
select(RoomParticipatedUser).where(
RoomParticipatedUser.room_id == room_id,
RoomParticipatedUser.user_id == user_id,
)
)
).first()
if participated_user is None:
participated_user = RoomParticipatedUser(
room_id=room_id,
user_id=user_id,
joined_at=datetime.now(UTC),
)
session.add(participated_user)
else:
participated_user.left_at = None
participated_user.joined_at = datetime.now(UTC)
db_room.participant_count += 1
@router.post("/rooms", tags=["room"], response_model=APICreatedRoom)
async def create_room(
room: APIUploadedRoom,
db: AsyncSession = Depends(get_db),
current_user: User = Security(get_current_user, scopes=["*"]),
):
user_id = current_user.id
db_room = await create_playlist_room_from_api(db, room, user_id)
await _participate_room(db_room.id, user_id, db_room, db)
# await db.commit()
# await db.refresh(db_room)
created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db))
created_room.error = ""
return created_room
@router.get("/rooms/{room}", tags=["room"], response_model=RoomResp)
async def get_room(
room: int,
category: str = Query(default=""),
db: AsyncSession = Depends(get_db),
current_user: User = Security(get_current_user, scopes=["*"]),
redis: Redis = Depends(get_redis),
):
# 直接从db获取信息毕竟都一样
db_room = (await db.exec(select(Room).where(Room.id == room))).first()
if db_room is None:
raise HTTPException(404, "Room not found")
resp = await RoomResp.from_db(
db_room, include=["current_user_score"], session=db, user=current_user
)
return resp
@router.delete("/rooms/{room}", tags=["room"])
async def delete_room(
room: int,
db: AsyncSession = Depends(get_db),
current_user: User = Security(get_current_user, scopes=["*"]),
):
db_room = (await db.exec(select(Room).where(Room.id == room))).first()
if db_room is None:
raise HTTPException(404, "Room not found")
else:
db_room.ends_at = datetime.now(UTC)
await db.commit()
return None
@router.put("/rooms/{room}/users/{user}", tags=["room"])
async def add_user_to_room(
room: int,
user: int,
db: AsyncSession = Depends(get_db),
current_user: User = Security(get_current_user, scopes=["*"]),
):
db_room = (await db.exec(select(Room).where(Room.id == room))).first()
if db_room is not None:
await _participate_room(room, user, db_room, db)
await db.commit()
await db.refresh(db_room)
resp = await RoomResp.from_db(db_room, db)
return resp
else:
raise HTTPException(404, "room not found0")
@router.delete("/rooms/{room}/users/{user}", tags=["room"])
async def remove_user_from_room(
room: int,
user: int,
db: AsyncSession = Depends(get_db),
current_user: User = Security(get_current_user, scopes=["*"]),
):
db_room = (await db.exec(select(Room).where(Room.id == room))).first()
if db_room is not None:
participated_user = (
await db.exec(
select(RoomParticipatedUser).where(
RoomParticipatedUser.room_id == room,
RoomParticipatedUser.user_id == user,
)
)
).first()
if participated_user is not None:
participated_user.left_at = datetime.now(UTC)
db_room.participant_count -= 1
await db.commit()
return None
else:
raise HTTPException(404, "Room not found")
class APILeaderboard(BaseModel):
leaderboard: list[ItemAttemptsResp] = Field(default_factory=list)
user_score: ItemAttemptsResp | None = None
@router.get("/rooms/{room}/leaderboard", tags=["room"], response_model=APILeaderboard)
async def get_room_leaderboard(
room: int,
db: AsyncSession = Depends(get_db),
current_user: User = Security(get_current_user, scopes=["public"]),
):
db_room = (await db.exec(select(Room).where(Room.id == room))).first()
if db_room is None:
raise HTTPException(404, "Room not found")
aggs = await db.exec(
select(ItemAttemptsCount)
.where(ItemAttemptsCount.room_id == room)
.order_by(col(ItemAttemptsCount.total_score).desc())
)
aggs_resp = []
user_agg = None
for i, agg in enumerate(aggs):
resp = await ItemAttemptsResp.from_db(agg, db)
resp.position = i + 1
# resp.accuracy *= 100
aggs_resp.append(resp)
if agg.user_id == current_user.id:
user_agg = resp
return APILeaderboard(
leaderboard=aggs_resp,
user_score=user_agg,
)
class RoomEvents(BaseModel):
beatmaps: list[BeatmapResp] = Field(default_factory=list)
beatmapsets: dict[int, BeatmapsetResp] = Field(default_factory=dict)
current_playlist_item_id: int = 0
events: list[MultiplayerEventResp] = Field(default_factory=list)
first_event_id: int = 0
last_event_id: int = 0
playlist_items: list[PlaylistResp] = Field(default_factory=list)
room: RoomResp
user: list[UserResp] = Field(default_factory=list)
@router.get("/rooms/{room_id}/events", response_model=RoomEvents, tags=["room"])
async def get_room_events(
room_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Security(get_current_user, scopes=["public"]),
limit: int = Query(100, ge=1, le=1000),
after: int | None = Query(None, ge=0),
before: int | None = Query(None, ge=0),
):
events = (
await db.exec(
select(MultiplayerEvent)
.where(
MultiplayerEvent.room_id == room_id,
col(MultiplayerEvent.id) > after if after is not None else True,
col(MultiplayerEvent.id) < before if before is not None else True,
)
.order_by(col(MultiplayerEvent.id).desc())
.limit(limit)
)
).all()
user_ids = set()
playlist_items = {}
beatmap_ids = set()
event_resps = []
first_event_id = 0
last_event_id = 0
current_playlist_item_id = 0
for event in events:
event_resps.append(MultiplayerEventResp.from_db(event))
if event.user_id:
user_ids.add(event.user_id)
if event.playlist_item_id is not None and (
playitem := (
await db.exec(
select(Playlist).where(
Playlist.id == event.playlist_item_id,
Playlist.room_id == room_id,
)
)
).first()
):
current_playlist_item_id = playitem.id
playlist_items[event.playlist_item_id] = playitem
beatmap_ids.add(playitem.beatmap_id)
scores = await db.exec(
select(Score).where(
Score.playlist_item_id == event.playlist_item_id,
Score.room_id == room_id,
)
)
for score in scores:
user_ids.add(score.user_id)
beatmap_ids.add(score.beatmap_id)
assert event.id is not None
first_event_id = min(first_event_id, event.id)
last_event_id = max(last_event_id, event.id)
if room := MultiplayerHubs.rooms.get(room_id):
current_playlist_item_id = room.queue.current_item.id
room_resp = await RoomResp.from_hub(room)
else:
room = (await db.exec(select(Room).where(Room.id == room_id))).first()
if room is None:
raise HTTPException(404, "Room not found")
room_resp = await RoomResp.from_db(room, db)
users = await db.exec(select(User).where(col(User.id).in_(user_ids)))
user_resps = [await UserResp.from_db(user, db) for user in users]
beatmaps = await db.exec(select(Beatmap).where(col(Beatmap.id).in_(beatmap_ids)))
beatmap_resps = [
await BeatmapResp.from_db(beatmap, session=db) for beatmap in beatmaps
]
beatmapset_resps = {}
for beatmap_resp in beatmap_resps:
beatmapset_resps[beatmap_resp.beatmapset_id] = beatmap_resp.beatmapset
playlist_items_resps = [
await PlaylistResp.from_db(item) for item in playlist_items.values()
]
return RoomEvents(
beatmaps=beatmap_resps,
beatmapsets=beatmapset_resps,
current_playlist_item_id=current_playlist_item_id,
events=event_resps,
first_event_id=first_event_id,
last_event_id=last_event_id,
playlist_items=playlist_items_resps,
room=room_resp,
user=user_resps,
)

5
app/router/v2/router.py Normal file
View File

@@ -0,0 +1,5 @@
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/api/v2")

761
app/router/v2/score.py Normal file
View File

@@ -0,0 +1,761 @@
from __future__ import annotations
from datetime import UTC, date, datetime
import time
from app.calculator import clamp
from app.config import settings
from app.database import (
Beatmap,
Playlist,
Room,
Score,
ScoreResp,
ScoreToken,
ScoreTokenResp,
User,
)
from app.database.counts import ReplayWatchedCount
from app.database.playlist_attempts import ItemAttemptsCount
from app.database.playlist_best_score import (
PlaylistBestScore,
get_position,
process_playlist_best_score,
)
from app.database.relationship import Relationship, RelationshipType
from app.database.score import (
MultiplayerScores,
ScoreAround,
get_leaderboard,
process_score,
process_user,
)
from app.dependencies.database import get_db, get_redis
from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user
from app.fetcher import Fetcher
from app.models.room import RoomCategory
from app.models.score import (
INT_TO_MODE,
GameMode,
LeaderboardType,
Rank,
SoloScoreSubmissionInfo,
)
from app.path import REPLAY_DIR
from .router import router
from fastapi import Body, Depends, Form, HTTPException, Query, Security
from fastapi.responses import FileResponse
from httpx import HTTPError
from pydantic import BaseModel
from redis.asyncio import Redis
from sqlalchemy.orm import joinedload
from sqlmodel import col, exists, func, select
from sqlmodel.ext.asyncio.session import AsyncSession
READ_SCORE_TIMEOUT = 10
async def submit_score(
info: SoloScoreSubmissionInfo,
beatmap: int,
token: int,
current_user: User,
db: AsyncSession,
redis: Redis,
fetcher: Fetcher,
item_id: int | None = None,
room_id: int | None = None,
):
if not info.passed:
info.rank = Rank.F
score_token = (
await db.exec(
select(ScoreToken)
.options(joinedload(ScoreToken.beatmap)) # pyright: ignore[reportArgumentType]
.where(ScoreToken.id == token)
)
).first()
if not score_token or score_token.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Score token not found")
if score_token.score_id:
score = (
await db.exec(
select(Score).where(
Score.id == score_token.score_id,
Score.user_id == current_user.id,
)
)
).first()
if not score:
raise HTTPException(status_code=404, detail="Score not found")
else:
try:
db_beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap)
except HTTPError:
raise HTTPException(status_code=404, detail="Beatmap not found")
ranked = (
db_beatmap.beatmap_status.has_pp() | settings.enable_all_beatmap_leaderboard
)
beatmap_length = db_beatmap.total_length
score = await process_score(
current_user,
beatmap,
ranked,
score_token,
info,
fetcher,
db,
redis,
item_id,
room_id,
)
await db.refresh(current_user)
score_id = score.id
score_token.score_id = score_id
await process_user(db, current_user, score, beatmap_length, ranked)
score = (await db.exec(select(Score).where(Score.id == score_id))).first()
assert score is not None
return await ScoreResp.from_db(db, score)
class BeatmapScores(BaseModel):
scores: list[ScoreResp]
userScore: ScoreResp | None = None
@router.get(
"/beatmaps/{beatmap}/scores", tags=["beatmap"], response_model=BeatmapScores
)
async def get_beatmap_scores(
beatmap: int,
mode: GameMode,
legacy_only: bool = Query(None), # TODO:加入对这个参数的查询
mods: list[str] = Query(default_factory=set, alias="mods[]"),
type: LeaderboardType = Query(LeaderboardType.GLOBAL),
current_user: User = Security(get_current_user, scopes=["public"]),
db: AsyncSession = Depends(get_db),
limit: int = Query(50, ge=1, le=200),
):
if legacy_only:
raise HTTPException(
status_code=404, detail="this server only contains lazer scores"
)
all_scores, user_score = await get_leaderboard(
db, beatmap, mode, type=type, user=current_user, limit=limit, mods=mods
)
return BeatmapScores(
scores=[await ScoreResp.from_db(db, score) for score in all_scores],
userScore=await ScoreResp.from_db(db, user_score) if user_score else None,
)
class BeatmapUserScore(BaseModel):
position: int
score: ScoreResp
@router.get(
"/beatmaps/{beatmap}/scores/users/{user}",
tags=["beatmap"],
response_model=BeatmapUserScore,
)
async def get_user_beatmap_score(
beatmap: int,
user: int,
legacy_only: bool = Query(None),
mode: str = Query(None),
mods: str = Query(None), # TODO:添加mods筛选
current_user: User = Security(get_current_user, scopes=["public"]),
db: AsyncSession = Depends(get_db),
):
if legacy_only:
raise HTTPException(
status_code=404, detail="This server only contains non-legacy scores"
)
user_score = (
await db.exec(
select(Score)
.where(
Score.gamemode == mode if mode is not None else True,
Score.beatmap_id == beatmap,
Score.user_id == user,
)
.order_by(col(Score.total_score).desc())
)
).first()
if not user_score:
raise HTTPException(
status_code=404, detail=f"Cannot find user {user}'s score on this beatmap"
)
else:
resp = await ScoreResp.from_db(db, user_score)
return BeatmapUserScore(
position=resp.rank_global or 0,
score=resp,
)
@router.get(
"/beatmaps/{beatmap}/scores/users/{user}/all",
tags=["beatmap"],
response_model=list[ScoreResp],
)
async def get_user_all_beatmap_scores(
beatmap: int,
user: int,
legacy_only: bool = Query(None),
ruleset: str = Query(None),
current_user: User = Security(get_current_user, scopes=["public"]),
db: AsyncSession = Depends(get_db),
):
if legacy_only:
raise HTTPException(
status_code=404, detail="This server only contains non-legacy scores"
)
all_user_scores = (
await db.exec(
select(Score)
.where(
Score.gamemode == ruleset if ruleset is not None else True,
Score.beatmap_id == beatmap,
Score.user_id == user,
)
.order_by(col(Score.classic_total_score).desc())
)
).all()
return [await ScoreResp.from_db(db, score) for score in all_user_scores]
@router.post(
"/beatmaps/{beatmap}/solo/scores", tags=["beatmap"], response_model=ScoreTokenResp
)
async def create_solo_score(
beatmap: int,
version_hash: str = Form(""),
beatmap_hash: str = Form(),
ruleset_id: int = Form(..., ge=0, le=3),
current_user: User = Security(get_current_user, scopes=["*"]),
db: AsyncSession = Depends(get_db),
):
assert current_user.id
async with db:
score_token = ScoreToken(
user_id=current_user.id,
beatmap_id=beatmap,
ruleset_id=INT_TO_MODE[ruleset_id],
)
db.add(score_token)
await db.commit()
await db.refresh(score_token)
return ScoreTokenResp.from_db(score_token)
@router.put(
"/beatmaps/{beatmap}/solo/scores/{token}",
tags=["beatmap"],
response_model=ScoreResp,
)
async def submit_solo_score(
beatmap: int,
token: int,
info: SoloScoreSubmissionInfo,
current_user: User = Security(get_current_user, scopes=["*"]),
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
fetcher=Depends(get_fetcher),
):
return await submit_score(info, beatmap, token, current_user, db, redis, fetcher)
@router.post(
"/rooms/{room_id}/playlist/{playlist_id}/scores", response_model=ScoreTokenResp
)
async def create_playlist_score(
room_id: int,
playlist_id: int,
beatmap_id: int = Form(),
beatmap_hash: str = Form(),
ruleset_id: int = Form(..., ge=0, le=3),
version_hash: str = Form(""),
current_user: User = Security(get_current_user, scopes=["*"]),
session: AsyncSession = Depends(get_db),
):
room = await session.get(Room, room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
db_room_time = room.ends_at.replace(tzinfo=UTC) if room.ends_at else None
if db_room_time and db_room_time < datetime.now(UTC).replace(tzinfo=UTC):
raise HTTPException(status_code=400, detail="Room has ended")
item = (
await session.exec(
select(Playlist).where(
Playlist.id == playlist_id, Playlist.room_id == room_id
)
)
).first()
if not item:
raise HTTPException(status_code=404, detail="Playlist not found")
# validate
if not item.freestyle:
if item.ruleset_id != ruleset_id:
raise HTTPException(
status_code=400, detail="Ruleset mismatch in playlist item"
)
if item.beatmap_id != beatmap_id:
raise HTTPException(
status_code=400, detail="Beatmap ID mismatch in playlist item"
)
agg = await session.exec(
select(ItemAttemptsCount).where(
ItemAttemptsCount.room_id == room_id,
ItemAttemptsCount.user_id == current_user.id,
)
)
agg = agg.first()
if agg and room.max_attempts and agg.attempts >= room.max_attempts:
raise HTTPException(
status_code=422,
detail="You have reached the maximum attempts for this room",
)
if item.expired:
raise HTTPException(status_code=400, detail="Playlist item has expired")
if item.played_at:
raise HTTPException(
status_code=400, detail="Playlist item has already been played"
)
# 这里应该不用验证mod了吧。。。
score_token = ScoreToken(
user_id=current_user.id,
beatmap_id=beatmap_id,
ruleset_id=INT_TO_MODE[ruleset_id],
playlist_item_id=playlist_id,
)
session.add(score_token)
await session.commit()
await session.refresh(score_token)
return ScoreTokenResp.from_db(score_token)
@router.put("/rooms/{room_id}/playlist/{playlist_id}/scores/{token}")
async def submit_playlist_score(
room_id: int,
playlist_id: int,
token: int,
info: SoloScoreSubmissionInfo,
current_user: User = Security(get_current_user, scopes=["*"]),
session: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
fetcher: Fetcher = Depends(get_fetcher),
):
item = (
await session.exec(
select(Playlist).where(
Playlist.id == playlist_id, Playlist.room_id == room_id
)
)
).first()
if not item:
raise HTTPException(status_code=404, detail="Playlist item not found")
user_id = current_user.id
score_resp = await submit_score(
info,
item.beatmap_id,
token,
current_user,
session,
redis,
fetcher,
item.id,
room_id,
)
await process_playlist_best_score(
room_id,
playlist_id,
user_id,
score_resp.id,
score_resp.total_score,
session,
redis,
)
await ItemAttemptsCount.get_or_create(room_id, user_id, session)
return score_resp
class IndexedScoreResp(MultiplayerScores):
total: int
user_score: ScoreResp | None = None
@router.get(
"/rooms/{room_id}/playlist/{playlist_id}/scores", response_model=IndexedScoreResp
)
async def index_playlist_scores(
room_id: int,
playlist_id: int,
limit: int = 50,
cursor: int = Query(2000000, alias="cursor[total_score]"),
current_user: User = Security(get_current_user, scopes=["public"]),
session: AsyncSession = Depends(get_db),
):
room = await session.get(Room, room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
limit = clamp(limit, 1, 50)
scores = (
await session.exec(
select(PlaylistBestScore)
.where(
PlaylistBestScore.playlist_id == playlist_id,
PlaylistBestScore.room_id == room_id,
PlaylistBestScore.total_score < cursor,
)
.order_by(col(PlaylistBestScore.total_score).desc())
.limit(limit + 1)
)
).all()
has_more = len(scores) > limit
if has_more:
scores = scores[:-1]
user_score = None
score_resp = [await ScoreResp.from_db(session, score.score) for score in scores]
for score in score_resp:
score.position = await get_position(room_id, playlist_id, score.id, session)
if score.user_id == current_user.id:
user_score = score
if room.category == RoomCategory.DAILY_CHALLENGE:
score_resp = [s for s in score_resp if s.passed]
if user_score and not user_score.passed:
user_score = None
resp = IndexedScoreResp(
scores=score_resp,
user_score=user_score,
total=len(scores),
params={
"limit": limit,
},
)
if has_more:
resp.cursor = {
"total_score": scores[-1].total_score,
}
return resp
@router.get(
"/rooms/{room_id}/playlist/{playlist_id}/scores/{score_id}",
response_model=ScoreResp,
)
async def show_playlist_score(
room_id: int,
playlist_id: int,
score_id: int,
current_user: User = Security(get_current_user, scopes=["*"]),
session: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
):
room = await session.get(Room, room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
start_time = time.time()
score_record = None
completed = room.category != RoomCategory.REALTIME
while time.time() - start_time < READ_SCORE_TIMEOUT:
if score_record is None:
score_record = (
await session.exec(
select(PlaylistBestScore).where(
PlaylistBestScore.score_id == score_id,
PlaylistBestScore.playlist_id == playlist_id,
PlaylistBestScore.room_id == room_id,
)
)
).first()
if completed_players := await redis.get(
f"multiplayer:{room_id}:gameplay:players"
):
completed = completed_players == "0"
if score_record and completed:
break
if not score_record:
raise HTTPException(status_code=404, detail="Score not found")
resp = await ScoreResp.from_db(session, score_record.score)
resp.position = await get_position(room_id, playlist_id, score_id, session)
if completed:
scores = (
await session.exec(
select(PlaylistBestScore).where(
PlaylistBestScore.playlist_id == playlist_id,
PlaylistBestScore.room_id == room_id,
)
)
).all()
higher_scores = []
lower_scores = []
for score in scores:
if score.total_score > resp.total_score:
higher_scores.append(await ScoreResp.from_db(session, score.score))
elif score.total_score < resp.total_score:
lower_scores.append(await ScoreResp.from_db(session, score.score))
resp.scores_around = ScoreAround(
higher=MultiplayerScores(scores=higher_scores),
lower=MultiplayerScores(scores=lower_scores),
)
return resp
@router.get(
"rooms/{room_id}/playlist/{playlist_id}/scores/users/{user_id}",
response_model=ScoreResp,
)
async def get_user_playlist_score(
room_id: int,
playlist_id: int,
user_id: int,
current_user: User = Security(get_current_user, scopes=["*"]),
session: AsyncSession = Depends(get_db),
):
score_record = None
start_time = time.time()
while time.time() - start_time < READ_SCORE_TIMEOUT:
score_record = (
await session.exec(
select(PlaylistBestScore).where(
PlaylistBestScore.user_id == user_id,
PlaylistBestScore.playlist_id == playlist_id,
PlaylistBestScore.room_id == room_id,
)
)
).first()
if score_record:
break
if not score_record:
raise HTTPException(status_code=404, detail="Score not found")
resp = await ScoreResp.from_db(session, score_record.score)
resp.position = await get_position(
room_id, playlist_id, score_record.score_id, session
)
return resp
@router.put("/score-pins/{score}", status_code=204)
async def pin_score(
score: int,
current_user: User = Security(get_current_user, scopes=["*"]),
db: AsyncSession = Depends(get_db),
):
score_record = (
await db.exec(
select(Score).where(
Score.id == score,
Score.user_id == current_user.id,
col(Score.passed).is_(True),
)
)
).first()
if not score_record:
raise HTTPException(status_code=404, detail="Score not found")
if score_record.pinned_order > 0:
return
next_order = (
(
await db.exec(
select(func.max(Score.pinned_order)).where(
Score.user_id == current_user.id,
Score.gamemode == score_record.gamemode,
)
)
).first()
or 0
) + 1
score_record.pinned_order = next_order
await db.commit()
@router.delete("/score-pins/{score}", status_code=204)
async def unpin_score(
score: int,
current_user: User = Security(get_current_user, scopes=["*"]),
db: AsyncSession = Depends(get_db),
):
score_record = (
await db.exec(
select(Score).where(Score.id == score, Score.user_id == current_user.id)
)
).first()
if not score_record:
raise HTTPException(status_code=404, detail="Score not found")
if score_record.pinned_order == 0:
return
changed_score = (
await db.exec(
select(Score).where(
Score.user_id == current_user.id,
Score.pinned_order > score_record.pinned_order,
Score.gamemode == score_record.gamemode,
)
)
).all()
for s in changed_score:
s.pinned_order -= 1
await db.commit()
@router.post("/score-pins/{score}/reorder", status_code=204)
async def reorder_score_pin(
score: int,
after_score_id: int | None = Body(default=None),
before_score_id: int | None = Body(default=None),
current_user: User = Security(get_current_user, scopes=["*"]),
db: AsyncSession = Depends(get_db),
):
score_record = (
await db.exec(
select(Score).where(Score.id == score, Score.user_id == current_user.id)
)
).first()
if not score_record:
raise HTTPException(status_code=404, detail="Score not found")
if score_record.pinned_order == 0:
raise HTTPException(status_code=400, detail="Score is not pinned")
if (after_score_id is None) == (before_score_id is None):
raise HTTPException(
status_code=400,
detail="Either after_score_id or before_score_id "
"must be provided (but not both)",
)
all_pinned_scores = (
await db.exec(
select(Score)
.where(
Score.user_id == current_user.id,
Score.pinned_order > 0,
Score.gamemode == score_record.gamemode,
)
.order_by(col(Score.pinned_order))
)
).all()
target_order = None
reference_score_id = after_score_id or before_score_id
reference_score = next(
(s for s in all_pinned_scores if s.id == reference_score_id), None
)
if not reference_score:
detail = "After score not found" if after_score_id else "Before score not found"
raise HTTPException(status_code=404, detail=detail)
if after_score_id:
target_order = reference_score.pinned_order + 1
else:
target_order = reference_score.pinned_order
current_order = score_record.pinned_order
if current_order == target_order:
return
updates = []
if current_order < target_order:
for s in all_pinned_scores:
if current_order < s.pinned_order <= target_order and s.id != score:
updates.append((s.id, s.pinned_order - 1))
if after_score_id:
final_target = (
target_order - 1 if target_order > current_order else target_order
)
else:
final_target = target_order
else:
for s in all_pinned_scores:
if target_order <= s.pinned_order < current_order and s.id != score:
updates.append((s.id, s.pinned_order + 1))
final_target = target_order
for score_id, new_order in updates:
await db.exec(select(Score).where(Score.id == score_id))
score_to_update = (
await db.exec(select(Score).where(Score.id == score_id))
).first()
if score_to_update:
score_to_update.pinned_order = new_order
score_record.pinned_order = final_target
await db.commit()
@router.get("/scores/{score_id}/download")
async def download_score_replay(
score_id: int,
current_user: User = Security(get_current_user, scopes=["public"]),
db: AsyncSession = Depends(get_db),
):
score = (await db.exec(select(Score).where(Score.id == score_id))).first()
if not score:
raise HTTPException(status_code=404, detail="Score not found")
filename = f"{score.id}_{score.beatmap_id}_{score.user_id}_lazer_replay.osr"
path = REPLAY_DIR / filename
if not path.exists():
raise HTTPException(status_code=404, detail="Replay file not found")
is_friend = (
score.user_id == current_user.id
or (
await db.exec(
select(exists()).where(
Relationship.user_id == current_user.id,
Relationship.target_id == score.user_id,
Relationship.type == RelationshipType.FOLLOW,
)
)
).first()
)
if not is_friend:
replay_watched_count = (
await db.exec(
select(ReplayWatchedCount).where(
ReplayWatchedCount.user_id == score.user_id,
ReplayWatchedCount.year == date.today().year,
ReplayWatchedCount.month == date.today().month,
)
)
).first()
if replay_watched_count is None:
replay_watched_count = ReplayWatchedCount(
user_id=score.user_id, year=date.today().year, month=date.today().month
)
db.add(replay_watched_count)
replay_watched_count.count += 1
await db.commit()
return FileResponse(
path=path, filename=filename, media_type="application/x-osu-replay"
)

195
app/router/v2/user.py Normal file
View File

@@ -0,0 +1,195 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Literal
from app.database import (
BeatmapPlaycounts,
BeatmapPlaycountsResp,
BeatmapsetResp,
User,
UserResp,
)
from app.database.lazer_user import SEARCH_INCLUDED
from app.database.pp_best_score import PPBestScore
from app.database.score import Score, ScoreResp
from app.dependencies.database import get_db
from app.dependencies.user import get_current_user
from app.models.score import GameMode
from app.models.user import BeatmapsetType
from .router import router
from fastapi import Depends, HTTPException, Query, Security
from pydantic import BaseModel
from sqlmodel import exists, false, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import col
class BatchUserResponse(BaseModel):
users: list[UserResp]
@router.get("/users", response_model=BatchUserResponse)
@router.get("/users/lookup", response_model=BatchUserResponse)
@router.get("/users/lookup/", response_model=BatchUserResponse)
async def get_users(
user_ids: list[int] = Query(default_factory=list, alias="ids[]"),
current_user: User = Security(get_current_user, scopes=["public"]),
include_variant_statistics: bool = Query(default=False), # TODO: future use
session: AsyncSession = Depends(get_db),
):
if user_ids:
searched_users = (
await session.exec(select(User).limit(50).where(col(User.id).in_(user_ids)))
).all()
else:
searched_users = (await session.exec(select(User).limit(50))).all()
return BatchUserResponse(
users=[
await UserResp.from_db(
searched_user,
session,
include=SEARCH_INCLUDED,
)
for searched_user in searched_users
]
)
@router.get("/users/{user}/{ruleset}", response_model=UserResp)
@router.get("/users/{user}/", response_model=UserResp)
@router.get("/users/{user}", response_model=UserResp)
async def get_user_info(
user: str,
ruleset: GameMode | None = None,
session: AsyncSession = Depends(get_db),
current_user: User = Security(get_current_user, scopes=["public"]),
):
searched_user = (
await session.exec(
select(User).where(
User.id == int(user)
if user.isdigit()
else User.username == user.removeprefix("@")
)
)
).first()
if not searched_user:
raise HTTPException(404, detail="User not found")
return await UserResp.from_db(
searched_user,
session,
include=SEARCH_INCLUDED,
ruleset=ruleset,
)
@router.get(
"/users/{user_id}/beatmapsets/{type}",
response_model=list[BeatmapsetResp | BeatmapPlaycountsResp],
)
async def get_user_beatmapsets(
user_id: int,
type: BeatmapsetType,
current_user: User = Security(get_current_user, scopes=["public"]),
session: AsyncSession = Depends(get_db),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
):
if type in {
BeatmapsetType.GRAVEYARD,
BeatmapsetType.GUEST,
BeatmapsetType.LOVED,
BeatmapsetType.NOMINATED,
BeatmapsetType.PENDING,
BeatmapsetType.RANKED,
}:
# TODO: mapping, modding
resp = []
elif type == BeatmapsetType.FAVOURITE:
user = await session.get(User, user_id)
if not user:
raise HTTPException(404, detail="User not found")
favourites = await user.awaitable_attrs.favourite_beatmapsets
resp = [
await BeatmapsetResp.from_db(
favourite.beatmapset, session=session, user=current_user
)
for favourite in favourites
]
elif type == BeatmapsetType.MOST_PLAYED:
most_played = await session.exec(
select(BeatmapPlaycounts)
.where(BeatmapPlaycounts.user_id == user_id)
.order_by(col(BeatmapPlaycounts.playcount).desc())
.limit(limit)
.offset(offset)
)
resp = [
await BeatmapPlaycountsResp.from_db(most_played_beatmap)
for most_played_beatmap in most_played
]
else:
raise HTTPException(400, detail="Invalid beatmapset type")
return resp
@router.get("/users/{user}/scores/{type}", response_model=list[ScoreResp])
async def get_user_scores(
user: int,
type: Literal["best", "recent", "firsts", "pinned"],
legacy_only: bool = Query(False),
include_fails: bool = Query(False),
mode: GameMode | None = None,
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_db),
current_user: User = Security(get_current_user, scopes=["public"]),
):
db_user = await session.get(User, user)
if not db_user:
raise HTTPException(404, detail="User not found")
gamemode = mode or db_user.playmode
order_by = None
where_clause = (col(Score.user_id) == db_user.id) & (
col(Score.gamemode) == gamemode
)
if not include_fails:
where_clause &= col(Score.passed).is_(True)
if type == "pinned":
where_clause &= Score.pinned_order > 0
order_by = col(Score.pinned_order).asc()
elif type == "best":
where_clause &= exists().where(col(PPBestScore.score_id) == Score.id)
order_by = col(Score.pp).desc()
elif type == "recent":
where_clause &= Score.ended_at > datetime.now(UTC) - timedelta(hours=24)
order_by = col(Score.ended_at).desc()
elif type == "firsts":
# TODO
where_clause &= false()
scores = (
await session.exec(
select(Score)
.where(where_clause)
.order_by(order_by)
.limit(limit)
.offset(offset)
)
).all()
if not scores:
return []
return [
await ScoreResp.from_db(
session,
score,
)
for score in scores
]