Skip to main content

rosu_render/model/
render.rs

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/// A list of [`Render`].
16#[derive(Clone, Debug, Deserialize)]
17pub struct RenderList {
18    /// Array of renders returned by the api
19    pub renders: Vec<Render>,
20    /// The total number of renders on o!rdr,
21    /// but if search query the total numbers of renders corresponding to that query will be used.
22    #[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    /// The o!rdr error code the render failed with; 0 if it did not fail.
41    #[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    /// 720x480 (30fps)
90    #[serde(rename = "720x480")]
91    SD480,
92    /// 960x540 (30fps)
93    #[serde(rename = "960x540")]
94    SD960,
95    /// 1280x720 (60fps)
96    #[serde(rename = "1280x720")]
97    HD720,
98    /// 1920x1080 (60fps)
99    #[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/// Customize danser settings when rendering.
122#[derive(Clone, Debug, Deserialize, Serialize)]
123pub struct RenderOptions {
124    pub resolution: RenderResolution,
125    /// The global volume for the video, in percent, from 0 to 100.
126    #[serde(rename = "globalVolume")]
127    pub global_volume: u8,
128    /// The music volume for the video, in percent, from 0 to 100.
129    #[serde(rename = "musicVolume")]
130    pub music_volume: u8,
131    /// The hitsounds volume for the video, in percent, from 0 to 100.
132    #[serde(rename = "hitsoundVolume")]
133    pub hitsound_volume: u8,
134    /// Show the hit error meter.
135    #[serde(rename = "showHitErrorMeter")]
136    pub show_hit_error_meter: bool,
137    /// Show the unstable rate, only takes effect if `show_hit_error_meter` is set to true.
138    #[serde(rename = "showUnstableRate")]
139    pub show_unstable_rate: bool,
140    /// Show the score.
141    #[serde(rename = "showScore")]
142    pub show_score: bool,
143    /// Show the HP bar.
144    #[serde(rename = "showHPBar")]
145    pub show_hp_bar: bool,
146    /// Show the combo counter.
147    #[serde(rename = "showComboCounter")]
148    pub show_combo_counter: bool,
149    /// Show the PP Counter or not.
150    #[serde(rename = "showPPCounter")]
151    pub show_pp_counter: bool,
152    /// Show the scoreboard or not.
153    #[serde(rename = "showScoreboard")]
154    pub show_scoreboard: bool,
155    /// Show the playfield borders or not.
156    #[serde(rename = "showBorders")]
157    pub show_borders: bool,
158    /// Show the mods used during the game or not.
159    #[serde(rename = "showMods")]
160    pub show_mods: bool,
161    /// Show the result screen or not.
162    #[serde(rename = "showResultScreen")]
163    pub show_result_screen: bool,
164    /// Use the skin cursor or not. If not, danser cursor will be used.
165    #[serde(rename = "useSkinCursor")]
166    pub use_skin_cursor: bool,
167    /// Use the skin combo colors or not.
168    #[serde(rename = "useSkinColors")]
169    pub use_skin_colors: bool,
170    /// Use skin hitsounds, if false beatmap hitsounds will be used.
171    #[serde(rename = "useSkinHitsounds")]
172    pub use_skin_hitsounds: bool,
173    /// Use the beatmap combo colors or not, overrides useSkinColors if true.
174    #[serde(rename = "useBeatmapColors")]
175    pub use_beatmap_colors: bool,
176    /// Scale cursor to circle size. Does not do anything at the moment.
177    #[serde(rename = "cursorScaleToCS")]
178    pub cursor_scale_to_cs: bool,
179    /// Makes the cursor rainbow, only takes effect if `use_skin_cursor` is set to false.
180    #[serde(rename = "cursorRainbow")]
181    pub cursor_rainbow: bool,
182    /// Have a glow with the trail or not.
183    #[serde(rename = "cursorTrailGlow")]
184    pub cursor_trail_glow: bool,
185    /// Draw follow points between objects or not.
186    #[serde(rename = "drawFollowPoints")]
187    pub draw_follow_points: bool,
188    /// Scale objects to the beat.
189    #[serde(rename = "scaleToTheBeat")]
190    pub beat_scaling: bool,
191    /// Merge sliders or not.
192    #[serde(rename = "sliderMerge")]
193    pub slider_merge: bool,
194    /// Makes the objects rainbow, overrides `use_skin_colors` and `use_beatmap_colors`.
195    #[serde(rename = "objectsRainbow")]
196    pub objects_rainbow: bool,
197    /// Makes the objects flash to the beat.
198    #[serde(rename = "objectsFlashToTheBeat")]
199    pub flash_objects: bool,
200    /// Makes the slider body have the same color as the hit circles.
201    #[serde(rename = "useHitCircleColor")]
202    pub use_slider_hitcircle_color: bool,
203    /// Display a 5 second seizure warning before the video.
204    #[serde(rename = "seizureWarning")]
205    pub seizure_warning: bool,
206    /// Load the background storyboard.
207    #[serde(rename = "loadStoryboard")]
208    pub load_storyboard: bool,
209    /// Load the background video (`load_storyboard` has to be set to true).
210    #[serde(rename = "loadVideo")]
211    pub load_video: bool,
212    /// Background dim for the intro, in percent, from 0 to 100.
213    #[serde(rename = "introBGDim")]
214    pub intro_bg_dim: u8,
215    /// Background dim in game, in percent, from 0 to 100.
216    #[serde(rename = "inGameBGDim")]
217    pub ingame_bg_dim: u8,
218    /// Background dim in break, in percent, from 0 to 100.
219    #[serde(rename = "breakBGDim")]
220    pub break_bg_dim: u8,
221    /// Adds a parallax effect.
222    #[serde(rename = "BGParallax")]
223    pub bg_parallax: bool,
224    /// Show danser logo on the intro.
225    #[serde(rename = "showDanserLogo")]
226    pub show_danser_logo: bool,
227    /// Skip the intro or not.
228    #[serde(rename = "skip")]
229    pub skip_intro: bool,
230    /// Show cursor ripples when keypress.
231    #[serde(rename = "cursorRipples")]
232    pub cursor_ripples: bool,
233    /// Set the cursor size, multiplier from 0.5 to 2.
234    #[serde(rename = "cursorSize")]
235    pub cursor_size: f32,
236    /// Show the cursor trail or not.
237    #[serde(rename = "cursorTrail")]
238    pub cursor_trail: bool,
239    /// Show the combo numbers in objects.
240    #[serde(rename = "drawComboNumbers")]
241    pub draw_combo_numbers: bool,
242    /// Have slider snaking in.
243    #[serde(rename = "sliderSnakingIn")]
244    pub slider_snaking_in: bool,
245    /// Have slider snaking out.
246    #[serde(rename = "sliderSnakingOut")]
247    pub slider_snaking_out: bool,
248    /// Shows a hit counter (100, 50, miss) below the PP counter.
249    #[serde(rename = "showHitCounter")]
250    pub show_hit_counter: bool,
251    /// Show the key overlay or not.
252    #[serde(rename = "showKeyOverlay")]
253    pub show_key_overlay: bool,
254    /// Show avatars on the left of the username of a player on the scoreboard.
255    /// May break some skins because the width of the scoreboard increases.
256    #[serde(rename = "showAvatarsOnScoreboard")]
257    pub show_avatars_on_scoreboard: bool,
258    /// Show the Aim Error Meter or not.
259    #[serde(rename = "showAimErrorMeter")]
260    pub show_aim_error_meter: bool,
261    /// Play nightcore hitsounds or not.
262    #[serde(rename = "playNightcoreSamples")]
263    pub play_nightcore_samples: bool,
264    /// Show the strain graph or not.
265    #[serde(rename = "showStrainGraph")]
266    pub show_strain_graph: bool,
267    /// Show the slider breaks count in the hit counter.
268    #[serde(rename = "showSliderBreaks")]
269    pub show_slider_breaks: bool,
270    /// Ignores fail in the replay or not.
271    #[serde(rename = "ignoreFail")]
272    pub ignore_fail: bool,
273    /// For verified bots, supplying the requester's Discord ID allows the o!rdr
274    /// server to set their favorite preset if they have one saved and set to be
275    /// used with bots in their issou.best account.
276    #[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        // we skip serialization if the option is `None`
301        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    /// `Render` fields are deserialized as borrowed strings, so the sample
525    /// goes through `serde_json::from_str` rather than `from_value`.
526    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}