1use std::{
2 borrow::Cow,
3 fmt::{Display, Formatter, Result as FmtResult},
4};
5
6use hyper::{body::Bytes, StatusCode};
7use serde::{
8 de::{Error as DeError, IgnoredAny, MapAccess, Unexpected, Visitor},
9 Deserialize, Deserializer, Serialize,
10};
11use time::OffsetDateTime;
12
13use crate::{request::Requestable, util::datetime::deserialize_datetime, ClientError};
14
15#[derive(Clone, Debug, Deserialize)]
17pub struct RenderList {
18 pub renders: Vec<Render>,
20 #[serde(rename = "maxRenders")]
23 pub max_renders: u32,
24}
25
26impl Requestable for RenderList {
27 fn response_error(status: StatusCode, bytes: Bytes) -> ClientError {
28 ClientError::response_error(bytes, status.as_u16())
29 }
30}
31
32#[derive(Clone, Debug, Deserialize)]
33pub struct Render {
34 #[serde(rename = "renderID")]
35 pub id: u32,
36 #[serde(deserialize_with = "deserialize_datetime")]
37 pub date: OffsetDateTime,
38 pub username: Box<str>,
39 pub progress: Box<str>,
40 pub renderer: Box<str>,
41 pub description: Box<str>,
42 pub title: Box<str>,
43 #[serde(rename = "isBot")]
44 pub is_bot: bool,
45 #[serde(rename = "isVerified")]
46 pub is_verified: bool,
47 #[serde(rename = "videoUrl")]
48 pub video_url: Box<str>,
49 #[serde(rename = "mapLink")]
50 pub map_link: Box<str>,
51 #[serde(rename = "mapTitle")]
52 pub map_title: Box<str>,
53 #[serde(rename = "replayDifficulty")]
54 pub replay_difficulty: Box<str>,
55 #[serde(rename = "replayUsername")]
56 pub replay_username: Box<str>,
57 #[serde(rename = "mapID")]
58 pub map_id: u32,
59 #[serde(rename = "needToRedownload")]
60 pub need_to_redownload: bool,
61 #[serde(rename = "motionBlur960fps")]
62 pub motion_blur: bool,
63 #[serde(rename = "renderStartTime", deserialize_with = "deserialize_datetime")]
64 pub render_start_time: OffsetDateTime,
65 #[serde(rename = "renderEndTime", deserialize_with = "deserialize_datetime")]
66 pub render_end_time: OffsetDateTime,
67 #[serde(rename = "uploadEndTime", deserialize_with = "deserialize_datetime")]
68 pub upload_end_time: OffsetDateTime,
69 #[serde(rename = "renderTotalTime")]
70 pub render_total_time: u32,
71 #[serde(rename = "uploadTotalTime")]
72 pub upload_total_time: u32,
73 #[serde(rename = "mapLength")]
74 pub map_length: u32,
75 #[serde(rename = "replayMods")]
76 pub replay_mods: Box<str>,
77 pub removed: bool,
78 #[serde(flatten)]
79 pub options: RenderOptions,
80 #[serde(flatten)]
81 pub skin: RenderSkinOption<'static>,
82}
83
84#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
85pub enum RenderResolution {
86 #[serde(rename = "720x480")]
88 SD480,
89 #[serde(rename = "960x540")]
91 SD960,
92 #[serde(rename = "1280x720")]
94 HD720,
95 #[serde(rename = "1920x1080")]
97 HD1080,
98}
99
100impl RenderResolution {
101 #[must_use]
102 pub fn as_str(self) -> &'static str {
103 match self {
104 Self::SD480 => "720x480",
105 Self::SD960 => "960x540",
106 Self::HD720 => "1280x720",
107 Self::HD1080 => "1920x1080",
108 }
109 }
110}
111
112impl Display for RenderResolution {
113 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
114 f.write_str(self.as_str())
115 }
116}
117
118#[derive(Clone, Debug, Deserialize, Serialize)]
120pub struct RenderOptions {
121 pub resolution: RenderResolution,
122 #[serde(rename = "globalVolume")]
124 pub global_volume: u8,
125 #[serde(rename = "musicVolume")]
127 pub music_volume: u8,
128 #[serde(rename = "hitsoundVolume")]
130 pub hitsound_volume: u8,
131 #[serde(rename = "showHitErrorMeter")]
133 pub show_hit_error_meter: bool,
134 #[serde(rename = "showUnstableRate")]
136 pub show_unstable_rate: bool,
137 #[serde(rename = "showScore")]
139 pub show_score: bool,
140 #[serde(rename = "showHPBar")]
142 pub show_hp_bar: bool,
143 #[serde(rename = "showComboCounter")]
145 pub show_combo_counter: bool,
146 #[serde(rename = "showPPCounter")]
148 pub show_pp_counter: bool,
149 #[serde(rename = "showScoreboard")]
151 pub show_scoreboard: bool,
152 #[serde(rename = "showBorders")]
154 pub show_borders: bool,
155 #[serde(rename = "showMods")]
157 pub show_mods: bool,
158 #[serde(rename = "showResultScreen")]
160 pub show_result_screen: bool,
161 #[serde(rename = "useSkinCursor")]
163 pub use_skin_cursor: bool,
164 #[serde(rename = "useSkinColors")]
166 pub use_skin_colors: bool,
167 #[serde(rename = "useSkinHitsounds")]
169 pub use_skin_hitsounds: bool,
170 #[serde(rename = "useBeatmapColors")]
172 pub use_beatmap_colors: bool,
173 #[serde(rename = "cursorScaleToCS")]
175 pub cursor_scale_to_cs: bool,
176 #[serde(rename = "cursorRainbow")]
178 pub cursor_rainbow: bool,
179 #[serde(rename = "cursorTrailGlow")]
181 pub cursor_trail_glow: bool,
182 #[serde(rename = "drawFollowPoints")]
184 pub draw_follow_points: bool,
185 #[serde(rename = "scaleToTheBeat")]
187 pub beat_scaling: bool,
188 #[serde(rename = "sliderMerge")]
190 pub slider_merge: bool,
191 #[serde(rename = "objectsRainbow")]
193 pub objects_rainbow: bool,
194 #[serde(rename = "objectsFlashToTheBeat")]
196 pub flash_objects: bool,
197 #[serde(rename = "useHitCircleColor")]
199 pub use_slider_hitcircle_color: bool,
200 #[serde(rename = "seizureWarning")]
202 pub seizure_warning: bool,
203 #[serde(rename = "loadStoryboard")]
205 pub load_storyboard: bool,
206 #[serde(rename = "loadVideo")]
208 pub load_video: bool,
209 #[serde(rename = "introBGDim")]
211 pub intro_bg_dim: u8,
212 #[serde(rename = "inGameBGDim")]
214 pub ingame_bg_dim: u8,
215 #[serde(rename = "breakBGDim")]
217 pub break_bg_dim: u8,
218 #[serde(rename = "BGParallax")]
220 pub bg_parallax: bool,
221 #[serde(rename = "showDanserLogo")]
223 pub show_danser_logo: bool,
224 #[serde(rename = "skip")]
226 pub skip_intro: bool,
227 #[serde(rename = "cursorRipples")]
229 pub cursor_ripples: bool,
230 #[serde(rename = "cursorSize")]
232 pub cursor_size: f32,
233 #[serde(rename = "cursorTrail")]
235 pub cursor_trail: bool,
236 #[serde(rename = "drawComboNumbers")]
238 pub draw_combo_numbers: bool,
239 #[serde(rename = "sliderSnakingIn")]
241 pub slider_snaking_in: bool,
242 #[serde(rename = "sliderSnakingOut")]
244 pub slider_snaking_out: bool,
245 #[serde(rename = "showHitCounter")]
247 pub show_hit_counter: bool,
248 #[serde(rename = "showKeyOverlay")]
250 pub show_key_overlay: bool,
251 #[serde(rename = "showAvatarsOnScoreboard")]
254 pub show_avatars_on_scoreboard: bool,
255 #[serde(rename = "showAimErrorMeter")]
257 pub show_aim_error_meter: bool,
258 #[serde(rename = "playNightcoreSamples")]
260 pub play_nightcore_samples: bool,
261 #[serde(rename = "showStrainGraph")]
263 pub show_strain_graph: bool,
264 #[serde(rename = "showSliderBreaks")]
266 pub show_slider_breaks: bool,
267 #[serde(rename = "ignoreFail")]
269 pub ignore_fail: bool,
270 #[serde(
274 rename = "discordUserId",
275 default,
276 skip_serializing_if = "Option::is_none",
277 with = "maybe_u64_as_str"
278 )]
279 pub discord_user_id: Option<u64>,
280}
281
282mod maybe_u64_as_str {
283 use serde::{
284 de::{Deserialize, Deserializer, Error},
285 Serializer,
286 };
287
288 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
289 match Option::<&str>::deserialize(d)? {
290 Some(s) => s.parse().map(Some).map_err(Error::custom),
291 None => Ok(None),
292 }
293 }
294
295 #[expect(clippy::ref_option, reason = "required by serde")]
296 pub fn serialize<S: Serializer>(opt: &Option<u64>, s: S) -> Result<S::Ok, S::Error> {
297 let Some(n) = opt else { unreachable!() };
299
300 s.serialize_str(&n.to_string())
301 }
302}
303
304impl RenderOptions {
305 pub const DEFAULT_RESOLUTION: RenderResolution = RenderResolution::HD720;
306}
307
308impl Default for RenderOptions {
309 fn default() -> Self {
310 Self {
311 resolution: Self::DEFAULT_RESOLUTION,
312 global_volume: 50,
313 music_volume: 50,
314 hitsound_volume: 50,
315 show_hit_error_meter: true,
316 show_unstable_rate: true,
317 show_score: true,
318 show_hp_bar: true,
319 show_combo_counter: true,
320 show_pp_counter: true,
321 show_key_overlay: true,
322 show_scoreboard: true,
323 show_borders: true,
324 show_mods: true,
325 show_result_screen: true,
326 use_skin_cursor: true,
327 use_skin_colors: false,
328 use_skin_hitsounds: true,
329 use_beatmap_colors: true,
330 cursor_scale_to_cs: false,
331 cursor_rainbow: false,
332 cursor_trail_glow: false,
333 draw_follow_points: true,
334 draw_combo_numbers: true,
335 cursor_size: 1.0,
336 cursor_trail: true,
337 beat_scaling: false,
338 slider_merge: false,
339 objects_rainbow: false,
340 flash_objects: false,
341 use_slider_hitcircle_color: false,
342 seizure_warning: false,
343 load_storyboard: false,
344 load_video: false,
345 intro_bg_dim: 0,
346 ingame_bg_dim: 80,
347 break_bg_dim: 30,
348 bg_parallax: false,
349 show_danser_logo: true,
350 skip_intro: true,
351 cursor_ripples: false,
352 slider_snaking_in: true,
353 slider_snaking_out: true,
354 show_hit_counter: true,
355 show_avatars_on_scoreboard: false,
356 show_aim_error_meter: false,
357 play_nightcore_samples: true,
358 show_strain_graph: false,
359 show_slider_breaks: false,
360 ignore_fail: false,
361 discord_user_id: None,
362 }
363 }
364}
365
366#[derive(Clone, Debug, PartialEq, Eq)]
367pub enum RenderSkinOption<'a> {
368 Official { name: Cow<'a, str> },
369 Custom { id: u32 },
370}
371
372impl Default for RenderSkinOption<'_> {
373 fn default() -> Self {
374 Self::Official {
375 name: "default".into(),
376 }
377 }
378}
379
380impl From<u32> for RenderSkinOption<'_> {
381 fn from(id: u32) -> Self {
382 Self::Custom { id }
383 }
384}
385
386macro_rules! impl_from_name {
387 ( $( $ty:ty ),* ) => {
388 $(
389 impl<'a> From<$ty> for RenderSkinOption<'a> {
390 fn from(name: $ty) -> Self {
391 Self::Official { name: name.into() }
392 }
393 }
394 )*
395 };
396}
397
398impl_from_name!(&'a str, &'a String, String, Cow<'a, str>);
399
400impl<'de> Deserialize<'de> for RenderSkinOption<'static> {
401 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
402 struct SkinVisitor;
403
404 impl<'de> Visitor<'de> for SkinVisitor {
405 type Value = RenderSkinOption<'static>;
406
407 fn expecting(&self, f: &mut Formatter<'_>) -> FmtResult {
408 f.write_str("`skin` and `customSkin` fields")
409 }
410
411 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
412 let mut custom_skin: Option<bool> = None;
413 let mut skin: Option<String> = None;
414
415 while let Some(key) = map.next_key()? {
416 match key {
417 "customSkin" => custom_skin = Some(map.next_value()?),
418 "skin" => skin = Some(map.next_value()?),
419 _ => {
420 let _: IgnoredAny = map.next_value()?;
421 }
422 }
423 }
424
425 let custom_skin =
426 custom_skin.ok_or_else(|| DeError::missing_field("customSkin"))?;
427 let skin = skin.ok_or_else(|| DeError::missing_field("skin"))?;
428
429 let skin = if custom_skin {
430 let id = skin
431 .parse()
432 .map_err(|_| DeError::invalid_value(Unexpected::Str(&skin), &"a u32"))?;
433
434 RenderSkinOption::Custom { id }
435 } else {
436 RenderSkinOption::Official {
437 name: Cow::Owned(skin),
438 }
439 };
440
441 Ok(skin)
442 }
443 }
444
445 d.deserialize_map(SkinVisitor)
446 }
447}
448
449#[derive(Clone, Debug, Deserialize, PartialEq)]
450pub struct RenderServers {
451 pub servers: Vec<RenderServer>,
452}
453
454impl Requestable for RenderServers {
455 fn response_error(status: StatusCode, bytes: Bytes) -> ClientError {
456 ClientError::response_error(bytes, status.as_u16())
457 }
458}
459
460#[derive(Clone, Debug, Deserialize, PartialEq)]
461pub struct RenderServer {
462 pub enabled: bool,
463 #[serde(rename = "lastSeen", deserialize_with = "deserialize_datetime")]
464 pub last_seen: OffsetDateTime,
465 pub name: Box<str>,
466 pub priority: f32,
467 #[serde(rename = "oldScore")]
468 pub old_score: f32,
469 #[serde(rename = "avgFPS")]
470 pub avg_fps: u32,
471 pub power: Box<str>,
472 pub status: Box<str>,
473 #[serde(rename = "totalRendered")]
474 pub total_rendered: u32,
475 #[serde(rename = "renderingType")]
476 pub rendering_type: Box<str>,
477 pub cpu: Box<str>,
478 pub gpu: Box<str>,
479 #[serde(rename = "motionBlurCapable")]
480 pub motion_blur_capable: bool,
481 #[serde(rename = "usingOsuApi")]
482 pub using_osu_api: bool,
483 #[serde(rename = "uhdCapable")]
484 pub uhd_capable: bool,
485 #[serde(rename = "avgRenderTime")]
486 pub avg_render_time: f32,
487 #[serde(rename = "avgUploadTime")]
488 pub avg_upload_time: f32,
489 #[serde(rename = "totalAvgTime")]
490 pub total_avg_time: f32,
491 #[serde(rename = "totalUploadedVideosSize")]
492 pub total_uploaded_videos_size: u32,
493 #[serde(rename = "ownerUserId")]
494 pub owner_user_id: u32,
495 #[serde(rename = "ownerUsername")]
496 pub owner_username: Box<str>,
497 pub customization: RenderServerOptions,
498}
499
500#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
501pub struct RenderServerOptions {
502 #[serde(rename = "textColor")]
503 pub text_color: Box<str>,
504 #[serde(rename = "backgroundType")]
505 pub background_type: i32,
506}
507
508#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
509pub struct ServerOnlineCount(pub u32);
510
511impl Requestable for ServerOnlineCount {
512 fn response_error(status: StatusCode, bytes: Bytes) -> ClientError {
513 ClientError::response_error(bytes, status.as_u16())
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 fn sample_render_json() -> String {
524 let json = sample_render_value();
525
526 serde_json::to_string(&json).unwrap()
527 }
528
529 fn sample_render_value() -> serde_json::Value {
530 let mut json = serde_json::json!({
531 "renderID": 42,
532 "date": 1_700_000_000_123_i64,
533 "username": "username",
534 "progress": "100",
535 "renderer": "renderer",
536 "description": "description",
537 "title": "title",
538 "isBot": false,
539 "isVerified": false,
540 "videoUrl": "https://example.com/video.mp4",
541 "mapLink": "maplink",
542 "mapTitle": "maptitle",
543 "replayDifficulty": "4.5",
544 "replayUsername": "player",
545 "mapID": 1,
546 "needToRedownload": false,
547 "motionBlur960fps": false,
548 "renderStartTime": 1_700_000_000_123_i64,
549 "renderEndTime": 1_700_000_060_123_i64,
550 "uploadEndTime": 1_700_000_070_123_i64,
551 "renderTotalTime": 60,
552 "uploadTotalTime": 10,
553 "mapLength": 180,
554 "replayMods": "HDHR",
555 "removed": false,
556 "discordUserId": serde_json::Value::Null,
557 "skin": "Kuro",
558 "customSkin": false,
559 });
560
561 let options = serde_json::to_value(RenderOptions::default()).unwrap();
562
563 json.as_object_mut().unwrap().extend(
564 options
565 .as_object()
566 .unwrap()
567 .iter()
568 .map(|(k, v)| (k.clone(), v.clone())),
569 );
570
571 json
572 }
573
574 #[test]
575 fn render_deserialize() {
576 let render: Render = serde_json::from_str(&sample_render_json()).unwrap();
577
578 let expected_date =
579 OffsetDateTime::from_unix_timestamp_nanos(1_700_000_000_123_i128 * 1_000_000).unwrap();
580
581 assert_eq!(render.id, 42);
582 assert_eq!(render.video_url.as_ref(), "https://example.com/video.mp4");
583 assert_eq!(render.date, expected_date);
584 assert_eq!(render.options.resolution, RenderResolution::HD720);
585 assert_eq!(
586 render.skin,
587 RenderSkinOption::Official {
588 name: "Kuro".into()
589 }
590 );
591 }
592
593 #[test]
594 fn render_deserialize_without_discord_user_id() {
595 let mut json = sample_render_value();
596
597 json.as_object_mut().unwrap().remove("discordUserId");
598
599 let render: Render = serde_json::from_str(&serde_json::to_string(&json).unwrap()).unwrap();
600
601 assert_eq!(render.options.discord_user_id, None);
602 }
603
604 #[test]
605 fn render_list_deserialize() {
606 let json = serde_json::json!({
607 "renders": [sample_render_value()],
608 "maxRenders": 1,
609 });
610
611 let list: RenderList =
612 serde_json::from_str(&serde_json::to_string(&json).unwrap()).unwrap();
613
614 assert_eq!(list.max_renders, 1);
615 assert_eq!(list.renders.len(), 1);
616 assert_eq!(list.renders[0].id, 42);
617 }
618}