feat(beatmap,score): update beatmaps from Bancho & deleting scores (#50)

New API:

- DELETE /api/private/score/{score_id}: delete a score
- POST /api/private/beatmapsets/{beatmapset_id}/sync: request for syncing a beatmapset

New configuration:

- OLD_SCORE_PROCESSING_MODE
This commit is contained in:
MingxuanGame
2025-10-02 13:36:09 +08:00
committed by GitHub
parent 860ebe9fa9
commit 3f6776847e
22 changed files with 888 additions and 84 deletions

View File

@@ -5,6 +5,7 @@ from .beatmap import (
BeatmapResp,
)
from .beatmap_playcounts import BeatmapPlaycounts, BeatmapPlaycountsResp
from .beatmap_sync import BeatmapSync
from .beatmap_tags import BeatmapTagVote
from .beatmapset import (
Beatmapset,
@@ -76,6 +77,7 @@ __all__ = [
"BeatmapPlaycountsResp",
"BeatmapRating",
"BeatmapResp",
"BeatmapSync",
"BeatmapTagVote",
"Beatmapset",
"BeatmapsetResp",

View File

@@ -71,7 +71,7 @@ class Beatmap(BeatmapBase, table=True):
failtimes: FailTime | None = Relationship(back_populates="beatmap", sa_relationship_kwargs={"lazy": "joined"})
@classmethod
async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap":
async def from_resp_no_save(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap":
d = resp.model_dump()
del d["beatmapset"]
beatmap = Beatmap.model_validate(
@@ -82,11 +82,16 @@ class Beatmap(BeatmapBase, table=True):
"beatmap_status": BeatmapRankStatus(resp.ranked),
}
)
return beatmap
@classmethod
async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap":
beatmap = await cls.from_resp_no_save(session, resp)
if not (await session.exec(select(exists()).where(Beatmap.id == resp.id))).first():
session.add(beatmap)
await session.commit()
beatmap = (await session.exec(select(Beatmap).where(Beatmap.id == resp.id))).first()
assert beatmap is not None, "Beatmap should not be None after commit"
beatmap = (await session.exec(select(Beatmap).where(Beatmap.id == resp.id))).first()
assert beatmap is not None, "Beatmap should not be None after commit"
return beatmap
@classmethod

View File

@@ -0,0 +1,23 @@
from datetime import datetime
from typing import TypedDict
from app.models.beatmap import BeatmapRankStatus
from app.utils import utcnow
from sqlmodel import JSON, Column, DateTime, Field, SQLModel
class SavedBeatmapMeta(TypedDict):
beatmap_id: int
md5: str
is_deleted: bool
beatmap_status: BeatmapRankStatus
class BeatmapSync(SQLModel, table=True):
beatmapset_id: int = Field(primary_key=True, foreign_key="beatmapsets.id")
beatmaps: list[SavedBeatmapMeta] = Field(sa_column=Column(JSON))
beatmap_status: BeatmapRankStatus = Field(index=True)
consecutive_no_change: int = Field(default=0)
next_sync_time: datetime = Field(default_factory=utcnow, sa_column=Column(DateTime, index=True))
updated_at: datetime = Field(default_factory=utcnow, sa_column=Column(DateTime, index=True))

View File

@@ -130,9 +130,7 @@ class Beatmapset(AsyncAttrs, BeatmapsetBase, table=True):
favourites: list["FavouriteBeatmapset"] = Relationship(back_populates="beatmapset")
@classmethod
async def from_resp(cls, session: AsyncSession, resp: "BeatmapsetResp", from_: int = 0) -> "Beatmapset":
from .beatmap import Beatmap
async def from_resp_no_save(cls, session: AsyncSession, resp: "BeatmapsetResp", from_: int = 0) -> "Beatmapset":
d = resp.model_dump()
update = {}
if resp.nominations:
@@ -158,6 +156,13 @@ class Beatmapset(AsyncAttrs, BeatmapsetBase, table=True):
"download_disabled": resp.availability.download_disabled or False,
}
)
return beatmapset
@classmethod
async def from_resp(cls, session: AsyncSession, resp: "BeatmapsetResp", from_: int = 0) -> "Beatmapset":
from .beatmap import Beatmap
beatmapset = await cls.from_resp_no_save(session, resp, from_=from_)
if not (await session.exec(select(exists()).where(Beatmapset.id == resp.id))).first():
session.add(beatmapset)
await session.commit()
@@ -166,10 +171,13 @@ class Beatmapset(AsyncAttrs, BeatmapsetBase, table=True):
@classmethod
async def get_or_fetch(cls, session: AsyncSession, fetcher: "Fetcher", sid: int) -> "Beatmapset":
from app.service.beatmapset_update_service import get_beatmapset_update_service
beatmapset = await session.get(Beatmapset, sid)
if not beatmapset:
resp = await fetcher.get_beatmapset(sid)
beatmapset = await cls.from_resp(session, resp)
await get_beatmapset_update_service().add(resp)
return beatmapset

View File

@@ -1,5 +1,7 @@
from typing import TYPE_CHECKING
from app.calculator import calculate_score_to_level
from app.database.statistics import UserStatistics
from app.models.score import GameMode, Rank
from .lazer_user import User
@@ -12,7 +14,11 @@ from sqlmodel import (
ForeignKey,
Relationship,
SQLModel,
col,
func,
select,
)
from sqlmodel.ext.asyncio.session import AsyncSession
if TYPE_CHECKING:
from .beatmap import Beatmap
@@ -37,6 +43,43 @@ class BestScore(SQLModel, table=True):
sa_relationship_kwargs={
"foreign_keys": "[BestScore.score_id]",
"lazy": "joined",
}
},
back_populates="best_score",
)
beatmap: "Beatmap" = Relationship()
async def delete(self, session: AsyncSession):
from .score import Score
statistics = await session.exec(
select(UserStatistics).where(UserStatistics.user_id == self.user_id, UserStatistics.mode == self.gamemode)
)
statistics = statistics.first()
if statistics:
statistics.total_score -= self.total_score
statistics.ranked_score -= self.total_score
statistics.level_current = calculate_score_to_level(statistics.total_score)
match self.rank:
case Rank.X:
statistics.grade_ss -= 1
case Rank.XH:
statistics.grade_ssh -= 1
case Rank.S:
statistics.grade_s -= 1
case Rank.SH:
statistics.grade_sh -= 1
case Rank.A:
statistics.grade_a -= 1
max_combo = (
await session.exec(
select(func.max(Score.max_combo)).where(
Score.user_id == self.user_id,
col(Score.id).in_(select(BestScore.score_id)),
Score.gamemode == self.gamemode,
)
)
).first()
statistics.maximum_combo = max(0, max_combo or 0)
await session.delete(self)

View File

@@ -1,5 +1,6 @@
from typing import TYPE_CHECKING
from app.database.statistics import UserStatistics
from app.models.score import GameMode
from .lazer_user import User
@@ -12,7 +13,9 @@ from sqlmodel import (
ForeignKey,
Relationship,
SQLModel,
select,
)
from sqlmodel.ext.asyncio.session import AsyncSession
if TYPE_CHECKING:
from .beatmap import Beatmap
@@ -33,5 +36,22 @@ class PPBestScore(SQLModel, table=True):
)
user: User = Relationship()
score: "Score" = Relationship()
score: "Score" = Relationship(
back_populates="ranked_score",
)
beatmap: "Beatmap" = Relationship()
async def delete(self, session: AsyncSession):
from .score import calculate_user_pp
gamemode = self.gamemode
user_id = self.user_id
await session.delete(self)
await session.flush()
statistics = await session.exec(
select(UserStatistics).where(UserStatistics.user_id == user_id, UserStatistics.mode == gamemode)
)
statistics = statistics.first()
if statistics:
statistics.pp, statistics.hit_accuracy = await calculate_user_pp(session, statistics.user_id, gamemode)

View File

@@ -32,6 +32,7 @@ from app.models.score import (
ScoreStatistics,
SoloScoreSubmissionInfo,
)
from app.storage import StorageService
from app.utils import utcnow
from .beatmap import Beatmap, BeatmapResp
@@ -40,6 +41,7 @@ from .beatmapset import BeatmapsetResp
from .best_score import BestScore
from .counts import MonthlyPlaycounts
from .lazer_user import User, UserResp
from .playlist_best_score import PlaylistBestScore
from .pp_best_score import PPBestScore
from .relationship import (
Relationship as DBRelationship,
@@ -95,6 +97,7 @@ class ScoreBase(AsyncAttrs, SQLModel, UTCBaseModel):
beatmap_id: int = Field(index=True, foreign_key="beatmaps.id")
maximum_statistics: ScoreStatistics = Field(sa_column=Column(JSON), default_factory=dict)
processed: bool = False # solo_score
ranked: bool = False
@field_validator("maximum_statistics", mode="before")
@classmethod
@@ -189,16 +192,57 @@ class Score(ScoreBase, table=True):
# optional
beatmap: Mapped[Beatmap] = Relationship()
user: Mapped[User] = Relationship(sa_relationship_kwargs={"lazy": "joined"})
best_score: Mapped[BestScore | None] = Relationship(
back_populates="score",
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
},
)
ranked_score: Mapped[PPBestScore | None] = Relationship(
back_populates="score",
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
},
)
playlist_item_score: Mapped[PlaylistBestScore | None] = Relationship(
back_populates="score",
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
},
)
@property
def is_perfect_combo(self) -> bool:
return self.max_combo == self.beatmap.max_combo
@property
def replay_filename(self) -> str:
return f"replays/{self.id}_{self.beatmap_id}_{self.user_id}_lazer_replay.osr"
async def to_resp(self, session: AsyncSession, api_version: int) -> "ScoreResp | LegacyScoreResp":
if api_version >= 20220705:
return await ScoreResp.from_db(session, self)
return await LegacyScoreResp.from_db(session, self)
async def delete(
self,
session: AsyncSession,
storage_service: StorageService,
):
if await self.awaitable_attrs.best_score:
assert self.best_score is not None
await self.best_score.delete(session)
await session.refresh(self)
if await self.awaitable_attrs.ranked_score:
assert self.ranked_score is not None
await self.ranked_score.delete(session)
await session.refresh(self)
if await self.awaitable_attrs.playlist_item_score:
await session.delete(self.playlist_item_score)
await storage_service.delete_file(self.replay_filename)
await session.delete(self)
class ScoreResp(ScoreBase):
id: int
@@ -218,7 +262,6 @@ class ScoreResp(ScoreBase):
rank_country: int | None = None
position: int | None = None
scores_around: "ScoreAround | None" = None
ranked: bool = False
current_user_attributes: CurrentUserAttributes | None = None
@field_validator(
@@ -335,7 +378,6 @@ class ScoreResp(ScoreBase):
s.current_user_attributes = CurrentUserAttributes(
pin=PinAttributes(is_pinned=bool(score.pinned_order), score_id=score.id)
)
s.ranked = s.pp > 0
return s
@@ -977,6 +1019,7 @@ async def process_score(
room_id=room_id,
maximum_statistics=info.maximum_statistics,
processed=True,
ranked=ranked,
)
successed = True
if can_get_pp: