feat(score): support download replay

This commit is contained in:
MingxuanGame
2025-08-11 08:19:05 +00:00
parent 680c7525b8
commit b9babb8f24
7 changed files with 198 additions and 17 deletions

View File

@@ -10,6 +10,11 @@ from .beatmapset import (
BeatmapsetResp as BeatmapsetResp,
)
from .best_score import BestScore
from .counts import (
CountResp,
MonthlyPlaycounts,
ReplayWatchedCount,
)
from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp
from .favourite_beatmapset import FavouriteBeatmapset
from .lazer_user import (
@@ -56,11 +61,13 @@ __all__ = [
"Beatmapset",
"BeatmapsetResp",
"BestScore",
"CountResp",
"DailyChallengeStats",
"DailyChallengeStatsResp",
"FavouriteBeatmapset",
"ItemAttemptsCount",
"ItemAttemptsResp",
"MonthlyPlaycounts",
"MultiplayerEvent",
"MultiplayerEventResp",
"MultiplayerScores",
@@ -73,6 +80,7 @@ __all__ = [
"Relationship",
"RelationshipResp",
"RelationshipType",
"ReplayWatchedCount",
"Room",
"RoomParticipatedUser",
"RoomResp",

View File

@@ -14,7 +14,13 @@ if TYPE_CHECKING:
from .lazer_user import User
class MonthlyPlaycounts(SQLModel, table=True):
class CountBase(SQLModel):
year: int = Field(index=True)
month: int = Field(index=True)
count: int = Field(default=0)
class MonthlyPlaycounts(CountBase, table=True):
__tablename__ = "monthly_playcounts" # pyright: ignore[reportAssignmentType]
id: int | None = Field(
@@ -24,20 +30,29 @@ class MonthlyPlaycounts(SQLModel, table=True):
user_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
)
year: int = Field(index=True)
month: int = Field(index=True)
playcount: int = Field(default=0)
user: "User" = Relationship(back_populates="monthly_playcounts")
class MonthlyPlaycountsResp(SQLModel):
class ReplayWatchedCount(CountBase, table=True):
__tablename__ = "replays_watched_counts" # pyright: ignore[reportAssignmentType]
id: int | None = Field(
default=None,
sa_column=Column(BigInteger, primary_key=True, autoincrement=True),
)
user_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
)
user: "User" = Relationship(back_populates="replays_watched_counts")
class CountResp(SQLModel):
start_date: date
count: int
@classmethod
def from_db(cls, db_model: MonthlyPlaycounts) -> "MonthlyPlaycountsResp":
def from_db(cls, db_model: CountBase) -> "CountResp":
return cls(
start_date=date(db_model.year, db_model.month, 1),
count=db_model.playcount,
count=db_model.count,
)

View File

@@ -7,8 +7,8 @@ from app.models.user import Country, Page, RankHistory
from .achievement import UserAchievement, UserAchievementResp
from .beatmap_playcounts import BeatmapPlaycounts
from .counts import CountResp, MonthlyPlaycounts, ReplayWatchedCount
from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp
from .monthly_playcounts import MonthlyPlaycounts, MonthlyPlaycountsResp
from .statistics import UserStatistics, UserStatisticsResp
from .team import Team, TeamMember
from .user_account_history import UserAccountHistory, UserAccountHistoryResp
@@ -76,7 +76,6 @@ class UserBase(UTCBaseModel, SQLModel):
username: str = Field(max_length=32, unique=True, index=True)
page: Page = Field(sa_column=Column(JSON), default=Page(html="", raw=""))
previous_usernames: list[str] = Field(default_factory=list, sa_column=Column(JSON))
# TODO: replays_watched_counts
support_level: int = 0
badges: list[Badge] = Field(default_factory=list, sa_column=Column(JSON))
@@ -146,6 +145,9 @@ class User(AsyncAttrs, UserBase, table=True):
back_populates="user"
)
monthly_playcounts: list[MonthlyPlaycounts] = Relationship(back_populates="user")
replays_watched_counts: list[ReplayWatchedCount] = Relationship(
back_populates="user"
)
favourite_beatmapsets: list["FavouriteBeatmapset"] = Relationship(
back_populates="user"
)
@@ -185,7 +187,8 @@ class UserResp(UserBase):
account_history: list[UserAccountHistoryResp] = []
active_tournament_banners: list[dict] = [] # TODO
kudosu: Kudosu = Field(default_factory=lambda: Kudosu(available=0, total=0)) # TODO
monthly_playcounts: list[MonthlyPlaycountsResp] = Field(default_factory=list)
monthly_playcounts: list[CountResp] = Field(default_factory=list)
replay_watched_counts: list[CountResp] = Field(default_factory=list)
unread_pm_count: int = 0 # TODO
rank_history: RankHistory | None = None # TODO
rank_highest: RankHighest | None = None # TODO
@@ -299,10 +302,16 @@ class UserResp(UserBase):
if "monthly_playcounts" in include:
u.monthly_playcounts = [
MonthlyPlaycountsResp.from_db(pc)
CountResp.from_db(pc)
for pc in await obj.awaitable_attrs.monthly_playcounts
]
if "replays_watched_counts" in include:
u.replay_watched_counts = [
CountResp.from_db(rwc)
for rwc in await obj.awaitable_attrs.replays_watched_counts
]
if "achievements" in include:
u.user_achievements = [
UserAchievementResp.from_db(ua)
@@ -373,6 +382,7 @@ ALL_INCLUDED = [
"statistics_rulesets",
"achievements",
"monthly_playcounts",
"replays_watched_counts",
]
@@ -383,6 +393,7 @@ SEARCH_INCLUDED = [
"statistics_rulesets",
"achievements",
"monthly_playcounts",
"replays_watched_counts",
]
BASE_INCLUDES = [

View File

@@ -37,8 +37,8 @@ from .beatmap import Beatmap, BeatmapResp
from .beatmap_playcounts import process_beatmap_playcount
from .beatmapset import BeatmapsetResp
from .best_score import BestScore
from .counts import MonthlyPlaycounts
from .lazer_user import User, UserResp
from .monthly_playcounts import MonthlyPlaycounts
from .pp_best_score import PPBestScore
from .relationship import (
Relationship as DBRelationship,
@@ -612,7 +612,7 @@ async def process_user(
)
)
statistics.play_count += 1
mouthly_playcount.playcount += 1
mouthly_playcount.count += 1
statistics.play_time += length
statistics.count_100 += score.n100 + score.nkatu
statistics.count_300 += score.n300 + score.ngeki

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, date, datetime
import time
from app.calculator import clamp
@@ -15,12 +15,14 @@ from app.database import (
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,
@@ -40,15 +42,17 @@ from app.models.score import (
Rank,
SoloScoreSubmissionInfo,
)
from app.path import REPLAY_DIR
from .api_router import router
from fastapi import Body, Depends, Form, HTTPException, Query
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, func, select
from sqlmodel import col, exists, func, select
from sqlmodel.ext.asyncio.session import AsyncSession
READ_SCORE_TIMEOUT = 10
@@ -704,3 +708,54 @@ async def reorder_score_pin(
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 = Depends(get_current_user),
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"
)

View File

@@ -136,7 +136,9 @@ def save_replay(
data.extend(struct.pack("<i", len(compressed)))
data.extend(compressed)
replay_path = REPLAY_DIR / f"lazer-{score.type}-{username}-{score.id}.osr"
replay_path = (
REPLAY_DIR / f"{score.id}_{score.beatmap_id}_{score.user_id}_lazer_replay.osr"
)
replay_path.write_bytes(data)

View File

@@ -0,0 +1,90 @@
"""count: add replays_watched_counts
Revision ID: aa582c13f905
Revises: 319e5f841dcf
Create Date: 2025-08-11 08:03:33.739398
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "aa582c13f905"
down_revision: str | Sequence[str] | None = "319e5f841dcf"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"replays_watched_counts",
sa.Column("year", sa.Integer(), nullable=False),
sa.Column("month", sa.Integer(), nullable=False),
sa.Column("count", sa.Integer(), nullable=False),
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["lazer_users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_replays_watched_counts_month"),
"replays_watched_counts",
["month"],
unique=False,
)
op.create_index(
op.f("ix_replays_watched_counts_user_id"),
"replays_watched_counts",
["user_id"],
unique=False,
)
op.create_index(
op.f("ix_replays_watched_counts_year"),
"replays_watched_counts",
["year"],
unique=False,
)
op.alter_column(
"monthly_playcounts",
"playcount",
new_column_name="count",
type_=sa.Integer(),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"monthly_playcounts",
"count",
new_column_name="playcount",
type_=sa.Integer(),
)
op.drop_constraint(
"replays_watched_counts_ibfk_1",
"replays_watched_counts",
type_="foreignkey",
)
op.drop_index(
op.f("ix_replays_watched_counts_year"), table_name="replays_watched_counts"
)
op.drop_index(
op.f("ix_replays_watched_counts_user_id"), table_name="replays_watched_counts"
)
op.drop_index(
op.f("ix_replays_watched_counts_month"), table_name="replays_watched_counts"
)
op.drop_table("replays_watched_counts")
# ### end Alembic commands ###