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