diff --git a/Cargo.lock b/Cargo.lock index 1897b68..8707751 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1815,6 +1815,8 @@ name = "youmubot-osu" version = "0.1.0" dependencies = [ "chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)", + "reqwest 0.9.24 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.103 (registry+https://github.com/rust-lang/crates.io-index)", "serenity 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)", ] diff --git a/youmubot-osu/Cargo.toml b/youmubot-osu/Cargo.toml index 48194f5..77e1b7e 100644 --- a/youmubot-osu/Cargo.toml +++ b/youmubot-osu/Cargo.toml @@ -9,3 +9,5 @@ edition = "2018" [dependencies] serenity = "0.7" chrono = "0.4.10" +reqwest = "0.9.24" +serde = { version = "1.0", features = ["derive"] } diff --git a/youmubot-osu/src/lib.rs b/youmubot-osu/src/lib.rs index 96b8b32..7213555 100644 --- a/youmubot-osu/src/lib.rs +++ b/youmubot-osu/src/lib.rs @@ -1,206 +1,37 @@ -extern crate chrono; +pub mod models; -pub mod models { - use chrono::{DateTime, Duration, Utc}; - pub enum ApprovalStatus { - Loved, - Qualified, - Approved, - Ranked(DateTime), - Pending, - WIP, - Graveyarded, - } - pub struct Difficulty { - pub stars: f64, - pub aim: f64, - pub speed: f64, +pub mod request; - pub cs: f64, - pub od: f64, - pub ar: f64, - pub hp: f64, +use models::*; +use request::builders::*; +use request::*; +use reqwest::Client as HTTPClient; +use serenity::framework::standard::CommandError as Error; - pub count_normal: u64, - pub count_slider: u64, - pub count_spinner: u64, - pub max_combo: u64, - } - pub enum Genre { - Any, - Unspecified, - VideoGame, - Anime, - Rock, - Pop, - Other, - Novelty, - HipHop, - Electronic, - } - pub enum Language { - Any, - Other, - English, - Japanese, - Chinese, - Instrumental, - Korean, - French, - German, - Swedish, - Italian, - } - pub enum Mode { - Std, - Taiko, - Mania, - Catch, - } - pub struct Beatmap { - // Beatmapset info - pub approval: ApprovalStatus, - pub submit_date: DateTime, - pub last_update: DateTime, - pub download_available: bool, - pub audio_available: bool, - // Media metadata - pub artist: String, - pub title: String, - pub beatmapset_id: u64, - pub bpm: f64, - pub creator: String, - pub creator_id: u64, - pub source: Option, - pub genre: Genre, - pub language: Language, - pub tags: Vec, - // Beatmap information - pub beatmap_id: u64, - pub difficulty_name: String, - pub difficulty: Difficulty, - pub drain_length: Duration, - pub total_length: Duration, - pub file_hash: String, - pub mode: Mode, - pub favourite_count: u64, - pub rating: f64, - pub play_count: u64, - pub pass_count: u64, - } - - pub struct UserEvent { - pub display_html: String, - pub beatmap_id: u64, - pub beatmapset_id: u64, - pub date: DateTime, - pub epic_factor: u8, - } - - pub struct User { - pub id: u64, - pub username: String, - pub joined: DateTime, - pub country: String, - // History - pub count_300: u64, - pub count_100: u64, - pub count_50: u64, - pub play_count: u64, - pub played_time: Duration, - pub ranked_score: u64, - pub total_score: u64, - pub count_ss: u64, - pub count_ssh: u64, - pub count_s: u64, - pub count_sh: u64, - pub count_a: u64, - pub events: Vec, - // Rankings - pub rank: u64, - pub level: f64, - pub pp: Option, - pub accuracy: f64, - } - - pub enum Rank { - SS, - SSH, - S, - SH, - A, - B, - C, - D, - F, - } - - pub struct Score { - pub id: u64, - pub username: String, - pub user_id: u64, - pub date: DateTime, - pub replay_available: bool, - - pub score: u64, - pub pp: f64, - pub rank: Rank, - pub mods: u64, // Later - - pub count_300: u64, - pub count_100: u64, - pub count_50: u64, - pub count_miss: u64, - pub count_katu: u64, - pub count_geki: u64, - pub max_combo: u64, - pub perfect: bool, - } - - pub mod request { - use super::Mode; - use chrono::{DateTime, Utc}; - pub enum UserID { - Username(String), - ID(u64), - Auto(String), - } - pub enum BeatmapRequestKind { - User(UserID), - Beatmap(u64), - Beatmapset(u64), - BeatmapHash(String), - } - pub struct BeatmapRequest { - pub since: DateTime, - pub kind: BeatmapRequestKind, - pub mode: Option, - pub converted: bool, - } - pub struct UserRequest { - pub user: UserID, - pub mode: Option, - pub event_days: Option, - } - pub struct ScoreRequest { - pub beatmap_id: u64, - pub user: Option, - pub mode: Option, - pub mods: u64, // Later - } - pub struct UserBestRequest { - pub user: UserID, - pub mode: Option, - } - pub struct UserRecentRequest { - pub user: UserID, - pub mode: Option, - } - } +/// Client is the client that will perform calls to the osu! api server. +pub struct Client { + key: String, } -#[cfg(test)] -mod test { - #[test] - fn test() {} +impl Client { + /// Create a new client from the given API key. + pub fn new(key: impl AsRef) -> Client { + Client { + key: key.as_ref().to_string(), + } + } + + pub fn beatmaps( + &self, + client: &HTTPClient, + kind: BeatmapRequestKind, + f: impl FnOnce(BeatmapRequestBuilder) -> BeatmapRequestBuilder, + ) -> Result, Error> { + let res = f(BeatmapRequestBuilder::new(kind)) + .build(client) + .query(&[("k", &self.key)]) + .send()? + .json()?; + Ok(res) + } } diff --git a/youmubot-osu/src/models/deser.rs b/youmubot-osu/src/models/deser.rs new file mode 100644 index 0000000..8c5adeb --- /dev/null +++ b/youmubot-osu/src/models/deser.rs @@ -0,0 +1,160 @@ +use super::*; +use chrono::{ + format::{parse, Item, Numeric, Pad, Parsed}, + DateTime, Duration, Utc, +}; +use serde::{de, Deserialize, Deserializer}; +use std::str::FromStr; + +impl<'de> Deserialize<'de> for Beatmap { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw: raw::Beatmap = raw::Beatmap::deserialize(deserializer)?; + Ok(Beatmap { + approval: parse_approval_status(&raw)?, + submit_date: parse_date(&raw.submit_date)?, + last_update: parse_date(&raw.last_update)?, + download_available: !(parse_bool(&raw.download_unavailable)?), + audio_available: !(parse_bool(&raw.audio_unavailable)?), + artist: raw.artist, + beatmap_id: parse_from_str(&raw.beatmap_id)?, + beatmapset_id: parse_from_str(&raw.beatmapset_id)?, + title: raw.title, + bpm: parse_from_str(&raw.bpm)?, + creator: raw.creator, + creator_id: parse_from_str(&raw.creator_id)?, + source: raw.source, + genre: parse_genre(&raw.genre_id)?, + language: parse_language(&raw.language_id)?, + tags: raw.tags.split_whitespace().map(|v| v.to_owned()).collect(), + difficulty_name: raw.version, + difficulty: Difficulty { + stars: parse_from_str(&raw.difficultyrating)?, + aim: parse_from_str(&raw.diff_aim)?, + speed: parse_from_str(&raw.diff_speed)?, + cs: parse_from_str(&raw.diff_size)?, + od: parse_from_str(&raw.diff_overall)?, + ar: parse_from_str(&raw.diff_approach)?, + hp: parse_from_str(&raw.diff_drain)?, + count_normal: parse_from_str(&raw.count_normal)?, + count_slider: parse_from_str(&raw.count_slider)?, + count_spinner: parse_from_str(&raw.count_spinner)?, + max_combo: parse_from_str(&raw.max_combo)?, + }, + drain_length: parse_duration(&raw.hit_length)?, + total_length: parse_duration(&raw.total_length)?, + file_hash: raw.file_md5, + mode: parse_mode(&raw.mode)?, + favourite_count: parse_from_str(&raw.favourite_count)?, + rating: parse_from_str(&raw.rating)?, + play_count: parse_from_str(&raw.playcount)?, + pass_count: parse_from_str(&raw.passcount)?, + }) + } +} + +fn parse_mode(s: impl AsRef) -> Result { + let t: u8 = parse_from_str(s)?; + use Mode::*; + Ok(match t { + 0 => Std, + 1 => Taiko, + 2 => Mania, + 3 => Catch, + _ => return Err(E::custom(format!("invalid value {} for mode", t))), + }) +} + +fn parse_language(s: impl AsRef) -> Result { + let t: u8 = parse_from_str(s)?; + use Language::*; + Ok(match t { + 0 => Any, + 1 => Other, + 2 => English, + 3 => Japanese, + 4 => Chinese, + 5 => Instrumental, + 6 => Korean, + 7 => French, + 8 => German, + 9 => Swedish, + 10 => Spanish, + 11 => Italian, + _ => return Err(E::custom(format!("Invalid value {} for language", t))), + }) +} + +fn parse_genre(s: impl AsRef) -> Result { + let t: u8 = parse_from_str(s)?; + use Genre::*; + Ok(match t { + 0 => Any, + 1 => Unspecified, + 2 => VideoGame, + 3 => Anime, + 4 => Rock, + 5 => Pop, + 6 => Other, + 7 => Novelty, + 9 => HipHop, + 10 => Electronic, + _ => return Err(E::custom(format!("Invalid value {} for genre", t))), + }) +} + +fn parse_duration(s: impl AsRef) -> Result { + Ok(Duration::seconds(parse_from_str(s)?)) +} + +fn parse_from_str(s: impl AsRef) -> Result { + T::from_str(s.as_ref()).map_err(|_| E::custom(format!("Invalid value {}", s.as_ref()))) +} + +fn parse_bool(b: impl AsRef) -> Result { + match b.as_ref() { + "1" => Ok(true), + "0" => Ok(false), + _ => Err(E::custom("Invalid value for bool")), + } +} + +fn parse_approval_status(b: &raw::Beatmap) -> Result { + use ApprovalStatus::*; + Ok(match &b.approved[..] { + "4" => Loved, + "3" => Qualified, + "2" => Approved, + "1" => Ranked(parse_date(&b.approved_date)?), + "0" => Pending, + "-1" => WIP, + "-2" => Graveyarded, + _ => return Err(E::custom("Invalid value for approval status")), + }) +} + +fn parse_date(date: impl AsRef) -> Result, E> { + let mut parsed = Parsed::new(); + parse( + &mut parsed, + date.as_ref(), + (&[ + Item::Numeric(Numeric::Year, Pad::Zero), + Item::Literal("/"), + Item::Numeric(Numeric::Month, Pad::Zero), + Item::Literal("/"), + Item::Numeric(Numeric::Day, Pad::Zero), + Item::Space(""), + Item::Numeric(Numeric::Hour, Pad::Zero), + Item::Literal("-"), + Item::Numeric(Numeric::Minute, Pad::Zero), + Item::Literal("-"), + Item::Numeric(Numeric::Second, Pad::Zero), + ]) + .iter(), + ) + .map_err(E::custom)?; + parsed.to_datetime_with_timezone(&Utc {}).map_err(E::custom) +} diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs new file mode 100644 index 0000000..38afff4 --- /dev/null +++ b/youmubot-osu/src/models/mod.rs @@ -0,0 +1,177 @@ +use chrono::{DateTime, Duration, Utc}; +use std::string::ToString; + +pub mod deser; +pub(crate) mod raw; + +#[derive(Debug)] +pub enum ApprovalStatus { + Loved, + Qualified, + Approved, + Ranked(DateTime), + Pending, + WIP, + Graveyarded, +} + +#[derive(Debug)] +pub struct Difficulty { + pub stars: f64, + pub aim: f64, + pub speed: f64, + + pub cs: f64, + pub od: f64, + pub ar: f64, + pub hp: f64, + + pub count_normal: u64, + pub count_slider: u64, + pub count_spinner: u64, + pub max_combo: u64, +} + +#[derive(Debug)] +pub enum Genre { + Any, + Unspecified, + VideoGame, + Anime, + Rock, + Pop, + Other, + Novelty, + HipHop, + Electronic, +} + +#[derive(Debug)] +pub enum Language { + Any, + Other, + English, + Japanese, + Chinese, + Instrumental, + Korean, + French, + German, + Swedish, + Spanish, + Italian, +} +#[derive(Clone, Copy, Debug)] +pub enum Mode { + Std, + Taiko, + Mania, + Catch, +} + +impl ToString for Mode { + fn to_string(&self) -> String { + (*self as u64).to_string() + } +} + +#[derive(Debug)] +pub struct Beatmap { + // Beatmapset info + pub approval: ApprovalStatus, + pub submit_date: DateTime, + pub last_update: DateTime, + pub download_available: bool, + pub audio_available: bool, + // Media metadata + pub artist: String, + pub title: String, + pub beatmapset_id: u64, + pub bpm: f64, + pub creator: String, + pub creator_id: u64, + pub source: Option, + pub genre: Genre, + pub language: Language, + pub tags: Vec, + // Beatmap information + pub beatmap_id: u64, + pub difficulty_name: String, + pub difficulty: Difficulty, + pub drain_length: Duration, + pub total_length: Duration, + pub file_hash: String, + pub mode: Mode, + pub favourite_count: u64, + pub rating: f64, + pub play_count: u64, + pub pass_count: u64, +} + +pub struct UserEvent { + pub display_html: String, + pub beatmap_id: u64, + pub beatmapset_id: u64, + pub date: DateTime, + pub epic_factor: u8, +} + +pub struct User { + pub id: u64, + pub username: String, + pub joined: DateTime, + pub country: String, + // History + pub count_300: u64, + pub count_100: u64, + pub count_50: u64, + pub play_count: u64, + pub played_time: Duration, + pub ranked_score: u64, + pub total_score: u64, + pub count_ss: u64, + pub count_ssh: u64, + pub count_s: u64, + pub count_sh: u64, + pub count_a: u64, + pub events: Vec, + // Rankings + pub rank: u64, + pub level: f64, + pub pp: Option, + pub accuracy: f64, +} + +pub enum Rank { + SS, + SSH, + S, + SH, + A, + B, + C, + D, + F, +} + +pub struct Score { + pub id: u64, + pub username: String, + pub user_id: u64, + pub date: DateTime, + pub replay_available: bool, + + pub score: u64, + pub pp: f64, + pub rank: Rank, + pub mods: u64, // Later + + pub count_300: u64, + pub count_100: u64, + pub count_50: u64, + pub count_miss: u64, + pub count_katu: u64, + pub count_geki: u64, + pub max_combo: u64, + pub perfect: bool, +} diff --git a/youmubot-osu/src/models/raw.rs b/youmubot-osu/src/models/raw.rs new file mode 100644 index 0000000..d1056ca --- /dev/null +++ b/youmubot-osu/src/models/raw.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub(crate) struct Beatmap { + pub approved: String, + pub submit_date: String, + pub approved_date: String, + pub last_update: String, + pub artist: String, + pub beatmap_id: String, + pub beatmapset_id: String, + pub bpm: String, + pub creator: String, + pub creator_id: String, + pub difficultyrating: String, + pub diff_aim: String, + pub diff_speed: String, + pub diff_size: String, + pub diff_overall: String, + pub diff_approach: String, + pub diff_drain: String, + pub hit_length: String, + pub source: Option, + pub genre_id: String, + pub language_id: String, + pub title: String, + pub total_length: String, + pub version: String, + pub file_md5: String, + pub mode: String, + pub tags: String, + pub favourite_count: String, + pub rating: String, + pub playcount: String, + pub passcount: String, + pub count_normal: String, + pub count_slider: String, + pub count_spinner: String, + pub max_combo: String, + pub download_unavailable: String, + pub audio_unavailable: String, +} diff --git a/youmubot-osu/src/request.rs b/youmubot-osu/src/request.rs new file mode 100644 index 0000000..515b7eb --- /dev/null +++ b/youmubot-osu/src/request.rs @@ -0,0 +1,137 @@ +use crate::models::Mode; +use chrono::{DateTime, Utc}; +use reqwest::{Client, RequestBuilder}; + +trait ToQuery { + fn to_query(&self) -> Vec<(&'static str, String)>; +} + +impl ToQuery for Option { + fn to_query(&self) -> Vec<(&'static str, String)> { + match self { + Some(ref v) => v.to_query(), + None => vec![], + } + } +} + +impl ToQuery for Mode { + fn to_query(&self) -> Vec<(&'static str, String)> { + vec![("m", (*self as u8).to_string())] + } +} + +impl ToQuery for (&'static str, String) { + fn to_query(&self) -> Vec<(&'static str, String)> { + vec![(self.0, self.1.clone())] + } +} + +impl ToQuery for (&'static str, DateTime) { + fn to_query(&self) -> Vec<(&'static str, String)> { + vec![(self.0, format!("{}", self.1.date().format("%Y-%m-%d")))] + } +} + +pub enum UserID { + Username(String), + ID(u64), + Auto(String), +} + +impl ToQuery for UserID { + fn to_query(&self) -> Vec<(&'static str, String)> { + use UserID::*; + match self { + Username(ref s) => vec![("u", s.clone()), ("type", "string".to_owned())], + ID(u) => vec![("u", u.to_string()), ("type", "id".to_owned())], + Auto(ref s) => vec![("u", s.clone())], + } + } +} +pub enum BeatmapRequestKind { + ByUser(UserID), + Beatmap(u64), + Beatmapset(u64), + BeatmapHash(String), +} + +impl ToQuery for BeatmapRequestKind { + fn to_query(&self) -> Vec<(&'static str, String)> { + use BeatmapRequestKind::*; + match self { + ByUser(ref u) => u.to_query(), + Beatmap(b) => vec![("b", b.to_string())], + Beatmapset(s) => vec![("s", s.to_string())], + BeatmapHash(ref h) => vec![("h", h.clone())], + } + } +} + +pub mod builders { + use super::*; + /// A builder for a Beatmap request. + pub struct BeatmapRequestBuilder { + kind: BeatmapRequestKind, + since: Option>, + mode: Mode, + converted: bool, + } + impl BeatmapRequestBuilder { + pub(crate) fn new(kind: BeatmapRequestKind) -> Self { + BeatmapRequestBuilder { + kind, + since: None, + mode: Mode::Std, + converted: false, + } + } + + pub fn since(&mut self, since: DateTime) -> &Self { + self.since = Some(since); + self + } + + pub fn mode(&mut self, mode: Mode, converted: bool) -> &Self { + self.mode = mode; + self.converted = converted; + self + } + + pub(crate) fn build(self, client: &Client) -> RequestBuilder { + client + .get("https://osu.ppy.sh/api/get_beatmaps") + .query(&self.kind.to_query()) + .query(&self.since.map(|v| ("since", v)).to_query()) + .query(&self.mode.to_query()) + .query( + &(if self.converted { + Some(("a", "1".to_owned())) + } else { + None + }) + .to_query(), + ) + } + } +} + +pub struct UserRequest { + pub user: UserID, + pub mode: Option, + pub event_days: Option, +} +pub struct ScoreRequest { + pub beatmap_id: u64, + pub user: Option, + pub mode: Option, + pub mods: u64, // Later +} +pub struct UserBestRequest { + pub user: UserID, + pub mode: Option, +} +pub struct UserRecentRequest { + pub user: UserID, + pub mode: Option, +}