cbg_core/
osu.rs

1//! # osu
2//! osu stuff is in here  
3//! embask in the glorious ~400 lines of code i've written
4//!
5//! you
6use moka::future::Cache;
7use reqwest::Client as Reqwest;
8use rosu_v2::prelude::*;
9use serde::{Deserialize, Serialize};
10use std::{num::ParseIntError, sync::Arc};
11use thiserror::Error;
12use tokio::time::Duration;
13
14#[derive(Debug, Error)]
15pub enum Error {
16    #[error("osu!api error! {0}")]
17    OsuApi(#[from] rosu_v2::error::OsuError),
18
19    #[error("http error! {0}")]
20    Http(#[from] reqwest::Error),
21
22    #[error("env var error! {0}")]
23    EnvVar(#[from] std::env::VarError),
24
25    #[error("parse int error! {0}")]
26    ParseInt(#[from] ParseIntError),
27
28    #[error("not found! {0}")]
29    NotFound(String),
30}
31
32pub type OsuResult<T> = Result<T, Error>;
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct OsuUser {
36    pub id: u32,
37    pub username: String,
38    pub country_code: String,
39
40    pub pp: Option<f32>,
41    pub global_rank: Option<u32>,
42    pub country_rank: Option<u32>,
43    pub accuracy: Option<f32>,
44    pub play_count: Option<u32>,
45    pub level: Option<f32>,
46
47    pub avatar_url: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct OsuBeatmap {
52    pub id: u32,
53    pub artist: String,
54    pub title: String,
55    pub creator: String,
56    pub game_mode: String,
57    pub version: String,
58
59    pub stars: f32,
60    pub bpm: f32,
61    pub ar: f32,
62    pub cs: f32,
63    pub hp: f32,
64    pub od: f32,
65
66    pub max_combo: u32,
67    pub play_count: u32,
68    pub is_scoreable: bool,
69    pub hit_objects: u32,
70
71    #[serde(skip)]
72    pub background_image: Option<Vec<u8>>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct OsuScore {
77    pub id: u64,
78    pub score: u32,
79    pub max_combo: u32,
80    pub perfect: bool,
81    pub mods: String,
82    pub pp: Option<f32>,
83    pub rank: String,
84    pub accuracy: f32,
85
86    pub user: OsuUser,
87    pub beatmap_id: Option<u32>,
88    pub beatmap: Option<OsuBeatmap>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct BeatmapScores {
93    pub beatmap: OsuBeatmap,
94    pub scores: Vec<OsuScore>,
95}
96
97#[derive(Debug, Clone, Eq, PartialEq, Hash)]
98pub enum UserIdentifier {
99    Id(u32),
100    Username(String),
101}
102
103impl From<&User> for OsuUser {
104    fn from(user: &User) -> Self {
105        let stats = user.statistics.as_ref();
106        Self {
107            id: user.user_id,
108            username: user.username.clone().into_string(),
109            country_code: user.country_code.clone().into_string(),
110            pp: stats.map(|s| s.pp),
111            global_rank: stats.and_then(|s| s.global_rank),
112            country_rank: stats.and_then(|s| s.country_rank),
113            accuracy: stats.map(|s| s.accuracy),
114            play_count: stats.map(|s| s.playcount),
115            level: stats.map(|s| s.level.current as f32),
116            avatar_url: user.avatar_url.clone(),
117        }
118    }
119}
120
121impl From<&UserExtended> for OsuUser {
122    fn from(user: &UserExtended) -> Self {
123        let stats = user.statistics.as_ref();
124        Self {
125            id: user.user_id,
126            username: user.username.clone().into_string(),
127            country_code: user.country_code.clone().into_string(),
128            pp: stats.map(|s| s.pp),
129            global_rank: stats.and_then(|s| s.global_rank),
130            country_rank: stats.and_then(|s| s.country_rank),
131            accuracy: stats.map(|s| s.accuracy),
132            play_count: stats.map(|s| s.playcount),
133            level: stats.map(|s| s.level.current as f32),
134            avatar_url: user.avatar_url.clone(),
135        }
136    }
137}
138
139impl OsuBeatmap {
140    fn from_beatmap(beatmap: &BeatmapExtended) -> Self {
141        let (artist, title, creator) = if let Some(set) = &beatmap.mapset {
142            (
143                set.artist.clone(),
144                set.title.clone(),
145                set.creator_name.clone().to_string(),
146            )
147        } else {
148            ("???".into(), "???".into(), "???".into())
149        };
150
151        Self {
152            id: beatmap.map_id,
153            artist,
154            title,
155            creator,
156            version: beatmap.version.clone(),
157            game_mode: beatmap.mode.to_string(),
158            stars: beatmap.stars,
159            bpm: beatmap.bpm,
160            ar: beatmap.ar,
161            cs: beatmap.cs,
162            hp: beatmap.hp,
163            od: beatmap.od,
164            max_combo: beatmap.max_combo.unwrap_or(0),
165            play_count: beatmap.playcount,
166            is_scoreable: beatmap.is_scoreable,
167            hit_objects: beatmap.count_objects(),
168            background_image: None,
169        }
170    }
171}
172
173pub struct OsuClient {
174    osu: Arc<Osu>,
175    http: Reqwest,
176    beatmap_cache: Cache<u32, OsuBeatmap>,
177    user_cache: Cache<UserIdentifier, OsuUser>,
178}
179
180impl OsuClient {
181    pub async fn new(client_id: u64, client_secret: String, user_agent: String) -> OsuResult<Self> {
182        let osu = Osu::new(client_id, client_secret).await?;
183        let http = Reqwest::builder().user_agent(user_agent).build()?;
184
185        Ok(Self {
186            osu: Arc::new(osu),
187            http,
188            beatmap_cache: Cache::builder()
189                .max_capacity(1_000)
190                .time_to_live(Duration::from_secs(60 * 60))
191                .build(),
192
193            user_cache: Cache::builder()
194                .max_capacity(10_000)
195                .time_to_live(Duration::from_secs(60 * 60))
196                .build(),
197        })
198    }
199
200    pub async fn from_env() -> OsuResult<Self> {
201        let client_id = std::env::var("OSU_CLIENT_ID")?.parse()?;
202        let secret = std::env::var("OSU_CLIENT_SECRET")?;
203        let user_agent = std::env::var("USER_AGENT")?;
204        Self::new(client_id, secret, user_agent).await
205    }
206
207    pub async fn get_user(&self, id: UserIdentifier) -> OsuResult<OsuUser> {
208        if let Some(cached) = self.user_cache.get(&id).await {
209            return Ok(cached);
210        }
211
212        let user = match &id {
213            UserIdentifier::Id(uid) => self.osu.user(*uid).await?,
214            UserIdentifier::Username(name) => self.osu.user(name).await?,
215        };
216
217        let osu_user: OsuUser = (&user).into();
218        self.user_cache.insert(id, osu_user.clone()).await;
219
220        Ok(osu_user)
221    }
222
223    pub async fn get_beatmap(&self, beatmap_id: u32) -> OsuResult<OsuBeatmap> {
224        if let Some(beatmap) = self.beatmap_cache.get(&beatmap_id).await {
225            return Ok(beatmap);
226        }
227
228        let beatmap = self.osu.beatmap().map_id(beatmap_id).await?;
229        let mut converted = OsuBeatmap::from_beatmap(&beatmap);
230
231        if let Ok(resp) = self
232            .http
233            .get(format!(
234                "https://catboy.best/preview/background/{beatmap_id}"
235            ))
236            .send()
237            .await?
238            .error_for_status()
239        {
240            converted.background_image = Some(resp.bytes().await?.to_vec());
241        }
242
243        self.beatmap_cache
244            .insert(beatmap_id, converted.clone())
245            .await;
246
247        Ok(converted)
248    }
249
250    pub async fn get_beatmap_scores(&self, beatmap_id: u32) -> OsuResult<BeatmapScores> {
251        let beatmap = self.get_beatmap(beatmap_id).await?;
252        let scores = self.osu.beatmap_scores(beatmap_id).await?;
253
254        let mut converted_scores = Vec::new();
255
256        for score in scores.scores {
257            let user_id = score.user.as_ref().map(|u| u.user_id);
258            let user = match user_id {
259                Some(id) => self.get_user(UserIdentifier::Id(id)).await?,
260                None => continue, // skip if no user
261            };
262
263            converted_scores.push(OsuScore {
264                id: score.id,
265                score: score.score,
266                max_combo: score.max_combo,
267                perfect: score.is_perfect_combo,
268                mods: score.mods.to_string(),
269                pp: score.pp,
270                rank: score.grade.to_string(),
271                accuracy: score.accuracy,
272                user,
273                beatmap_id: Some(beatmap.id),
274                beatmap: None,
275            });
276        }
277
278        Ok(BeatmapScores {
279            beatmap,
280            scores: converted_scores,
281        })
282    }
283
284    #[cfg_attr(not(feature = "discord"), allow(dead_code))]
285    pub async fn get_user_scores(
286        &self,
287        user: UserIdentifier,
288        kind: ScoreType,
289        limit: Option<usize>,
290    ) -> OsuResult<Vec<OsuScore>> {
291        let uid = match &user {
292            UserIdentifier::Id(id) => *id,
293            UserIdentifier::Username(name) => {
294                self.get_user(UserIdentifier::Username(name.to_string()))
295                    .await?
296                    .id
297            }
298        };
299
300        let scores = match kind {
301            ScoreType::Best => self.osu.user_scores(uid).best().await?,
302            ScoreType::Recent => self.osu.user_scores(uid).recent().await?,
303            ScoreType::Firsts => self.osu.user_scores(uid).firsts().await?,
304        };
305
306        let take = limit.unwrap_or(10);
307        let mut converted = Vec::new();
308
309        for score in scores.into_iter().take(take) {
310            let user = self.get_user(UserIdentifier::Id(uid)).await?;
311            converted.push(OsuScore {
312                id: score.id,
313                score: score.score,
314                max_combo: score.max_combo,
315                perfect: score.is_perfect_combo,
316                mods: score.mods.to_string(),
317                pp: score.pp,
318                rank: score.grade.to_string(),
319                accuracy: score.accuracy,
320                user,
321                beatmap_id: Some(score.map_id),
322                beatmap: None,
323            });
324        }
325
326        Ok(converted)
327    }
328}
329
330impl From<u32> for UserIdentifier {
331    fn from(id: u32) -> Self {
332        Self::Id(id)
333    }
334}
335impl From<&str> for UserIdentifier {
336    fn from(name: &str) -> Self {
337        Self::Username(name.to_owned())
338    }
339}
340impl From<String> for UserIdentifier {
341    fn from(name: String) -> Self {
342        Self::Username(name)
343    }
344}
345
346#[cfg(feature = "discord")]
347#[derive(Debug, Clone, poise::ChoiceParameter)]
348pub enum ScoreType {
349    #[name = "best scores"]
350    Best,
351    #[name = "recent scores"]
352    Recent,
353    #[name = "firsts"]
354    Firsts,
355}
356
357#[cfg(not(feature = "discord"))]
358#[derive(Debug, Clone)]
359pub enum ScoreType {
360    Best,
361    Recent,
362    Firsts,
363}