feat(beatmap,beatmapset): get beatmap & beatmapset information by id

This commit is contained in:
MingxuanGame
2025-07-25 17:01:01 +08:00
parent f8abc7067f
commit 376d98ad5a
12 changed files with 452 additions and 9 deletions

View File

@@ -1,6 +1,14 @@
from __future__ import annotations
from .auth import OAuthToken
from .beatmap import (
Beatmap as Beatmap,
BeatmapResp as BeatmapResp,
)
from .beatmapset import (
Beatmapset as Beatmapset,
BeatmapsetResp as BeatmapsetResp,
)
from .legacy import LegacyOAuthToken, LegacyUserStatistics
from .team import Team, TeamMember
from .user import (
@@ -23,7 +31,13 @@ from .user import (
UserAvatar,
)
BeatmapsetResp.model_rebuild()
BeatmapResp.model_rebuild()
__all__ = [
"Beatmap",
"BeatmapResp",
"Beatmapset",
"BeatmapsetResp",
"DailyChallengeStats",
"LazerUserAchievement",
"LazerUserBadge",

94
app/database/beatmap.py Normal file
View File

@@ -0,0 +1,94 @@
# ruff: noqa: I002
from datetime import datetime
from app.models.beatmap import BeatmapRankStatus
from app.models.score import MODE_TO_INT, GameMode
from .beatmapset import Beatmapset, BeatmapsetResp
from sqlalchemy import DECIMAL, Column, DateTime
from sqlmodel import VARCHAR, Field, Relationship, SQLModel
class BeatmapOwner(SQLModel):
id: int
username: str
class BeatmapBase(SQLModel):
# Beatmap
url: str
mode: GameMode
beatmapset_id: int = Field(foreign_key="beatmapsets.id", index=True)
difficulty_rating: float = Field(
default=0.0, sa_column=Column(DECIMAL(precision=10, scale=6))
)
beatmap_status: BeatmapRankStatus
total_length: int
user_id: int
version: str
# optional
checksum: str = Field(sa_column=Column(VARCHAR(32), index=True))
current_user_playcount: int = Field(default=0)
max_combo: int = Field(default=0)
# TODO: failtimes, owners
# BeatmapExtended
ar: float = Field(default=0.0, sa_column=Column(DECIMAL(precision=10, scale=2)))
cs: float = Field(default=0.0, sa_column=Column(DECIMAL(precision=10, scale=2)))
drain: float = Field(
default=0.0,
sa_column=Column(DECIMAL(precision=10, scale=2)),
) # hp
accuracy: float = Field(
default=0.0,
sa_column=Column(DECIMAL(precision=10, scale=2)),
) # od
bpm: float = Field(default=0.0, sa_column=Column(DECIMAL(precision=10, scale=2)))
count_circles: int = Field(default=0)
count_sliders: int = Field(default=0)
count_spinners: int = Field(default=0)
deleted_at: datetime | None = Field(default=None, sa_column=Column(DateTime))
hit_length: int = Field(default=0)
last_updated: datetime = Field(sa_column=Column(DateTime))
passcount: int = Field(default=0)
playcount: int = Field(default=0)
class Beatmap(BeatmapBase, table=True):
__tablename__ = "beatmaps" # pyright: ignore[reportAssignmentType]
id: int | None = Field(default=None, primary_key=True, index=True)
beatmapset_id: int = Field(foreign_key="beatmapsets.id", index=True)
# optional
beatmapset: Beatmapset = Relationship(back_populates="beatmaps")
class BeatmapResp(BeatmapBase):
id: int
beatmapset_id: int
beatmapset: BeatmapsetResp | None = None
convert: bool = False
is_scoreable: bool
status: str
mode_int: int
ranked: int
url: str = ""
@classmethod
def from_db(
cls,
beatmap: Beatmap,
query_mode: GameMode | None = None,
from_set: bool = False,
) -> "BeatmapResp":
beatmap_ = beatmap.model_dump()
if query_mode is not None and beatmap.mode != query_mode:
beatmap_["convert"] = True
beatmap_["is_scoreable"] = beatmap.beatmap_status > BeatmapRankStatus.PENDING
beatmap_["status"] = beatmap.beatmap_status.name.lower()
beatmap_["ranked"] = beatmap.beatmap_status.value
beatmap_["mode_int"] = MODE_TO_INT[beatmap.mode]
if not from_set:
beatmap_["beatmapset"] = BeatmapsetResp.from_db(beatmap.beatmapset)
return cls.model_validate(beatmap_)

205
app/database/beatmapset.py Normal file
View File

@@ -0,0 +1,205 @@
# ruff: noqa: I002
from datetime import datetime
from typing import TYPE_CHECKING, cast
from app.models.beatmap import BeatmapRankStatus, Genre, Language
from app.models.score import GameMode
from pydantic import BaseModel, model_serializer
from sqlalchemy import DECIMAL, JSON, Column, DateTime, Text
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from .beatmap import Beatmap, BeatmapResp
class BeatmapCovers(SQLModel):
cover: str
card: str
list: str
slimcover: str
cover_2_x: str | None = Field(default=None, alias="cover@2x")
card_2_x: str | None = Field(default=None, alias="card@2x")
list_2_x: str | None = Field(default=None, alias="list@2x")
slimcover_2_x: str | None = Field(default=None, alias="slimcover@2x")
@model_serializer
def _(self) -> dict[str, str | None]:
self = cast(dict[str, str | None] | BeatmapCovers, self)
if isinstance(self, dict):
return {
"cover": self["cover"],
"card": self["card"],
"list": self["list"],
"slimcover": self["slimcover"],
"cover@2x": self.get("cover@2x"),
"card@2x": self.get("card@2x"),
"list@2x": self.get("list@2x"),
"slimcover@2x": self.get("slimcover@2x"),
}
else:
return {
"cover": self.cover,
"card": self.card,
"list": self.list,
"slimcover": self.slimcover,
"cover@2x": self.cover_2_x,
"card@2x": self.card_2_x,
"list@2x": self.list_2_x,
"slimcover@2x": self.slimcover_2_x,
}
class BeatmapHype(BaseModel):
current: int
required: int
class BeatmapAvailability(BaseModel):
more_information: str | None = Field(default=None)
download_disabled: bool | None = Field(default=None)
class BeatmapNominations(SQLModel):
current: int | None = Field(default=None)
required: int | None = Field(default=None)
class BeatmapNomination(SQLModel):
beatmapset_id: int
reset: bool
user_id: int
rulesets: list[GameMode] | None = None
class BeatmapDescription(SQLModel):
bbcode: str | None = None
description: str | None = None
class BeatmapTranslationText(BaseModel):
name: str
id: int | None = None
class BeatmapsetBase(SQLModel):
# Beatmapset
artist: str = Field(index=True)
artist_unicode: str = Field(index=True)
covers: BeatmapCovers = Field(sa_column=Column(JSON))
creator: str
favourite_count: int
nsfw: bool = Field(default=False)
play_count: int
preview_url: str
source: str = Field(default="")
spotlight: bool = Field(default=False)
title: str
title_unicode: str
user_id: int
video: bool
# optional
# converts: list[Beatmap] = Relationship(back_populates="beatmapset")
current_nominations: list[BeatmapNomination] | None = Field(
None, sa_column=Column(JSON)
)
description: BeatmapDescription | None = Field(default=None, sa_column=Column(JSON))
# TODO: discussions: list[BeatmapsetDiscussion] = None
# TODO: current_user_attributes: Optional[CurrentUserAttributes] = None
# TODO: events: Optional[list[BeatmapsetEvent]] = None
pack_tags: list[str] = Field(default=[], sa_column=Column(JSON))
ratings: list[int] = Field(default=None, sa_column=Column(JSON))
# TODO: recent_favourites: Optional[list[User]] = None
# TODO: related_users: Optional[list[User]] = None
# TODO: user: Optional[User] = Field(default=None)
track_id: int | None = Field(default=None) # feature artist?
# TODO: has_favourited
# BeatmapsetExtended
bpm: float = Field(default=0.0, sa_column=Column(DECIMAL(10, 2)))
can_be_hyped: bool = Field(default=False)
discussion_locked: bool = Field(default=False)
last_updated: datetime = Field(sa_column=Column(DateTime))
ranked_date: datetime | None = Field(default=None, sa_column=Column(DateTime))
storyboard: bool = Field(default=False)
submitted_date: datetime = Field(sa_column=Column(DateTime))
tags: str = Field(default="", sa_column=Column(Text))
class Beatmapset(BeatmapsetBase, table=True):
__tablename__ = "beatmapsets" # pyright: ignore[reportAssignmentType]
id: int | None = Field(default=None, primary_key=True, index=True)
# Beatmapset
beatmap_status: BeatmapRankStatus = Field(
default=BeatmapRankStatus.GRAVEYARD,
)
# optional
beatmaps: list["Beatmap"] = Relationship(back_populates="beatmapset")
beatmap_genre: Genre = Field(default=Genre.UNSPECIFIED)
beatmap_language: Language = Field(default=Language.UNSPECIFIED)
nominations_required: int = Field(default=0)
nominations_current: int = Field(default=0)
# BeatmapExtended
hype_current: int = Field(default=0)
hype_required: int = Field(default=0)
availability_info: str | None = Field(default=None)
download_disabled: bool = Field(default=False)
class BeatmapsetResp(BeatmapsetBase):
id: int
beatmaps: list["BeatmapResp"]
discussion_enabled: bool = True
status: str
ranked: int
legacy_thread_url: str = ""
is_scoreable: bool
hype: BeatmapHype
availability: BeatmapAvailability
genre: BeatmapTranslationText
language: BeatmapTranslationText
nominations: BeatmapNominations
@classmethod
def from_db(cls, beatmapset: Beatmapset) -> "BeatmapsetResp":
from .beatmap import BeatmapResp
beatmaps = [
BeatmapResp.from_db(beatmap, from_set=True)
for beatmap in beatmapset.beatmaps
]
return cls.model_validate(
{
"beatmaps": beatmaps,
"hype": BeatmapHype(
current=beatmapset.hype_current, required=beatmapset.hype_required
),
"availability": BeatmapAvailability(
more_information=beatmapset.availability_info,
download_disabled=beatmapset.download_disabled,
),
"genre": BeatmapTranslationText(
name=beatmapset.beatmap_genre.name,
id=beatmapset.beatmap_genre.value,
),
"language": BeatmapTranslationText(
name=beatmapset.beatmap_language.name,
id=beatmapset.beatmap_language.value,
),
"nominations": BeatmapNominations(
required=beatmapset.nominations_required,
current=beatmapset.nominations_current,
),
"status": beatmapset.beatmap_status.name.lower(),
"ranked": beatmapset.beatmap_status.value,
"is_scoreable": beatmapset.beatmap_status > BeatmapRankStatus.PENDING,
**beatmapset.model_dump(),
}
)

View File

@@ -8,7 +8,7 @@ from .team import TeamMember
from sqlalchemy import DECIMAL, JSON, Column, Date, DateTime, Text
from sqlalchemy.dialects.mysql import VARCHAR
from sqlmodel import Field, Relationship, SQLModel
from sqlmodel import BigInteger, Field, Relationship, SQLModel
class User(SQLModel, table=True):
@@ -263,10 +263,10 @@ class LazerUserStatistics(SQLModel, table=True):
# PP 和分数
pp: float = Field(default=0.00, sa_column=Column(DECIMAL(10, 2)))
pp_exp: float = Field(default=0.00, sa_column=Column(DECIMAL(10, 2)))
ranked_score: int = Field(default=0)
ranked_score: int = Field(default=0, sa_column=Column(BigInteger))
hit_accuracy: float = Field(default=0.00, sa_column=Column(DECIMAL(5, 2)))
total_score: int = Field(default=0)
total_hits: int = Field(default=0)
total_score: int = Field(default=0, sa_column=Column(BigInteger))
total_hits: int = Field(default=0, sa_column=Column(BigInteger))
maximum_combo: int = Field(default=0)
# 游戏统计
@@ -406,6 +406,7 @@ class UserAchievement:
achieved_at: datetime
achievement_id: int
class DailyChallengeStats(SQLModel, table=True):
__tablename__ = "daily_challenge_stats" # pyright: ignore[reportAssignmentType]
@@ -441,7 +442,6 @@ class RankHistory(SQLModel, table=True):
user: "User" = Relationship(back_populates="rank_history")
class UserAvatar(SQLModel, table=True):
__tablename__ = "user_avatars" # pyright: ignore[reportAssignmentType]