cbg_core/
osu.rs

1// tried vibe coding this geniunely the ai was so dumb how can someone vibe code and push fullstack apps????
2// tho the structure was pretty good aand ehh lgtm
3use rosu_v2::prelude::*;
4use serde::{Deserialize, Serialize};
5
6pub type OsuResult<T> = Result<T, crate::Error>;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct OsuUser {
10    pub id: u32,
11    pub username: String,
12    pub country_code: String,
13    pub pp: f32,
14    pub global_rank: Option<u32>,
15    pub country_rank: Option<u32>,
16    pub accuracy: f32,
17    pub play_count: u32,
18    pub level: f32,
19    pub avatar_url: String,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct OsuBeatmap {
24    pub id: u32,
25    pub artist: String,
26    pub title: String,
27    pub creator: String,
28    pub version: String,
29    pub stars: f32,
30    pub bpm: f32,
31    pub ar: f32,
32    pub cs: f32,
33    pub hp: f32,
34    pub od: f32,
35    pub max_combo: u32,
36    #[serde(skip)]
37    pub background_image: Option<Vec<u8>>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct OsuScore {
42    pub id: u64,
43    pub score: u32,
44    pub max_combo: u32,
45    pub perfect: bool,
46    pub mods: String,
47    pub pp: Option<f32>,
48    pub rank: String,
49    pub accuracy: f32,
50    pub user: OsuUser,
51    pub beatmap: Option<OsuBeatmap>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct BeatmapScores {
56    pub beatmap: OsuBeatmap,
57    pub scores: Vec<OsuScore>,
58}
59
60#[derive(Debug, Clone)]
61pub enum UserIdentifier {
62    Id(u32),
63    Username(String),
64}
65
66#[derive(Debug, Clone)]
67pub enum ScoreType {
68    Best,
69    Recent,
70    Firsts,
71}
72
73pub struct OsuClient {
74    client: Osu,
75}
76
77impl OsuClient {
78    pub async fn new(client_id: u64, client_secret: String) -> OsuResult<Self> {
79        let client = Osu::new(client_id, client_secret)
80            .await
81            .map_err(|e| format!("osu API authentication failed: {}", e))?;
82
83        Ok(Self { client })
84    }
85
86    pub async fn from_env() -> OsuResult<Self> {
87        let client_id = std::env::var("osu_CLIENT_ID")
88            .map_err(|_| "osu_CLIENT_ID environment variable not set")?
89            .parse()
90            .map_err(|_| "invalid osu_CLIENT_ID: must be a valid u64")?;
91
92        let client_secret = std::env::var("osu_CLIENT_SECRET")
93            .map_err(|_| "osu_CLIENT_SECRET environment variable not set")?;
94
95        Self::new(client_id, client_secret).await
96    }
97
98    pub async fn get_user(&self, identifier: UserIdentifier) -> OsuResult<OsuUser> {
99        let user = match identifier {
100            UserIdentifier::Id(id) => self.client.user(id).await,
101            UserIdentifier::Username(ref username) => self.client.user(username).await,
102        }
103        .map_err(|e| match e {
104            rosu_v2::error::OsuError::NotFound => {
105                format!("user not found: {:?}", identifier)
106            }
107            _ => format!("osu API error: {}", e),
108        })?;
109
110        Ok(OsuUser {
111            id: user.user_id as u32,
112            username: user.username.to_string(),
113            country_code: user.country_code.to_string(),
114            pp: user.statistics.as_ref().map(|s| s.pp).unwrap_or(0.0),
115            global_rank: user.statistics.as_ref().and_then(|s| s.global_rank),
116            country_rank: user.statistics.as_ref().and_then(|s| s.country_rank),
117            accuracy: user.statistics.as_ref().map(|s| s.accuracy).unwrap_or(0.0),
118            play_count: user.statistics.as_ref().map(|s| s.playcount).unwrap_or(0),
119            level: user
120                .statistics
121                .as_ref()
122                .map(|s| s.level.current as f32)
123                .unwrap_or(0.0),
124            avatar_url: user.avatar_url,
125        })
126    }
127
128    pub async fn get_beatmap(&self, beatmap_id: u32) -> OsuResult<OsuBeatmap> {
129        let beatmap = self
130            .client
131            .beatmap()
132            .map_id(beatmap_id)
133            .await
134            .map_err(|e| match e {
135                rosu_v2::error::OsuError::NotFound => {
136                    format!("beatmap not found: {}", beatmap_id)
137                }
138                _ => format!("osu API error: {}", e),
139            })?;
140
141        // get beatmapset for artist, title, and creator
142        let (artist, title, creator) = if let Some(beatmapset) = &beatmap.mapset {
143            (
144                beatmapset.artist.to_string(),
145                beatmapset.title.to_string(),
146                beatmapset.creator_name.to_string(),
147            )
148        } else {
149            ("???".to_string(), "???".to_string(), "???".to_string())
150        };
151
152        let image_request = reqwest::ClientBuilder::default()
153            .user_agent("contact@pastaya.net if im being too spammy")
154            .build()?
155            .get(format!(
156                "https://catboy.best/preview/background/{}",
157                beatmap_id
158            ))
159            .send()
160            .await?;
161
162        let mut image = None;
163        if image_request.status().is_success() {
164            let bytes = image_request.bytes().await?;
165            image = Some(bytes.to_vec());
166        }
167
168        Ok(OsuBeatmap {
169            id: beatmap.map_id as u32,
170            artist,
171            title,
172            creator,
173            version: beatmap.version,
174            stars: beatmap.stars,
175            bpm: beatmap.bpm,
176            ar: beatmap.ar,
177            cs: beatmap.cs,
178            hp: beatmap.hp,
179            od: beatmap.od,
180            max_combo: beatmap.max_combo.unwrap_or(0) as u32,
181            background_image: image,
182        })
183    }
184
185    pub async fn get_beatmap_scores(&self, beatmap_id: u32) -> OsuResult<BeatmapScores> {
186        let beatmap = self.get_beatmap(beatmap_id).await?;
187        let scores = self
188            .client
189            .beatmap_scores(beatmap_id)
190            .await
191            .map_err(|e| format!("Failed to fetch beatmap scores: {}", e))?;
192
193        let mut converted_scores = Vec::new();
194
195        for score in scores.scores {
196            if let Some(user) = score.user {
197                converted_scores.push(OsuScore {
198                    id: score.id,
199                    score: score.score as u32,
200                    max_combo: score.max_combo as u32,
201                    perfect: score.is_perfect_combo,
202                    mods: score.mods.to_string(),
203                    pp: score.pp,
204                    rank: score.grade.to_string(),
205                    accuracy: score.accuracy,
206                    user: OsuUser {
207                        id: user.user_id as u32,
208                        username: user.username.to_string(),
209                        country_code: user.country_code.to_string(),
210                        pp: user.statistics.as_ref().map(|s| s.pp).unwrap_or(0.0),
211                        global_rank: user.statistics.as_ref().and_then(|s| s.global_rank),
212                        country_rank: user.statistics.as_ref().and_then(|s| s.country_rank),
213                        accuracy: user.statistics.as_ref().map(|s| s.accuracy).unwrap_or(0.0),
214                        play_count: user.statistics.as_ref().map(|s| s.playcount).unwrap_or(0),
215                        level: user
216                            .statistics
217                            .as_ref()
218                            .map(|s| s.level.current as f32)
219                            .unwrap_or(0.0),
220                        avatar_url: user.avatar_url,
221                    },
222                    beatmap: None,
223                });
224            }
225        }
226
227        Ok(BeatmapScores {
228            beatmap,
229            scores: converted_scores,
230        })
231    }
232
233    pub async fn get_user_scores(
234        &self,
235        user_identifier: UserIdentifier,
236        score_type: ScoreType,
237        limit: Option<usize>,
238    ) -> OsuResult<Vec<OsuScore>> {
239        let user_id = match user_identifier {
240            UserIdentifier::Id(id) => id,
241            UserIdentifier::Username(username) => {
242                let user = self
243                    .client
244                    .user(username)
245                    .await
246                    .map_err(|e| format!("User not found: {}", e))?;
247                user.user_id as u32
248            }
249        };
250
251        let scores = match score_type {
252            ScoreType::Best => self.client.user_scores(user_id).best().await,
253            ScoreType::Recent => self.client.user_scores(user_id).recent().await,
254            ScoreType::Firsts => self.client.user_scores(user_id).firsts().await,
255        }
256        .map_err(|e| format!("Failed to fetch user scores: {}", e))?;
257
258        let limit = limit.unwrap_or(10);
259        let mut converted_scores = Vec::new();
260
261        for score in scores.into_iter().take(limit) {
262            if let Some(user) = score.user {
263                converted_scores.push(OsuScore {
264                    id: score.id,
265                    score: score.score as u32,
266                    max_combo: score.max_combo as u32,
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: OsuUser {
273                        id: user.user_id as u32,
274                        username: user.username.to_string(),
275                        country_code: user.country_code.to_string(),
276                        pp: user.statistics.as_ref().map(|s| s.pp).unwrap_or(0.0),
277                        global_rank: user.statistics.as_ref().and_then(|s| s.global_rank),
278                        country_rank: user.statistics.as_ref().and_then(|s| s.country_rank),
279                        accuracy: user.statistics.as_ref().map(|s| s.accuracy).unwrap_or(0.0),
280                        play_count: user.statistics.as_ref().map(|s| s.playcount).unwrap_or(0),
281                        level: user
282                            .statistics
283                            .as_ref()
284                            .map(|s| s.level.current as f32)
285                            .unwrap_or(0.0),
286                        avatar_url: user.avatar_url,
287                    },
288                    beatmap: None,
289                });
290            }
291        }
292
293        Ok(converted_scores)
294    }
295
296    // Utility methods for common operations
297    pub async fn search_user(&self, username: &str) -> OsuResult<OsuUser> {
298        self.get_user(UserIdentifier::Username(username.to_string()))
299            .await
300    }
301
302    pub async fn get_user_by_id(&self, user_id: u32) -> OsuResult<OsuUser> {
303        self.get_user(UserIdentifier::Id(user_id)).await
304    }
305
306    pub async fn get_user_best_scores(
307        &self,
308        user_identifier: UserIdentifier,
309        limit: Option<usize>,
310    ) -> OsuResult<Vec<OsuScore>> {
311        self.get_user_scores(user_identifier, ScoreType::Best, limit)
312            .await
313    }
314
315    pub async fn get_user_recent_scores(
316        &self,
317        user_identifier: UserIdentifier,
318        limit: Option<usize>,
319    ) -> OsuResult<Vec<OsuScore>> {
320        self.get_user_scores(user_identifier, ScoreType::Recent, limit)
321            .await
322    }
323
324    pub async fn get_user_first_places(
325        &self,
326        user_identifier: UserIdentifier,
327        limit: Option<usize>,
328    ) -> OsuResult<Vec<OsuScore>> {
329        self.get_user_scores(user_identifier, ScoreType::Firsts, limit)
330            .await
331    }
332}
333
334// Convenience trait implementations
335impl From<u32> for UserIdentifier {
336    fn from(id: u32) -> Self {
337        UserIdentifier::Id(id)
338    }
339}
340
341impl From<String> for UserIdentifier {
342    fn from(username: String) -> Self {
343        UserIdentifier::Username(username)
344    }
345}
346
347impl From<&str> for UserIdentifier {
348    fn from(username: &str) -> Self {
349        UserIdentifier::Username(username.to_string())
350    }
351}