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

View File

@@ -14,7 +14,13 @@ if TYPE_CHECKING:
from .lazer_user import User 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] __tablename__ = "monthly_playcounts" # pyright: ignore[reportAssignmentType]
id: int | None = Field( id: int | None = Field(
@@ -24,20 +30,29 @@ class MonthlyPlaycounts(SQLModel, table=True):
user_id: int = Field( user_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) 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") 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 start_date: date
count: int count: int
@classmethod @classmethod
def from_db(cls, db_model: MonthlyPlaycounts) -> "MonthlyPlaycountsResp": def from_db(cls, db_model: CountBase) -> "CountResp":
return cls( return cls(
start_date=date(db_model.year, db_model.month, 1), 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 .achievement import UserAchievement, UserAchievementResp
from .beatmap_playcounts import BeatmapPlaycounts from .beatmap_playcounts import BeatmapPlaycounts
from .counts import CountResp, MonthlyPlaycounts, ReplayWatchedCount
from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp
from .monthly_playcounts import MonthlyPlaycounts, MonthlyPlaycountsResp
from .statistics import UserStatistics, UserStatisticsResp from .statistics import UserStatistics, UserStatisticsResp
from .team import Team, TeamMember from .team import Team, TeamMember
from .user_account_history import UserAccountHistory, UserAccountHistoryResp 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) username: str = Field(max_length=32, unique=True, index=True)
page: Page = Field(sa_column=Column(JSON), default=Page(html="", raw="")) page: Page = Field(sa_column=Column(JSON), default=Page(html="", raw=""))
previous_usernames: list[str] = Field(default_factory=list, sa_column=Column(JSON)) previous_usernames: list[str] = Field(default_factory=list, sa_column=Column(JSON))
# TODO: replays_watched_counts
support_level: int = 0 support_level: int = 0
badges: list[Badge] = Field(default_factory=list, sa_column=Column(JSON)) badges: list[Badge] = Field(default_factory=list, sa_column=Column(JSON))
@@ -146,6 +145,9 @@ class User(AsyncAttrs, UserBase, table=True):
back_populates="user" back_populates="user"
) )
monthly_playcounts: list[MonthlyPlaycounts] = Relationship(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( favourite_beatmapsets: list["FavouriteBeatmapset"] = Relationship(
back_populates="user" back_populates="user"
) )
@@ -185,7 +187,8 @@ class UserResp(UserBase):
account_history: list[UserAccountHistoryResp] = [] account_history: list[UserAccountHistoryResp] = []
active_tournament_banners: list[dict] = [] # TODO active_tournament_banners: list[dict] = [] # TODO
kudosu: Kudosu = Field(default_factory=lambda: Kudosu(available=0, total=0)) # 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 unread_pm_count: int = 0 # TODO
rank_history: RankHistory | None = None # TODO rank_history: RankHistory | None = None # TODO
rank_highest: RankHighest | None = None # TODO rank_highest: RankHighest | None = None # TODO
@@ -299,10 +302,16 @@ class UserResp(UserBase):
if "monthly_playcounts" in include: if "monthly_playcounts" in include:
u.monthly_playcounts = [ u.monthly_playcounts = [
MonthlyPlaycountsResp.from_db(pc) CountResp.from_db(pc)
for pc in await obj.awaitable_attrs.monthly_playcounts 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: if "achievements" in include:
u.user_achievements = [ u.user_achievements = [
UserAchievementResp.from_db(ua) UserAchievementResp.from_db(ua)
@@ -373,6 +382,7 @@ ALL_INCLUDED = [
"statistics_rulesets", "statistics_rulesets",
"achievements", "achievements",
"monthly_playcounts", "monthly_playcounts",
"replays_watched_counts",
] ]
@@ -383,6 +393,7 @@ SEARCH_INCLUDED = [
"statistics_rulesets", "statistics_rulesets",
"achievements", "achievements",
"monthly_playcounts", "monthly_playcounts",
"replays_watched_counts",
] ]
BASE_INCLUDES = [ BASE_INCLUDES = [

View File

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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, date, datetime
import time import time
from app.calculator import clamp from app.calculator import clamp
@@ -15,12 +15,14 @@ from app.database import (
ScoreTokenResp, ScoreTokenResp,
User, User,
) )
from app.database.counts import ReplayWatchedCount
from app.database.playlist_attempts import ItemAttemptsCount from app.database.playlist_attempts import ItemAttemptsCount
from app.database.playlist_best_score import ( from app.database.playlist_best_score import (
PlaylistBestScore, PlaylistBestScore,
get_position, get_position,
process_playlist_best_score, process_playlist_best_score,
) )
from app.database.relationship import Relationship, RelationshipType
from app.database.score import ( from app.database.score import (
MultiplayerScores, MultiplayerScores,
ScoreAround, ScoreAround,
@@ -40,15 +42,17 @@ from app.models.score import (
Rank, Rank,
SoloScoreSubmissionInfo, SoloScoreSubmissionInfo,
) )
from app.path import REPLAY_DIR
from .api_router import router from .api_router import router
from fastapi import Body, Depends, Form, HTTPException, Query from fastapi import Body, Depends, Form, HTTPException, Query
from fastapi.responses import FileResponse
from httpx import HTTPError from httpx import HTTPError
from pydantic import BaseModel from pydantic import BaseModel
from redis.asyncio import Redis from redis.asyncio import Redis
from sqlalchemy.orm import joinedload 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 from sqlmodel.ext.asyncio.session import AsyncSession
READ_SCORE_TIMEOUT = 10 READ_SCORE_TIMEOUT = 10
@@ -704,3 +708,54 @@ async def reorder_score_pin(
score_record.pinned_order = final_target score_record.pinned_order = final_target
await db.commit() 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(struct.pack("<i", len(compressed)))
data.extend(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) 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 ###