Skip to content

Commit

Permalink
Expose an API for Akatsuki (DB) Beatmaps (#8)
Browse files Browse the repository at this point in the history
* A base for akatsuki beatmaps

* refactor to use osu api v1

* port logic from score-service

* api v1 modelling fixes

* way more infra

* re-enable webhooks

* remove todo

* better logs

* bugfix

* cleanup

* beatmaps-service

* support multiple osu api v1 keys

* add todo

* remove sample resp
  • Loading branch information
cmyui authored Jun 30, 2024
1 parent 9498c7c commit b24df35
Show file tree
Hide file tree
Showing 12 changed files with 805 additions and 6 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
221 changes: 221 additions & 0 deletions app/adapters/discord_webhooks.py
Original file line number Diff line number Diff line change
@@ -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,
)
88 changes: 88 additions & 0 deletions app/adapters/osu_api_v1.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 29 additions & 6 deletions app/adapters/osu_api_v2/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions app/api/internal/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit b24df35

Please sign in to comment.