1use 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, };
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}