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