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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
23
app/database/beatmap_sync.py
Normal file
23
app/database/beatmap_sync.py
Normal 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))
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user