1use 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 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 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
334impl 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}