From b9babb8f240270f12f6dbb706297ac3f12bdc621 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Mon, 11 Aug 2025 08:19:05 +0000 Subject: [PATCH] feat(score): support download replay --- app/database/__init__.py | 8 ++ .../{monthly_playcounts.py => counts.py} | 31 +++++-- app/database/lazer_user.py | 19 +++- app/database/score.py | 4 +- app/router/score.py | 59 +++++++++++- app/signalr/hub/spectator.py | 4 +- ...13f905_count_add_replays_watched_counts.py | 90 +++++++++++++++++++ 7 files changed, 198 insertions(+), 17 deletions(-) rename app/database/{monthly_playcounts.py => counts.py} (55%) create mode 100644 migrations/versions/aa582c13f905_count_add_replays_watched_counts.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 7b5c228..b4167f0 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -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", diff --git a/app/database/monthly_playcounts.py b/app/database/counts.py similarity index 55% rename from app/database/monthly_playcounts.py rename to app/database/counts.py index 46192d1..c999471 100644 --- a/app/database/monthly_playcounts.py +++ b/app/database/counts.py @@ -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, ) diff --git a/app/database/lazer_user.py b/app/database/lazer_user.py index 2397323..05998a7 100644 --- a/app/database/lazer_user.py +++ b/app/database/lazer_user.py @@ -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 = [ diff --git a/app/database/score.py b/app/database/score.py index c247ffc..9efb081 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -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 diff --git a/app/router/score.py b/app/router/score.py index b2a52ca..d197c8b 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -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" + ) diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index 7764994..dcc47f3 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -136,7 +136,9 @@ def save_replay( data.extend(struct.pack(" 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 ###