feat(score): support download replay
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 ###
|
||||||
Reference in New Issue
Block a user