diff --git a/.env.example b/.env.example index dc0f053..37804fc 100644 --- a/.env.example +++ b/.env.example @@ -9,8 +9,12 @@ CODE_HOTRELOAD= OSU_API_V2_CLIENT_ID= OSU_API_V2_CLIENT_SECRET= +OSU_API_V1_API_KEYS_POOL= + DB_USER=cmyui DB_PASS=lol123 DB_HOST=localhost DB_PORT=3306 DB_NAME=akatsuki + +DISCORD_BEATMAP_UPDATES_WEBHOOK_URL= diff --git a/app/adapters/discord_webhooks.py b/app/adapters/discord_webhooks.py new file mode 100644 index 0000000..63dd22a --- /dev/null +++ b/app/adapters/discord_webhooks.py @@ -0,0 +1,221 @@ +# This portion is based off cmyui's discord hooks code +# https://github.com/cmyui/cmyui_pkg/blob/master/cmyui/discord/webhook.py +import logging +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal + +import httpx + +from app import job_scheduling +from app import settings + +if TYPE_CHECKING: + from app.repositories.akatsuki_beatmaps import AkatsukiBeatmap + +discord_webhooks_http_client = httpx.AsyncClient() + + +EDIT_COL = "4360181" +EDIT_ICON = "https://cdn3.iconfinder.com/data/icons/bold-blue-glyphs-free-samples/32/Info_Circle_Symbol_Information_Letter-512.png" + + +class Footer: + def __init__(self, text: str, **kwargs: Any) -> None: + self.text = text + self.icon_url = kwargs.get("icon_url") + self.proxy_icon_url = kwargs.get("proxy_icon_url") + + +class Image: + def __init__(self, **kwargs: Any) -> None: + self.url = kwargs.get("url") + self.proxy_url = kwargs.get("proxy_url") + self.height = kwargs.get("height") + self.width = kwargs.get("width") + + +class Thumbnail: + def __init__(self, **kwargs: Any) -> None: + self.url = kwargs.get("url") + self.proxy_url = kwargs.get("proxy_url") + self.height = kwargs.get("height") + self.width = kwargs.get("width") + + +class Video: + def __init__(self, **kwargs: Any) -> None: + self.url = kwargs.get("url") + self.height = kwargs.get("height") + self.width = kwargs.get("width") + + +class Provider: + def __init__(self, **kwargs: Any) -> None: + self.url = kwargs.get("url") + self.name = kwargs.get("name") + + +class Author: + def __init__(self, **kwargs: Any) -> None: + self.name = kwargs.get("name") + self.url = kwargs.get("url") + self.icon_url = kwargs.get("icon_url") + self.proxy_icon_url = kwargs.get("proxy_icon_url") + + +class Field: + def __init__(self, name: str, value: str, inline: bool = False) -> None: + self.name = name + self.value = value + self.inline = inline + + +class Embed: + def __init__(self, **kwargs: Any) -> None: + self.title = kwargs.get("title") + self.type = kwargs.get("type") + self.description = kwargs.get("description") + self.url = kwargs.get("url") + self.timestamp = kwargs.get("timestamp") # datetime + self.color = kwargs.get("color", 0x000000) + + self.footer: Footer | None = kwargs.get("footer") + self.image: Image | None = kwargs.get("image") + self.thumbnail: Thumbnail | None = kwargs.get("thumbnail") + self.video: Video | None = kwargs.get("video") + self.provider: Provider | None = kwargs.get("provider") + self.author: Author | None = kwargs.get("author") + + self.fields: list[Field] = kwargs.get("fields", []) + + def set_footer(self, **kwargs: Any) -> None: + self.footer = Footer(**kwargs) + + def set_image(self, **kwargs: Any) -> None: + self.image = Image(**kwargs) + + def set_thumbnail(self, **kwargs: Any) -> None: + self.thumbnail = Thumbnail(**kwargs) + + def set_video(self, **kwargs: Any) -> None: + self.video = Video(**kwargs) + + def set_provider(self, **kwargs: Any) -> None: + self.provider = Provider(**kwargs) + + def set_author(self, **kwargs: Any) -> None: + self.author = Author(**kwargs) + + def add_field(self, name: str, value: str, inline: bool = False) -> None: + self.fields.append(Field(name, value, inline)) + + +class Webhook: + """A class to represent a single-use Discord webhook.""" + + __slots__ = ("url", "content", "username", "avatar_url", "tts", "file", "embeds") + + def __init__(self, url: str, **kwargs: Any) -> None: + self.url = url + self.content = kwargs.get("content") + self.username = kwargs.get("username") + self.avatar_url = kwargs.get("avatar_url") + self.tts = kwargs.get("tts") + self.file = kwargs.get("file") + self.embeds: list[Embed] = kwargs.get("embeds", []) + + def add_embed(self, embed: Embed) -> None: + self.embeds.append(embed) + + @property + def json(self) -> dict[str, Any]: + if not any([self.content, self.file, self.embeds]): + raise Exception( + "Webhook must contain atleast one " "of (content, file, embeds).", + ) + + if self.content and len(self.content) > 2000: + raise Exception("Webhook content must be under " "2000 characters.") + + payload: dict[str, Any] = {"embeds": []} + + for key in ("content", "username", "avatar_url", "tts", "file"): + if (val := getattr(self, key)) is not None: + payload[key] = val + + for embed in self.embeds: + embed_payload = {} + + # simple params + for key in ("title", "type", "description", "url", "timestamp", "color"): + if val := getattr(embed, key): + embed_payload[key] = val + + # class params, must turn into dict + for key in ("footer", "image", "thumbnail", "video", "provider", "author"): + if val := getattr(embed, key): + embed_payload[key] = val.__dict__ + + if embed.fields: + embed_payload["fields"] = [f.__dict__ for f in embed.fields] + + payload["embeds"].append(embed_payload) + + return payload + + async def post(self) -> None: + """Post the webhook in JSON format.""" + response = await discord_webhooks_http_client.post( + self.url, + json=self.json, + ) + response.raise_for_status() + + +async def wrap_hook(webhook_url: str, embed: Embed) -> None: + """Handles sending the webhook to discord.""" + + logging.info("Sending Discord webhook!") + + try: + wh = Webhook(webhook_url, tts=False, username="LESS Score Server") + wh.add_embed(embed) + await wh.post() + except Exception: + logging.exception( + "Failed to send Discord webhook", + extra={"embed": embed}, + ) + + +def schedule_hook(*, webhook_url: str | None, embed: Embed) -> None: + """Performs a hook execution in a non-blocking manner.""" + + if not webhook_url: + return None + + job_scheduling.schedule_job(wrap_hook(webhook_url, embed)) + + logging.debug("Scheduled the performing of a discord webhook!") + return None + + +def beatmap_status_change( + *, + old_beatmap: "AkatsukiBeatmap", + new_beatmap: "AkatsukiBeatmap", + action_taken: Literal["status_change", "frozen"], +) -> None: + embed = Embed(title="Beatmap Status/Freeze Change During Update!", color=EDIT_COL) + if action_taken == "status_change": + embed.description = f"Non-frozen {old_beatmap.embed} has just been changed from {old_beatmap.ranked.name} to {new_beatmap.ranked.name}!" + else: + embed.description = f"{new_beatmap.embed} has just been frozen in transit from {old_beatmap.ranked.name} to {new_beatmap.ranked.name}!" + embed.set_author(name="beatmaps-service", icon_url=EDIT_ICON) + embed.set_footer(text="This is an automated action performed by the server.") + + schedule_hook( + webhook_url=settings.DISCORD_BEATMAP_UPDATES_WEBHOOK_URL, + embed=embed, + ) diff --git a/app/adapters/osu_api_v1.py b/app/adapters/osu_api_v1.py new file mode 100644 index 0000000..08d4d14 --- /dev/null +++ b/app/adapters/osu_api_v1.py @@ -0,0 +1,88 @@ +import logging +import random +from datetime import datetime +from typing import Any + +import httpx +from pydantic import BaseModel + +from app import settings +from app.common_models import GameMode + +osu_api_v1_http_client = httpx.AsyncClient( + base_url="https://old.ppy.sh/api/", + timeout=httpx.Timeout(15), +) + + +class Beatmap(BaseModel): + approved: int + submit_date: datetime + approved_date: datetime | None + last_update: datetime + artist: str + beatmap_id: int + beatmapset_id: int + bpm: float | None + creator: str + creator_id: int + difficultyrating: float + diff_aim: float | None + diff_speed: float | None + diff_size: float + diff_overall: float + diff_approach: float + diff_drain: float + hit_length: int + source: str + genre_id: int + language_id: int + title: str + total_length: int + version: str + file_md5: str + mode: GameMode + tags: str + favourite_count: int + rating: float + playcount: int + passcount: int + count_normal: int + count_slider: int + count_spinner: int + max_combo: int | None + storyboard: int + video: int + download_unavailable: int + audio_unavailable: int + + +async def get_beatmap_by_id(beatmap_id: int) -> Beatmap | None: + osu_api_response_data: list[dict[str, Any]] | None = None + try: + response = await osu_api_v1_http_client.get( + "get_beatmaps", + params={ + "k": random.choice(settings.OSU_API_V1_API_KEYS_POOL), + "b": beatmap_id, + }, + ) + if response.status_code == 404: + return None + if response.status_code == 403: + raise ValueError("osu api is down") from None + response.raise_for_status() + osu_api_response_data = response.json() + if osu_api_response_data == []: + return None + assert osu_api_response_data is not None + return Beatmap(**osu_api_response_data[0]) + except Exception: + logging.exception( + "Failed to fetch beatmap from osu! API v1", + extra={ + "beatmap_id": beatmap_id, + "osu_api_response_data": osu_api_response_data, + }, + ) + raise diff --git a/app/adapters/osu_api_v2/api.py b/app/adapters/osu_api_v2/api.py index bc065be..fb38492 100644 --- a/app/adapters/osu_api_v2/api.py +++ b/app/adapters/osu_api_v2/api.py @@ -46,8 +46,11 @@ async def get_beatmap(beatmap_id: int) -> BeatmapExtended | None: return BeatmapExtended(**osu_api_response_data) except Exception: logging.exception( - "Failed to fetch beatmap from osu! API", - extra={"osu_api_response_data": osu_api_response_data}, + "Failed to fetch beatmap from osu! API v2", + extra={ + "beatmap_id": beatmap_id, + "osu_api_response_data": osu_api_response_data, + }, ) raise @@ -64,8 +67,11 @@ async def get_beatmapset(beatmapset_id: int) -> BeatmapsetExtended | None: return BeatmapsetExtended(**osu_api_response_data) except Exception: logging.exception( - "Failed to fetch beatmapset from osu! API", - extra={"osu_api_response_data": osu_api_response_data}, + "Failed to fetch beatmapset from osu! API v2", + extra={ + "beatmapset_id": beatmapset_id, + "osu_api_response_data": osu_api_response_data, + }, ) raise @@ -112,7 +118,24 @@ async def search_beatmapsets( return BeatmapsetSearchResponse(**osu_api_response_data) except Exception: logging.exception( - "Failed to fetch beatmapsets from osu! API", - extra={"osu_api_response_data": osu_api_response_data}, + "Failed to fetch beatmapsets from osu! API v2", + extra={ + "query": query, + "general_settings": ( + {s.name for s in general_settings} + if general_settings is not None + else None + ), + "extras": {s.name for s in extras} if extras is not None else None, + "mode": mode.name if mode is not None else None, + "category": category.name if category is not None else None, + "filter_nsfw": filter_nsfw, + "language_id": language_id.value if language_id is not None else None, + "genre_id": genre_id.value if genre_id is not None else None, + "sort_by": sort_by.name if sort_by else None, + "page": page, + "cursor_string": cursor_string, + "osu_api_response_data": osu_api_response_data, + }, ) raise diff --git a/app/api/internal/v1/__init__.py b/app/api/internal/v1/__init__.py index de874c2..6877a21 100644 --- a/app/api/internal/v1/__init__.py +++ b/app/api/internal/v1/__init__.py @@ -1,7 +1,9 @@ from fastapi import APIRouter +from . import akatsuki from . import osu_api_v2 v1_router = APIRouter() +v1_router.include_router(akatsuki.router) v1_router.include_router(osu_api_v2.router) diff --git a/app/api/internal/v1/akatsuki.py b/app/api/internal/v1/akatsuki.py new file mode 100644 index 0000000..39b3ed2 --- /dev/null +++ b/app/api/internal/v1/akatsuki.py @@ -0,0 +1,37 @@ +"""\ +Provides an API exposing Akatsuki's beatmaps, which +include internal state such as ranked status updates. +""" + +import logging + +from fastapi import APIRouter +from fastapi import Header +from fastapi import Response + +from app.api.responses import JSONResponse +from app.usecases import akatsuki_beatmaps + +router = APIRouter(tags=["Akatsuki Beatmaps"]) + + +@router.get("/api/akatsuki/v1/beatmaps/{beatmap_id}") +async def get_beatmap( + beatmap_id: int, + client_ip_address: str | None = Header(None, alias="X-Real-IP"), + client_user_agent: str | None = Header(None, alias="User-Agent"), +) -> Response: + beatmap = await akatsuki_beatmaps.fetch_one_by_id(beatmap_id) + if beatmap is None: + return Response(status_code=404) + + logging.info( + "Serving Akatsuki beatmap", + extra={ + "beatmap": beatmap.model_dump_json(), + "client_ip_address": client_ip_address, + "client_user_agent": client_user_agent, + }, + ) + + return JSONResponse(content=beatmap.model_dump()) diff --git a/app/job_scheduling.py b/app/job_scheduling.py new file mode 100644 index 0000000..7f77276 --- /dev/null +++ b/app/job_scheduling.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import asyncio +import sys +from collections.abc import Coroutine +from collections.abc import Generator +from typing import Any +from typing import TypeVar + +T = TypeVar("T") + +ACTIVE_TASKS: set[asyncio.Task[Any]] = set() + + +def schedule_job( + coro: Generator[Any, None, T] | Coroutine[Any, Any, T], +) -> None: + """\ + Run a coroutine to run in the background. + + This function is a wrapper around `asyncio.create_task` that adds the task + to a set of active tasks. This set is used to provide handling of any + exceptions that occur as well as to wait for all tasks to complete before + shutting down the application. + """ + task = asyncio.create_task(coro) + task.add_done_callback(_handle_task_completion) + _register_task(task) + return None + + +def _register_task(task: asyncio.Task[Any]) -> None: + ACTIVE_TASKS.add(task) + + +def _unregister_task(task: asyncio.Task[Any]) -> None: + ACTIVE_TASKS.remove(task) + + +def _handle_task_completion(task: asyncio.Task[Any]) -> None: + _unregister_task(task) + + if task.cancelled(): + return None + + try: + exception = task.exception() + except asyncio.InvalidStateError: + pass + else: + if exception is not None: + sys.excepthook( + type(exception), + exception, + exception.__traceback__, + ) + + +async def await_running_jobs( + timeout: float, +) -> tuple[set[asyncio.Task[Any]], set[asyncio.Task[Any]]]: + """\ + Await all tasks to complete, or until the timeout is reached. + + Returns a tuple of done and pending tasks. + """ + if not ACTIVE_TASKS: + return set(), set() + + done, pending = await asyncio.wait( + ACTIVE_TASKS, + timeout=timeout, + return_when=asyncio.ALL_COMPLETED, + ) + return done, pending diff --git a/app/oauth.py b/app/oauth.py index 0d71840..1decd76 100644 --- a/app/oauth.py +++ b/app/oauth.py @@ -78,6 +78,7 @@ async def async_auth_flow( ) response = yield request + # TODO: refactor this log to work with osu api v1, be more specific etc. logging.info( "Made oauth-authorized request", extra={ diff --git a/app/repositories/akatsuki_beatmaps.py b/app/repositories/akatsuki_beatmaps.py new file mode 100644 index 0000000..b49bdd7 --- /dev/null +++ b/app/repositories/akatsuki_beatmaps.py @@ -0,0 +1,188 @@ +from datetime import datetime +from datetime import timedelta + +from pydantic import BaseModel + +from app import state +from app.common_models import GameMode +from app.common_models import RankedStatus + + +class AkatsukiBeatmap(BaseModel): + beatmap_id: int + beatmapset_id: int + beatmap_md5: str + song_name: str + file_name: str + ar: float + od: float + mode: GameMode + max_combo: int + hit_length: int + bpm: int + ranked: RankedStatus + latest_update: int + ranked_status_freezed: bool + playcount: int + passcount: int + rankedby: int | None + rating: float + bancho_ranked_status: RankedStatus | None + count_circles: int | None + count_spinners: int | None + count_sliders: int | None + bancho_creator_id: int | None + bancho_creator_name: str | None + + @property + def deserves_update(self) -> bool: + match self.ranked: + case RankedStatus.QUALIFIED: + update_interval = timedelta(minutes=5) + case RankedStatus.PENDING: + update_interval = timedelta(minutes=10) + case RankedStatus.LOVED: + # loved maps can *technically* be updated + update_interval = timedelta(days=1) + case RankedStatus.RANKED | RankedStatus.APPROVED: + # in very rare cases, the osu! team has updated ranked/appvoed maps + # this is usually done to remove things like inappropriate content + update_interval = timedelta(days=1) + case _: + raise NotImplementedError(f"Unknown ranked status: {self.ranked}") + + last_updated = datetime.fromtimestamp(self.latest_update) + return last_updated <= (datetime.now() - update_interval) + + @property + def url(self) -> str: + return f"https://osu.ppy.sh/beatmaps/{self.beatmap_id}" + + @property + def set_url(self) -> str: + return f"https://osu.ppy.sh/beatmapsets/{self.beatmapset_id}" + + @property + def embed(self) -> str: + return f"[{self.url} {self.song_name}]" + + +async def fetch_one_by_id(beatmap_id: int, /) -> AkatsukiBeatmap | None: + query = """\ + SELECT * FROM beatmaps WHERE beatmap_id = :beatmap_id + """ + rec = await state.database.fetch_one(query, {"beatmap_id": beatmap_id}) + if rec is None: + return None + return AkatsukiBeatmap( + beatmap_id=rec["beatmap_id"], + beatmapset_id=rec["beatmapset_id"], + beatmap_md5=rec["beatmap_md5"], + song_name=rec["song_name"], + file_name=rec["file_name"], + ar=rec["ar"], + od=rec["od"], + mode=rec["mode"], + max_combo=rec["max_combo"], + hit_length=rec["hit_length"], + bpm=rec["bpm"], + ranked=rec["ranked"], + latest_update=rec["latest_update"], + ranked_status_freezed=rec["ranked_status_freezed"], + playcount=rec["playcount"], + passcount=rec["passcount"], + rankedby=rec["rankedby"], + rating=rec["rating"], + bancho_ranked_status=rec["bancho_ranked_status"], + count_circles=rec["count_circles"], + count_spinners=rec["count_spinners"], + count_sliders=rec["count_sliders"], + bancho_creator_id=rec["bancho_creator_id"], + bancho_creator_name=rec["bancho_creator_name"], + ) + + +async def create_or_replace(beatmap: AkatsukiBeatmap) -> AkatsukiBeatmap: + query = """\ + REPLACE INTO beatmaps ( + beatmap_id, beatmapset_id, beatmap_md5, song_name, file_name, + ar, od, mode, max_combo, hit_length, bpm, ranked, latest_update, + ranked_status_freezed, playcount, passcount, rankedby, rating, + bancho_ranked_status, count_circles, count_spinners, count_sliders, + bancho_creator_id, bancho_creator_name + ) + VALUES ( + :beatmap_id, :beatmapset_id, :beatmap_md5, :song_name, :file_name, + :ar, :od, :mode, :max_combo, :hit_length, :bpm, :ranked, :latest_update, + :ranked_status_freezed, :playcount, :passcount, :rankedby, :rating, + :bancho_ranked_status, :count_circles, :count_spinners, :count_sliders, + :bancho_creator_id, :bancho_creator_name + ) + """ + await state.database.execute( + query=query, + values={ + "beatmap_id": beatmap.beatmap_id, + "beatmapset_id": beatmap.beatmapset_id, + "beatmap_md5": beatmap.beatmap_md5, + "song_name": beatmap.song_name, + "file_name": beatmap.file_name, + "ar": beatmap.ar, + "od": beatmap.od, + "mode": beatmap.mode.value, + "max_combo": beatmap.max_combo, + "hit_length": beatmap.hit_length, + "bpm": beatmap.bpm, + "ranked": beatmap.ranked.value, + "latest_update": beatmap.latest_update, + "ranked_status_freezed": beatmap.ranked_status_freezed, + "playcount": beatmap.playcount, + "passcount": beatmap.passcount, + "rankedby": beatmap.rankedby, + "rating": beatmap.rating, + "bancho_ranked_status": ( + beatmap.bancho_ranked_status.value + if beatmap.bancho_ranked_status is not None + else None + ), + "count_circles": beatmap.count_circles, + "count_spinners": beatmap.count_spinners, + "count_sliders": beatmap.count_sliders, + "bancho_creator_id": beatmap.bancho_creator_id, + "bancho_creator_name": beatmap.bancho_creator_name, + }, + ) + rec = await state.database.fetch_one( + """\ + SELECT * FROM beatmaps WHERE beatmap_id = :beatmap_id + """, + {"beatmap_id": beatmap.beatmap_id}, + ) + assert rec is not None + + return AkatsukiBeatmap( + beatmap_id=rec["beatmap_id"], + beatmapset_id=rec["beatmapset_id"], + beatmap_md5=rec["beatmap_md5"], + song_name=rec["song_name"], + file_name=rec["file_name"], + ar=rec["ar"], + od=rec["od"], + mode=rec["mode"], + max_combo=rec["max_combo"], + hit_length=rec["hit_length"], + bpm=rec["bpm"], + ranked=rec["ranked"], + latest_update=rec["latest_update"], + ranked_status_freezed=rec["ranked_status_freezed"], + playcount=rec["playcount"], + passcount=rec["passcount"], + rankedby=rec["rankedby"], + rating=rec["rating"], + bancho_ranked_status=rec["bancho_ranked_status"], + count_circles=rec["count_circles"], + count_spinners=rec["count_spinners"], + count_sliders=rec["count_sliders"], + bancho_creator_id=rec["bancho_creator_id"], + bancho_creator_name=rec["bancho_creator_name"], + ) diff --git a/app/settings.py b/app/settings.py index e4d41fb..c8ca96c 100644 --- a/app/settings.py +++ b/app/settings.py @@ -18,8 +18,12 @@ def read_bool(s: str) -> bool: OSU_API_V2_CLIENT_ID = os.environ["OSU_API_V2_CLIENT_ID"] OSU_API_V2_CLIENT_SECRET = os.environ["OSU_API_V2_CLIENT_SECRET"] +OSU_API_V1_API_KEYS_POOL = os.environ["OSU_API_V1_API_KEY"].split(",") + DB_USER = os.environ["DB_USER"] DB_PASS = os.environ["DB_PASS"] DB_HOST = os.environ["DB_HOST"] DB_PORT = int(os.environ["DB_PORT"]) DB_NAME = os.environ["DB_NAME"] + +DISCORD_BEATMAP_UPDATES_WEBHOOK_URL = os.environ["DISCORD_BEATMAP_UPDATES_WEBHOOK_URL"] diff --git a/app/usecases/__init__.py b/app/usecases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/usecases/akatsuki_beatmaps.py b/app/usecases/akatsuki_beatmaps.py new file mode 100644 index 0000000..8aef4ad --- /dev/null +++ b/app/usecases/akatsuki_beatmaps.py @@ -0,0 +1,156 @@ +import time + +from app import state +from app.adapters import discord_webhooks +from app.adapters import osu_api_v1 +from app.common_models import RankedStatus +from app.repositories import akatsuki_beatmaps +from app.repositories.akatsuki_beatmaps import AkatsukiBeatmap + +IGNORED_BEATMAP_CHARS = dict.fromkeys(map(ord, r':\/*<>?"|'), None) +FROZEN_STATUSES = (RankedStatus.RANKED, RankedStatus.APPROVED, RankedStatus.LOVED) + + +def _parse_akatsuki_beatmap_from_osu_api_v1_response( + osu_api_beatmap: osu_api_v1.Beatmap, +) -> AkatsukiBeatmap: + filename = ( + ("{artist} - {title} ({creator}) [{version}].osu") + .format( + artist=osu_api_beatmap.artist, + title=osu_api_beatmap.title, + creator=osu_api_beatmap.creator, + version=osu_api_beatmap.version, + ) + .translate(IGNORED_BEATMAP_CHARS) + ) + + song_name = ( + ("{artist} - {title} [{version}]") + .format( + artist=osu_api_beatmap.artist, + title=osu_api_beatmap.title, + version=osu_api_beatmap.version, + ) + .translate(IGNORED_BEATMAP_CHARS) + ) + + bancho_ranked_status = RankedStatus.from_osu_api(osu_api_beatmap.approved) + frozen = bancho_ranked_status in FROZEN_STATUSES + + akatsuki_beatmap = AkatsukiBeatmap( + beatmap_md5=osu_api_beatmap.file_md5, + beatmap_id=osu_api_beatmap.beatmap_id, + beatmapset_id=osu_api_beatmap.beatmapset_id, + song_name=song_name, + ranked=bancho_ranked_status, + playcount=0, + passcount=0, + mode=osu_api_beatmap.mode, + od=osu_api_beatmap.diff_overall, + ar=osu_api_beatmap.diff_approach, + hit_length=osu_api_beatmap.hit_length, + latest_update=int(time.time()), + max_combo=osu_api_beatmap.max_combo or 0, + bpm=round(osu_api_beatmap.bpm) if osu_api_beatmap.bpm is not None else 0, + file_name=filename, + ranked_status_freezed=frozen, + rankedby=None, + rating=10.0, + bancho_ranked_status=bancho_ranked_status, + count_circles=osu_api_beatmap.count_normal, + count_sliders=osu_api_beatmap.count_slider, + count_spinners=osu_api_beatmap.count_spinner, + bancho_creator_id=osu_api_beatmap.creator_id, + bancho_creator_name=osu_api_beatmap.creator, + ) + return akatsuki_beatmap + + +async def _update_from_osu_api(old_beatmap: AkatsukiBeatmap) -> AkatsukiBeatmap | None: + if not old_beatmap.deserves_update: + return old_beatmap + + new_osu_api_v1_beatmap = await osu_api_v1.get_beatmap_by_id(old_beatmap.beatmap_id) + if new_osu_api_v1_beatmap is None: + # it's now unsubmitted! + + await state.database.execute( + "DELETE FROM beatmaps WHERE beatmap_md5 = :old_md5", + {"old_md5": old_beatmap.beatmap_md5}, + ) + + return None + + new_beatmap = _parse_akatsuki_beatmap_from_osu_api_v1_response( + new_osu_api_v1_beatmap, + ) + + # handle deleting the old beatmap etc. + if new_beatmap.beatmap_md5 != old_beatmap.beatmap_md5: + # delete any instances of the old map + await state.database.execute( + "DELETE FROM beatmaps WHERE beatmap_md5 = :old_md5", + {"old_md5": old_beatmap.beatmap_md5}, + ) + else: + # the map may have changed in some ways (e.g. ranked status), + # but we want to make sure to keep our stats, because the map + # is the same from the player's pov (hit objects, ar/od, etc.) + new_beatmap.playcount = old_beatmap.playcount + new_beatmap.passcount = old_beatmap.passcount + new_beatmap.rating = old_beatmap.rating + new_beatmap.rankedby = old_beatmap.rankedby + + if old_beatmap.ranked_status_freezed: + # if the previous version is status frozen + # we should force the old status on the new version + new_beatmap.ranked = old_beatmap.ranked + new_beatmap.ranked_status_freezed = True + new_beatmap.rankedby = old_beatmap.rankedby + elif old_beatmap.ranked != new_beatmap.ranked: + if new_beatmap.ranked is RankedStatus.PENDING and old_beatmap.ranked in { + RankedStatus.RANKED, + RankedStatus.APPROVED, + RankedStatus.LOVED, + }: + discord_webhooks.beatmap_status_change( + old_beatmap=old_beatmap, + new_beatmap=new_beatmap, + action_taken="frozen", + ) + new_beatmap.ranked = old_beatmap.ranked + new_beatmap.ranked_status_freezed = True + else: + discord_webhooks.beatmap_status_change( + old_beatmap=old_beatmap, + new_beatmap=new_beatmap, + action_taken="status_change", + ) + + new_beatmap.latest_update = int(time.time()) + + new_beatmap = await akatsuki_beatmaps.create_or_replace(new_beatmap) + + return new_beatmap + + +async def fetch_one_by_id(beatmap_id: int) -> AkatsukiBeatmap | None: + beatmap = await akatsuki_beatmaps.fetch_one_by_id(beatmap_id) + if beatmap is None: + osu_api_v1_beatmap = await osu_api_v1.get_beatmap_by_id(beatmap_id) + if osu_api_v1_beatmap is None: + return None + + new_beatmap = _parse_akatsuki_beatmap_from_osu_api_v1_response( + osu_api_v1_beatmap, + ) + beatmap = await akatsuki_beatmaps.create_or_replace(new_beatmap) + + elif beatmap.deserves_update: + beatmap = await _update_from_osu_api(beatmap) + if beatmap is None: + # (we may have deleted the map during the update) + return None + + return beatmap