feat(fetcher): add data fetcher for beatmap & beatmapset

This commit is contained in:
MingxuanGame
2025-07-26 17:01:46 +08:00
parent cca4a2f1be
commit 8d6b5a882d
6 changed files with 126 additions and 16 deletions

View File

@@ -7,6 +7,7 @@ from .beatmapset import Beatmapset, BeatmapsetResp
from sqlalchemy import DECIMAL, Column, DateTime from sqlalchemy import DECIMAL, Column, DateTime
from sqlmodel import VARCHAR, Field, Relationship, SQLModel from sqlmodel import VARCHAR, Field, Relationship, SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
class BeatmapOwner(SQLModel): class BeatmapOwner(SQLModel):
@@ -22,7 +23,6 @@ class BeatmapBase(SQLModel):
difficulty_rating: float = Field( difficulty_rating: float = Field(
default=0.0, sa_column=Column(DECIMAL(precision=10, scale=6)) default=0.0, sa_column=Column(DECIMAL(precision=10, scale=6))
) )
beatmap_status: BeatmapRankStatus
total_length: int total_length: int
user_id: int user_id: int
version: str version: str
@@ -59,9 +59,49 @@ class Beatmap(BeatmapBase, table=True):
__tablename__ = "beatmaps" # pyright: ignore[reportAssignmentType] __tablename__ = "beatmaps" # pyright: ignore[reportAssignmentType]
id: int | None = Field(default=None, primary_key=True, index=True) id: int | None = Field(default=None, primary_key=True, index=True)
beatmapset_id: int = Field(foreign_key="beatmapsets.id", index=True) beatmapset_id: int = Field(foreign_key="beatmapsets.id", index=True)
beatmap_status: BeatmapRankStatus
# optional # optional
beatmapset: Beatmapset = Relationship(back_populates="beatmaps") beatmapset: Beatmapset = Relationship(back_populates="beatmaps")
@classmethod
async def from_resp(cls, session: AsyncSession, resp: "BeatmapResp") -> "Beatmap":
d = resp.model_dump()
del d["beatmapset"]
beatmap = Beatmap.model_validate(
{
**d,
"beatmapset_id": resp.beatmapset_id,
"id": resp.id,
"beatmap_status": BeatmapRankStatus(resp.ranked),
}
)
session.add(beatmap)
await session.commit()
return beatmap
@classmethod
async def from_resp_batch(
cls, session: AsyncSession, inp: list["BeatmapResp"], from_: int = 0
) -> list["Beatmap"]:
beatmaps = []
for resp in inp:
if resp.id == from_:
continue
d = resp.model_dump()
del d["beatmapset"]
beatmap = Beatmap.model_validate(
{
**d,
"beatmapset_id": resp.beatmapset_id,
"id": resp.id,
"beatmap_status": BeatmapRankStatus(resp.ranked),
}
)
session.add(beatmap)
beatmaps.append(beatmap)
await session.commit()
return beatmaps
class BeatmapResp(BeatmapBase): class BeatmapResp(BeatmapBase):
id: int id: int

View File

@@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, TypedDict, cast
from app.models.beatmap import BeatmapRankStatus, Genre, Language from app.models.beatmap import BeatmapRankStatus, Genre, Language
from app.models.score import GameMode from app.models.score import GameMode
@@ -7,6 +7,7 @@ from app.models.score import GameMode
from pydantic import BaseModel, model_serializer from pydantic import BaseModel, model_serializer
from sqlalchemy import DECIMAL, JSON, Column, DateTime, Text from sqlalchemy import DECIMAL, JSON, Column, DateTime, Text
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
if TYPE_CHECKING: if TYPE_CHECKING:
from .beatmap import Beatmap, BeatmapResp from .beatmap import Beatmap, BeatmapResp
@@ -64,11 +65,11 @@ class BeatmapNominations(SQLModel):
required: int | None = Field(default=None) required: int | None = Field(default=None)
class BeatmapNomination(SQLModel): class BeatmapNomination(TypedDict):
beatmapset_id: int beatmapset_id: int
reset: bool reset: bool
user_id: int user_id: int
rulesets: list[GameMode] | None = None rulesets: dict[str, GameMode] | None
class BeatmapDescription(SQLModel): class BeatmapDescription(SQLModel):
@@ -150,20 +151,52 @@ class Beatmapset(BeatmapsetBase, table=True):
availability_info: str | None = Field(default=None) availability_info: str | None = Field(default=None)
download_disabled: bool = Field(default=False) download_disabled: bool = Field(default=False)
@classmethod
async def from_resp(
cls, session: AsyncSession, resp: "BeatmapsetResp", from_: int = 0
) -> "Beatmapset":
from .beatmap import Beatmap
d = resp.model_dump()
update = {}
if resp.nominations:
update["nominations_required"] = resp.nominations.required
update["nominations_current"] = resp.nominations.current
if resp.hype:
update["hype_current"] = resp.hype.current
update["hype_required"] = resp.hype.required
if resp.genre:
update["beatmap_genre"] = Genre(resp.genre.id)
if resp.language:
update["beatmap_language"] = Language(resp.language.id)
beatmapset = Beatmapset.model_validate(
{
**d,
"id": resp.id,
"beatmap_status": BeatmapRankStatus(resp.ranked),
"availability_info": resp.availability.more_information,
"download_disabled": resp.availability.download_disabled or False,
}
)
session.add(beatmapset)
await session.commit()
await Beatmap.from_resp_batch(session, resp.beatmaps, from_=from_)
return beatmapset
class BeatmapsetResp(BeatmapsetBase): class BeatmapsetResp(BeatmapsetBase):
id: int id: int
beatmaps: list["BeatmapResp"] beatmaps: list["BeatmapResp"] = Field(default_factory=list)
discussion_enabled: bool = True discussion_enabled: bool = True
status: str status: str
ranked: int ranked: int
legacy_thread_url: str = "" legacy_thread_url: str = ""
is_scoreable: bool is_scoreable: bool
hype: BeatmapHype hype: BeatmapHype | None = None
availability: BeatmapAvailability availability: BeatmapAvailability
genre: BeatmapTranslationText genre: BeatmapTranslationText | None = None
language: BeatmapTranslationText language: BeatmapTranslationText | None = None
nominations: BeatmapNominations nominations: BeatmapNominations | None = None
@classmethod @classmethod
def from_db(cls, beatmapset: Beatmapset) -> "BeatmapsetResp": def from_db(cls, beatmapset: Beatmapset) -> "BeatmapsetResp":

View File

@@ -1,5 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json
from app.config import settings
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -8,10 +13,16 @@ try:
import redis import redis
except ImportError: except ImportError:
redis = None redis = None
from app.config import settings
def json_serializer(value):
if isinstance(value, BaseModel | SQLModel):
return value.model_dump_json()
return json.dumps(value)
# 数据库引擎 # 数据库引擎
engine = create_async_engine(settings.DATABASE_URL) engine = create_async_engine(settings.DATABASE_URL, json_serializer=json_serializer)
# Redis 连接 # Redis 连接
if redis: if redis:

View File

@@ -8,7 +8,7 @@ from httpx import AsyncClient
class BeatmapsetFetcher(BaseFetcher): class BeatmapsetFetcher(BaseFetcher):
async def get_beatmap_set(self, beatmap_set_id: int) -> BeatmapsetResp: async def get_beatmapset(self, beatmap_set_id: int) -> BeatmapsetResp:
async with AsyncClient() as client: async with AsyncClient() as client:
response = await client.get( response = await client.get(
f"https://osu.ppy.sh/api/v2/beatmapsets/{beatmap_set_id}", f"https://osu.ppy.sh/api/v2/beatmapsets/{beatmap_set_id}",

View File

@@ -8,11 +8,14 @@ from app.database import (
from app.database.beatmapset import Beatmapset from app.database.beatmapset import Beatmapset
from app.database.score import Score, ScoreResp from app.database.score import Score, ScoreResp
from app.dependencies.database import get_db from app.dependencies.database import get_db
from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user from app.dependencies.user import get_current_user
from app.fetcher import Fetcher
from .api_router import router from .api_router import router
from fastapi import Depends, HTTPException, Query from fastapi import Depends, HTTPException, Query
from httpx import HTTPStatusError
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlmodel import col, select from sqlmodel import col, select
@@ -24,6 +27,7 @@ async def get_beatmap(
bid: int, bid: int,
current_user: DBUser = Depends(get_current_user), current_user: DBUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
fetcher: Fetcher = Depends(get_fetcher),
): ):
beatmap = ( beatmap = (
await db.exec( await db.exec(
@@ -33,8 +37,20 @@ async def get_beatmap(
) )
).first() ).first()
if not beatmap: if not beatmap:
raise HTTPException(status_code=404, detail="Beatmap not found") try:
return BeatmapResp.from_db(beatmap) resp = await fetcher.get_beatmap(bid)
r = await db.exec(
select(Beatmapset.id).where(Beatmapset.id == resp.beatmapset_id)
)
if not r.first():
set_resp = await fetcher.get_beatmapset(resp.beatmapset_id)
await Beatmapset.from_resp(db, set_resp, from_=resp.id)
await Beatmap.from_resp(db, resp)
except HTTPStatusError:
raise HTTPException(status_code=404, detail="Beatmap not found")
else:
resp = BeatmapResp.from_db(beatmap)
return resp
class BatchGetResp(BaseModel): class BatchGetResp(BaseModel):

View File

@@ -6,11 +6,14 @@ from app.database import (
User as DBUser, User as DBUser,
) )
from app.dependencies.database import get_db from app.dependencies.database import get_db
from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user from app.dependencies.user import get_current_user
from app.fetcher import Fetcher
from .api_router import router from .api_router import router
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from httpx import HTTPStatusError
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -21,6 +24,7 @@ async def get_beatmapset(
sid: int, sid: int,
current_user: DBUser = Depends(get_current_user), current_user: DBUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
fetcher: Fetcher = Depends(get_fetcher),
): ):
beatmapset = ( beatmapset = (
await db.exec( await db.exec(
@@ -30,5 +34,11 @@ async def get_beatmapset(
) )
).first() ).first()
if not beatmapset: if not beatmapset:
raise HTTPException(status_code=404, detail="Beatmapset not found") try:
return BeatmapsetResp.from_db(beatmapset) resp = await fetcher.get_beatmapset(sid)
await Beatmapset.from_resp(db, resp)
except HTTPStatusError:
raise HTTPException(status_code=404, detail="Beatmapset not found")
else:
resp = BeatmapsetResp.from_db(beatmapset)
return resp