etternaonline_api/v2/
mod.rs

1mod structs;
2pub use structs::*;
3
4use etterna::*;
5
6use crate::extension_traits::*;
7use crate::Error;
8
9fn difficulty_from_eo(string: &str) -> Result<etterna::Difficulty, Error> {
10	Ok(match string {
11		"Beginner" => Difficulty::Beginner,
12		"Easy" => Difficulty::Easy,
13		"Medium" => Difficulty::Medium,
14		"Hard" => Difficulty::Hard,
15		"Challenge" => Difficulty::Challenge,
16		"Edit" => Difficulty::Edit,
17		other => {
18			return Err(Error::InvalidDataStructure(format!(
19				"Unexpected difficulty name '{}'",
20				other
21			)))
22		}
23	})
24}
25
26fn parse_judgements(json: &serde_json::Value) -> Result<etterna::FullJudgements, Error> {
27	Ok(etterna::FullJudgements {
28		marvelouses: json["marvelous"].u32_()?,
29		perfects: json["perfect"].u32_()?,
30		greats: json["great"].u32_()?,
31		goods: json["good"].u32_()?,
32		bads: json["bad"].u32_()?,
33		misses: json["miss"].u32_()?,
34		hit_mines: json["hitMines"].u32_()?,
35		held_holds: json["heldHold"].u32_()?,
36		let_go_holds: json["letGoHold"].u32_()?,
37		missed_holds: json["missedHold"].u32_()?,
38	})
39}
40
41/// EtternaOnline API session client, handles all requests to and from EtternaOnline.
42///
43/// This wrapper keeps care of expiring tokens by automatically logging back in when the login
44/// token expires.
45///
46/// This session has rate-limiting built-in. Please do make use of it - the EO server is brittle and
47/// funded entirely by donations.
48///
49/// Initialize a session using [`Session::new_from_login`]
50///
51/// # Example
52/// ```rust,no_run
53/// # fn main() -> Result<(), etternaonline_api::Error> {
54/// # use etternaonline_api::v2::*;
55/// let mut session = Session::new_from_login(
56/// 	"<USERNAME>".into(),
57/// 	"<PASSWORD>".into(),
58/// 	"<CLIENT_DATA>".into(),
59/// 	std::time::Duration::from_millis(2000), // Wait 2s inbetween requests
60/// 	None, // No request timeout
61/// )?;
62///
63/// println!("Details about kangalioo: {:?}", session.user_details("kangalioo")?);
64///
65/// let best_score = &session.user_top_10_scores("kangalioo")?[0];
66/// println!(
67/// 	"kangalioo's best score has {} misses",
68/// 	session.score_data(&best_score.scorekey)?.judgements.misses
69/// );
70/// # Ok(()) }
71/// ```
72pub struct Session {
73	// This stuff is needed for re-login
74	username: String,
75	password: String,
76	client_data: String,
77
78	// The auth key that we get from the server on login
79	authorization: crate::common::AuthorizationManager<Option<String>>,
80
81	// Rate limiting stuff
82	last_request: std::sync::Mutex<std::time::Instant>,
83	cooldown: std::time::Duration,
84
85	timeout: Option<std::time::Duration>,
86}
87
88impl Session {
89	/// Initiate a new session by logging in using the specified credentials and API token.
90	///
91	/// Rate-limiting is done by waiting at least `rate_limit` inbetween requests
92	///
93	/// # Errors
94	/// - [`Error::InvalidLogin`] if username or password are wrong
95	///
96	/// # Example
97	/// ```rust,no_run
98	/// # fn main() -> Result<(), etternaonline_api::Error> {
99	/// # use etternaonline_api::v2::*;
100	/// let mut session = Session::new_from_login(
101	/// 	"kangalioo".into(),
102	/// 	"<PASSWORD>".into(),
103	/// 	"<CLIENT_DATA>".into(),
104	/// 	std::time::Duration::from_millis(2000), // wait 2s inbetween requests
105	/// 	None, // no timeout
106	/// )?;
107	///
108	/// println!("Details about kangalioo: {:?}", session.user_details("kangalioo"));
109	/// # Ok(()) }
110	/// ```
111	pub fn new_from_login(
112		username: String,
113		password: String,
114		client_data: String,
115		cooldown: std::time::Duration,
116		timeout: Option<std::time::Duration>,
117	) -> Result<Self, Error> {
118		let session = Self {
119			username,
120			password,
121			client_data,
122			cooldown,
123			timeout,
124			authorization: crate::common::AuthorizationManager::new(None),
125			last_request: std::sync::Mutex::new(std::time::Instant::now() - cooldown),
126		};
127		session.login()?;
128
129		Ok(session)
130	}
131
132	// login again to generate a new session token
133	// hmmm I wonder if there's a risk that the server won't properly generate a session token,
134	// return Unauthorized, and then my client will try to login to get a fresh token, and the
135	// process repeats indefinitely...? I just hope that the EO server never throws an Unauthorized
136	// on login
137	fn login(&self) -> Result<(), Error> {
138		self.authorization.refresh(|| {
139			let form: &[(&str, &str)] = &[
140				// eh fuck it. I dont wanna bother with those lifetime headaches
141				// who needs allocation efficiency anyways
142				("username", &self.username.clone()),
143				("password", &self.password.clone()),
144				("clientData", &self.client_data.clone()),
145			];
146
147			let json = self.generic_request(
148				"POST",
149				"login",
150				|mut request| request.send_form(form),
151				false,
152			)?;
153
154			Ok(Some(format!(
155				"Bearer {}",
156				json["attributes"]["accessToken"].str_()?,
157			)))
158		})
159	}
160
161	// If `do_authorization` is set, the authorization field will be locked immutably! So if the
162	// caller has a mutable lock active when calling generic_request, DONT PASS true FOR
163	// do_authorization, or we'll deadlock!
164	fn generic_request(
165		&self,
166		method: &str,
167		path: &str,
168		request_callback: impl Fn(ureq::Request) -> ureq::Response,
169		do_authorization: bool,
170	) -> Result<serde_json::Value, Error> {
171		// UNWRAP: propagate panics
172		crate::rate_limit(&mut *self.last_request.lock().unwrap(), self.cooldown);
173
174		let mut request = ureq::request(
175			method,
176			&format!("https://api.etternaonline.com/v2/{}", path),
177		);
178		if let Some(timeout) = self.timeout {
179			request.timeout(timeout);
180		}
181		if do_authorization {
182			let auth = self
183				.authorization
184				.get_authorization()
185				.as_ref()
186				.expect("No authorization set even though it was requested??")
187				.clone();
188			request.set("Authorization", &auth);
189		}
190
191		let response = request_callback(request);
192
193		if let Some(ureq::Error::Io(io_err)) = response.synthetic_error() {
194			if io_err.kind() == std::io::ErrorKind::TimedOut {
195				return Err(Error::Timeout);
196			}
197		}
198
199		let status = response.status();
200		let response = match response.into_string() {
201			Ok(response) => response,
202			Err(e) => {
203				return if e.to_string().contains("timed out reading response") {
204					// yes, there are two places where timeouts can happen :p
205					// see https://github.com/algesten/ureq/issues/119
206					Err(Error::Timeout)
207				} else {
208					Err(e.into())
209				};
210			}
211		};
212
213		if status >= 500 {
214			return Err(Error::ServerIsDown {
215				status_code: status,
216			});
217		}
218
219		if response.is_empty() {
220			return Err(Error::EmptyServerResponse);
221		}
222
223		// only parse json if the response code is not 5xx because on 5xx response codes, the server
224		// sometimes sends empty responses
225		let mut json: serde_json::Value = serde_json::from_str(&response)?;
226
227		// Error handling
228		if status >= 400 {
229			return match json["errors"][0]["title"].str_()? {
230				"Unauthorized" => {
231					// Token expired, let's login again and retry
232					self.login()?;
233					return self.generic_request(method, path, request_callback, do_authorization);
234				}
235				"Score not found" => Err(Error::ScoreNotFound),
236				"Chart not tracked" => Err(Error::ChartNotTracked),
237				"User not found" => Err(Error::UserNotFound),
238				"Favorite already exists" => Err(Error::ChartAlreadyFavorited),
239				"Database error" => Err(Error::DatabaseError),
240				"Goal already exist" => Err(Error::GoalAlreadyExists),
241				"Chart already exists" => Err(Error::ChartAlreadyAdded),
242				"Malformed XML file" => Err(Error::InvalidXml),
243				"No users found" => Err(Error::NoUsersFound),
244				other => Err(Error::UnknownApiError(other.to_owned())),
245			};
246		} else if status != 200 {
247			// TODO: should we have print calls in a library?
248			println!("Warning: status code {}", status);
249		}
250
251		Ok(json["data"].take())
252	}
253
254	fn request(
255		&self,
256		method: &str,
257		path: &str,
258		request_callback: impl Fn(ureq::Request) -> ureq::Response,
259	) -> Result<serde_json::Value, Error> {
260		self.generic_request(method, path, request_callback, true)
261	}
262
263	fn get(&self, path: &str) -> Result<serde_json::Value, Error> {
264		self.request("GET", path, |mut request| request.call())
265	}
266
267	/// Retrieves details about the profile of the specified user.
268	///
269	/// Note: the aboutMe field may be an empty string
270	///
271	/// # Errors
272	/// - [`Error::UserNotFound`] if the supplied username was not found
273	///
274	/// # Example
275	/// ```rust,no_run
276	/// # fn main() -> Result<(), etternaonline_api::Error> {
277	/// # use etternaonline_api::v2::*;
278	/// # let mut session: Session = unimplemented!();
279	/// // Retrieve details about user "kangalioo"
280	/// let details = session.user_details("kangalioo")?;
281	/// # Ok(()) }
282	/// ```
283	pub fn user_details(&self, username: &str) -> Result<UserDetails, Error> {
284		let json = self.get(&format!("user/{}", username))?;
285		let json = &json["attributes"];
286
287		Ok(UserDetails {
288			username: json["userName"].string()?,
289			about_me: json["aboutMe"].string()?,
290			is_moderator: json["moderator"].bool_()?,
291			is_patreon: json["patreon"].bool_()?,
292			avatar_url: json["avatar"].string()?,
293			country_code: json["countryCode"].string()?,
294			player_rating: json["playerRating"].f32_()?,
295			default_modifiers: match json["defaultModifiers"].str_()? {
296				"" => None,
297				modifiers => Some(modifiers.to_owned()),
298			},
299			rating: etterna::Skillsets8 {
300				overall: json["playerRating"].f32_()?,
301				stream: json["skillsets"]["Stream"].f32_()?,
302				jumpstream: json["skillsets"]["Jumpstream"].f32_()?,
303				handstream: json["skillsets"]["Handstream"].f32_()?,
304				stamina: json["skillsets"]["Stamina"].f32_()?,
305				jackspeed: json["skillsets"]["JackSpeed"].f32_()?,
306				chordjack: json["skillsets"]["Chordjack"].f32_()?,
307				technical: json["skillsets"]["Technical"].f32_()?,
308			},
309		})
310	}
311
312	fn parse_top_scores(&self, url: &str) -> Result<Vec<TopScore>, Error> {
313		let json = self.get(url)?;
314
315		json.array()?
316			.iter()
317			.map(|json| {
318				Ok(TopScore {
319					scorekey: json["id"].parse()?,
320					song_name: json["attributes"]["songName"].string()?,
321					ssr_overall: json["attributes"]["Overall"].f32_()?,
322					wifescore: json["attributes"]["wife"].wifescore_percent_float()?,
323					rate: json["attributes"]["rate"].rate_float()?,
324					difficulty: json["attributes"]["difficulty"].parse()?,
325					chartkey: json["attributes"]["chartKey"].parse()?,
326					base_msd: etterna::Skillsets8 {
327						overall: json["attributes"]["Overall"].f32_()?,
328						stream: json["attributes"]["skillsets"]["Stream"].f32_()?,
329						jumpstream: json["attributes"]["skillsets"]["Jumpstream"].f32_()?,
330						handstream: json["attributes"]["skillsets"]["Handstream"].f32_()?,
331						stamina: json["attributes"]["skillsets"]["Stamina"].f32_()?,
332						jackspeed: json["attributes"]["skillsets"]["JackSpeed"].f32_()?,
333						chordjack: json["attributes"]["skillsets"]["Chordjack"].f32_()?,
334						technical: json["attributes"]["skillsets"]["Technical"].f32_()?,
335					},
336				})
337			})
338			.collect()
339	}
340
341	/// Retrieve the user's top scores by the given skillset. The number of scores returned is equal
342	/// to `limit`
343	///
344	/// # Errors
345	/// - [`Error::UserNotFound`] if the supplied username was not found
346	///
347	/// # Example
348	/// ```rust,no_run
349	/// # fn main() -> Result<(), etternaonline_api::Error> {
350	/// # use etternaonline_api::v2::*;
351	/// # use etterna::*;
352	/// # let mut session: Session = unimplemented!();
353	/// // Retrieve the top 10 chordjack scores of user "kangalioo"
354	/// let scores = session.user_top_skillset_scores("kangalioo", Skillset7::Chordjack, 10)?;
355	/// # Ok(()) }
356	/// ```
357	pub fn user_top_skillset_scores(
358		&self,
359		username: &str,
360		skillset: etterna::Skillset7,
361		limit: u32,
362	) -> Result<Vec<TopScore>, Error> {
363		self.parse_top_scores(&format!(
364			"user/{}/top/{}/{}",
365			username,
366			crate::common::skillset_to_eo(skillset),
367			limit
368		))
369	}
370
371	/// Retrieve the user's top 10 scores, sorted by the overall SSR. Due to a bug in the EO v2 API,
372	/// it's unfortunately not possible to control the number of scores returned.
373	///
374	/// # Errors
375	/// - [`Error::UserNotFound`] if the supplied username was not found
376	///
377	/// # Example
378	/// ```rust,no_run
379	/// # fn main() -> Result<(), etternaonline_api::Error> {
380	/// # use etternaonline_api::v2::*;
381	/// # let mut session: Session = unimplemented!();
382	/// // Retrieve the top 10 scores of user "kangalioo"
383	/// let scores = session.user_top_10_scores("kangalioo")?;
384	/// # Ok(()) }
385	/// ```
386	pub fn user_top_10_scores(&self, username: &str) -> Result<Vec<TopScore>, Error> {
387		self.parse_top_scores(&format!("user/{}/top//", username))
388	}
389
390	/// Retrieve the user's latest 10 scores.
391	///
392	/// # Errors
393	/// - [`Error::UserNotFound`] if the supplied username was not found
394	///
395	/// # Example
396	/// ```rust,no_run
397	/// # fn main() -> Result<(), etternaonline_api::Error> {
398	/// # use etternaonline_api::v2::*;
399	/// # let mut session: Session = unimplemented!();
400	/// // Retrieve the latest 10 scores of user "kangalioo"
401	/// let scores = session.user_latest_scores("kangalioo")?;
402	/// # Ok(()) }
403	/// ```
404	pub fn user_latest_scores(&self, username: &str) -> Result<Vec<LatestScore>, Error> {
405		let json = self.get(&format!("user/{}/latest", username))?;
406
407		json.array()?
408			.iter()
409			.map(|json| {
410				Ok(LatestScore {
411					scorekey: json["id"].parse()?,
412					song_name: json["attributes"]["songName"].string()?,
413					ssr_overall: json["attributes"]["Overall"].f32_()?,
414					wifescore: json["attributes"]["wife"].wifescore_percent_float()?,
415					rate: json["attributes"]["rate"].rate_float()?,
416					difficulty: difficulty_from_eo(json["attributes"]["difficulty"].str_()?)?,
417				})
418			})
419			.collect()
420	}
421
422	/// Retrieve the user's rank for each skillset.
423	///
424	/// # Errors
425	/// - [`Error::UserNotFound`] if the supplied username was not found
426	///
427	/// # Example
428	/// ```rust,no_run
429	/// # fn main() -> Result<(), etternaonline_api::Error> {
430	/// # use etternaonline_api::v2::*;
431	/// # let mut session: Session = unimplemented!();
432	/// // Retrieve "kangalioo"'s rank for each skillset
433	/// let scores = session.user_ranks_per_skillset("kangalioo")?;
434	/// # Ok(()) }
435	/// ```
436	pub fn user_ranks_per_skillset(&self, username: &str) -> Result<etterna::UserRank, Error> {
437		let json = self.get(&format!("user/{}/ranks", username))?;
438		let json = &json["attributes"];
439
440		Ok(etterna::UserRank {
441			overall: json["Overall"].u32_()?,
442			stream: json["Stream"].u32_()?,
443			jumpstream: json["Jumpstream"].u32_()?,
444			handstream: json["Handstream"].u32_()?,
445			stamina: json["Stamina"].u32_()?,
446			jackspeed: json["JackSpeed"].u32_()?,
447			chordjack: json["Chordjack"].u32_()?,
448			technical: json["Technical"].u32_()?,
449		})
450	}
451
452	/// Retrieve the user's best scores for each skillset. The number of scores yielded is not
453	/// documented in the EO API, but according to my experiments it's 25.
454	///
455	/// # Errors
456	/// - [`Error::UserNotFound`] if the supplied username was not found
457	///
458	/// # Example
459	/// ```rust,no_run
460	/// # fn main() -> Result<(), etternaonline_api::Error> {
461	/// # use etternaonline_api::v2::*;
462	/// # let mut session: Session = unimplemented!();
463	/// let top_scores = session.user_top_scores_per_skillset("kangalioo")?;
464	/// println!("kangalioo's 5th best handstream score is {:?}", top_scores.handstream[4]);
465	/// # Ok(()) }
466	/// ```
467	pub fn user_top_scores_per_skillset(
468		&self,
469		username: &str,
470	) -> Result<UserTopScoresPerSkillset, Error> {
471		let json = self.get(&format!("user/{}/all", username))?;
472
473		let parse_skillset_top_scores = |array: &serde_json::Value| -> Result<Vec<_>, Error> {
474			array
475				.array()?
476				.iter()
477				.map(|json| {
478					Ok(TopScorePerSkillset {
479						song_name: json["songname"].string()?,
480						rate: json["user_chart_rate_rate"].rate_float()?,
481						wifescore: json["wifescore"].wifescore_proportion_float()?,
482						chartkey: json["chartkey"].parse()?,
483						scorekey: json["scorekey"].parse()?,
484						difficulty: difficulty_from_eo(json["difficulty"].str_()?)?,
485						ssr: etterna::Skillsets8 {
486							overall: json["Overall"].f32_()?,
487							stream: json["Stream"].f32_()?,
488							jumpstream: json["Jumpstream"].f32_()?,
489							handstream: json["Handstream"].f32_()?,
490							stamina: json["Stamina"].f32_()?,
491							jackspeed: json["JackSpeed"].f32_()?,
492							chordjack: json["Chordjack"].f32_()?,
493							technical: json["Technical"].f32_()?,
494						},
495					})
496				})
497				.collect()
498		};
499
500		Ok(UserTopScoresPerSkillset {
501			overall: parse_skillset_top_scores(&json["attributes"]["Overall"])?,
502			stream: parse_skillset_top_scores(&json["attributes"]["Stream"])?,
503			jumpstream: parse_skillset_top_scores(&json["attributes"]["Jumpstream"])?,
504			handstream: parse_skillset_top_scores(&json["attributes"]["Handstream"])?,
505			stamina: parse_skillset_top_scores(&json["attributes"]["Stamina"])?,
506			jackspeed: parse_skillset_top_scores(&json["attributes"]["JackSpeed"])?,
507			chordjack: parse_skillset_top_scores(&json["attributes"]["Chordjack"])?,
508			technical: parse_skillset_top_scores(&json["attributes"]["Technical"])?,
509		})
510	}
511
512	/// Retrieves detailed metadata and the replay data about the score with the given scorekey.
513	///
514	/// # Errors
515	/// - [`Error::ScoreNotFound`] if the supplied scorekey was not found
516	/// - panics if the passed in scorekey is in an invalid format (only applies if passed in as a
517	///   `&str`, since `&Scorekey` is guaranteed to be valid)
518	///
519	/// # Example
520	/// ```rust,no_run
521	/// # fn main() -> Result<(), etternaonline_api::Error> {
522	/// # use etternaonline_api::v2::*;
523	/// # let mut session: Session = unimplemented!();
524	/// let score_info = session.score_data("S65565b5bc377c6d78b60c0aecfd9e05955b4cf63")?;
525	/// # Ok(()) }
526	/// ```
527	pub fn score_data(&self, scorekey: impl AsRef<str>) -> Result<ScoreData, Error> {
528		let json = self.get(&format!("score/{}", scorekey.as_ref()))?;
529
530		let scorekey = json["id"].parse()?;
531		let json = &json["attributes"];
532
533		Ok(ScoreData {
534			scorekey,
535			modifiers: json["modifiers"].string()?,
536			wifescore: json["wife"].wifescore_proportion_float()?,
537			rate: json["rate"].rate_float()?,
538			max_combo: json["maxCombo"].u32_()?,
539			is_valid: json["valid"].bool_()?,
540			has_chord_cohesion: !json["nocc"].bool_()?,
541			song_name: json["song"]["songName"].string()?,
542			artist: json["song"]["artist"].string()?,
543			song_id: json["song"]["id"].u32_()?,
544			ssr: etterna::Skillsets8 {
545				overall: json["skillsets"]["Overall"].f32_()?,
546				stream: json["skillsets"]["Stream"].f32_()?,
547				jumpstream: json["skillsets"]["Jumpstream"].f32_()?,
548				handstream: json["skillsets"]["Handstream"].f32_()?,
549				stamina: json["skillsets"]["Stamina"].f32_()?,
550				jackspeed: json["skillsets"]["JackSpeed"].f32_()?,
551				chordjack: json["skillsets"]["Chordjack"].f32_()?,
552				technical: json["skillsets"]["Technical"].f32_()?,
553			},
554			judgements: parse_judgements(&json["judgements"])?,
555			replay: crate::common::parse_replay(&json["replay"])?,
556			user: ScoreUser {
557				username: json["user"]["username"].string()?,
558				avatar: json["user"]["avatar"].string()?,
559				country_code: json["user"]["countryCode"].string()?,
560				overall_rating: json["user"]["Overall"].f32_()?,
561			},
562		})
563	}
564
565	/// Retrieves the leaderboard for the specified chart. The return type is a vector of
566	/// leaderboard entries.
567	///
568	/// # Errors
569	/// - [`Error::ChartNotTracked`] if the chartkey provided is not tracked by EO
570	///
571	/// # Example
572	/// ```rust,no_run
573	/// # fn main() -> Result<(), etternaonline_api::Error> {
574	/// # use etternaonline_api::v2::*;
575	/// # let mut session: Session = unimplemented!();
576	/// let leaderboard = session.chart_leaderboard("X4a15f62b66a80b62ec64521704f98c6c03d98e03")?;
577	///
578	/// println!("The best Game Time score is being held by {}", leaderboard[0].user.username);
579	/// # Ok(()) }
580	/// ```
581	pub fn chart_leaderboard(
582		&self,
583		chartkey: impl AsRef<str>,
584	) -> Result<Vec<ChartLeaderboardScore>, Error> {
585		let json = self.get(&format!("charts/{}/leaderboards", chartkey.as_ref()))?;
586
587		json.array()?
588			.iter()
589			.map(|json| {
590				Ok(ChartLeaderboardScore {
591					scorekey: json["id"].parse()?,
592					wifescore: json["attributes"]["wife"].wifescore_percent_float()?,
593					max_combo: json["attributes"]["maxCombo"].u32_()?,
594					is_valid: json["attributes"]["valid"].bool_()?,
595					modifiers: json["attributes"]["modifiers"].string()?,
596					has_chord_cohesion: !json["attributes"]["noCC"].bool_()?,
597					rate: json["attributes"]["rate"].rate_float()?,
598					datetime: json["attributes"]["datetime"].string()?,
599					ssr: etterna::Skillsets8 {
600						overall: json["attributes"]["skillsets"]["Overall"].f32_()?,
601						stream: json["attributes"]["skillsets"]["Stream"].f32_()?,
602						jumpstream: json["attributes"]["skillsets"]["Jumpstream"].f32_()?,
603						handstream: json["attributes"]["skillsets"]["Handstream"].f32_()?,
604						stamina: json["attributes"]["skillsets"]["Stamina"].f32_()?,
605						jackspeed: json["attributes"]["skillsets"]["JackSpeed"].f32_()?,
606						chordjack: json["attributes"]["skillsets"]["Chordjack"].f32_()?,
607						technical: json["attributes"]["skillsets"]["Technical"].f32_()?,
608					},
609					judgements: parse_judgements(&json["attributes"]["judgements"])?,
610					has_replay: json["attributes"]["hasReplay"].bool_()?, // API docs are wrong again
611					user: ScoreUser {
612						username: json["attributes"]["user"]["userName"].string()?,
613						avatar: json["attributes"]["user"]["avatar"].string()?,
614						country_code: json["attributes"]["user"]["countryCode"].string()?,
615						overall_rating: json["attributes"]["user"]["playerRating"].f32_()?,
616					},
617				})
618			})
619			.collect()
620	}
621
622	/// Retrieves the player leaderboard for the given country.
623	///
624	/// # Errors
625	/// - [`Error::NoUsersFound`] if there are no users registered in this country
626	///
627	/// # Example
628	/// ```rust,no_run
629	/// # fn main() -> Result<(), etternaonline_api::Error> {
630	/// # use etternaonline_api::v2::*;
631	/// # let mut session: Session = unimplemented!();
632	/// let leaderboard = session.country_leaderboard("DE")?;
633	///
634	/// println!(
635	/// 	"The best German Etterna player is {} with a rating of {}",
636	/// 	leaderboard[0].user.username,
637	/// 	leaderboard[0].rating.overall,
638	/// );
639	/// # Ok(()) }
640	/// ```
641	pub fn country_leaderboard(&self, country_code: &str) -> Result<Vec<LeaderboardEntry>, Error> {
642		let json = self.get(&format!("leaderboard/{}", country_code))?;
643
644		json.array()?
645			.iter()
646			.map(|json| {
647				Ok(LeaderboardEntry {
648					user: ScoreUser {
649						username: json["attributes"]["user"]["username"].string()?,
650						avatar: json["attributes"]["user"]["avatar"].string()?,
651						country_code: json["attributes"]["user"]["countryCode"].string()?,
652						overall_rating: json["attributes"]["user"]["Overall"].f32_()?,
653					},
654					rating: etterna::Skillsets8 {
655						overall: json["attributes"]["user"]["Overall"].f32_()?,
656						stream: json["attributes"]["skillsets"]["Stream"].f32_()?,
657						jumpstream: json["attributes"]["skillsets"]["Jumpstream"].f32_()?,
658						handstream: json["attributes"]["skillsets"]["Handstream"].f32_()?,
659						stamina: json["attributes"]["skillsets"]["Stamina"].f32_()?,
660						jackspeed: json["attributes"]["skillsets"]["JackSpeed"].f32_()?,
661						chordjack: json["attributes"]["skillsets"]["Chordjack"].f32_()?,
662						technical: json["attributes"]["skillsets"]["Technical"].f32_()?,
663					},
664				})
665			})
666			.collect()
667	}
668
669	/// Retrieves the worldwide leaderboard of players.
670	///
671	/// # Example
672	/// ```rust,no_run
673	/// # fn main() -> Result<(), etternaonline_api::Error> {
674	/// # use etternaonline_api::v2::*;
675	/// # let mut session: Session = unimplemented!();
676	/// let leaderboard = session.world_leaderboard()?;
677	///
678	/// println!(
679	/// 	"The world's best Etterna player is {} with a rating of {}",
680	/// 	leaderboard[0].user.username,
681	/// 	leaderboard[0].rating.overall,
682	/// );
683	/// # Ok(()) }
684	/// ```
685	pub fn world_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, Error> {
686		self.country_leaderboard("")
687	}
688
689	/// Retrieves the user's favorites. Returns a vector of chartkeys.
690	///
691	/// # Errors
692	/// - [`Error::UserNotFound`] if the supplied username was not found
693	///
694	/// # Example
695	/// ```rust,no_run
696	/// # fn main() -> Result<(), etternaonline_api::Error> {
697	/// # use etternaonline_api::v2::*;
698	/// # let mut session: Session = unimplemented!();
699	/// let favorites = session.user_favorites("kangalioo")?;
700	/// println!("kangalioo has {} favorites", favorites.len());
701	/// # Ok(()) }
702	/// ```
703	pub fn user_favorites(&self, username: &str) -> Result<Vec<String>, Error> {
704		let json = self.get(&format!("user/{}/favorites", username))?;
705
706		json.array()?
707			.iter()
708			.map(|obj| Ok(obj["attributes"]["chartkey"].string()?))
709			.collect()
710	}
711
712	/// Add a chart to the user's favorites.
713	///
714	/// # Errors
715	/// - [`Error::ChartAlreadyFavorited`] if the chart is already in the user's favorites
716	/// - [`Error::ChartNotTracked`] if the chartkey provided is not tracked by EO
717	///
718	/// # Example
719	/// ```rust,no_run
720	/// # fn main() -> Result<(), etternaonline_api::Error> {
721	/// # use etternaonline_api::v2::*;
722	/// # let mut session: Session = unimplemented!();
723	/// // Favorite Game Time
724	/// session.add_user_favorite("kangalioo", "X4a15f62b66a80b62ec64521704f98c6c03d98e03")?;
725	/// # Ok(()) }
726	/// ```
727	pub fn add_user_favorite(
728		&self,
729		username: &str,
730		chartkey: impl AsRef<str>,
731	) -> Result<(), Error> {
732		self.request(
733			"POST",
734			&format!("user/{}/favorites", username),
735			|mut req| req.send_form(&[("chartkey", chartkey.as_ref())]),
736		)?;
737
738		Ok(())
739	}
740
741	/// Remove a chart from the user's favorites.
742	///
743	/// # Example
744	/// ```rust,no_run
745	/// # fn main() -> Result<(), etternaonline_api::Error> {
746	/// # use etternaonline_api::v2::*;
747	/// # let mut session: Session = unimplemented!();
748	/// // Unfavorite Game Time
749	/// session.remove_user_favorite("kangalioo", "X4a15f62b66a80b62ec64521704f98c6c03d98e03")?;
750	/// # Ok(()) }
751	/// ```
752	pub fn remove_user_favorite(
753		&self,
754		username: &str,
755		chartkey: impl AsRef<str>,
756	) -> Result<(), Error> {
757		self.request(
758			"DELETE",
759			&format!("user/{}/favorites/{}", username, chartkey.as_ref()),
760			|mut request| request.call(),
761		)?;
762
763		Ok(())
764	}
765
766	/// Retrieves a user's score goals.
767	///
768	/// # Errors
769	/// - [`Error::UserNotFound`] if the specified user doesn't exist or if the specified user has no
770	///   goals
771	///
772	/// # Example
773	/// ```rust,no_run
774	/// # fn main() -> Result<(), etternaonline_api::Error> {
775	/// # use etternaonline_api::v2::*;
776	/// # let mut session: Session = unimplemented!();
777	/// let score_goals = session.user_goals("theropfather")?;
778	///
779	/// println!("theropfather has {} goals", score_goals.len());
780	/// # Ok(()) }
781	/// ```
782	pub fn user_goals(&self, username: &str) -> Result<Vec<ScoreGoal>, Error> {
783		let json = self.get(&format!("user/{}/goals", username))?;
784
785		json.array()?
786			.iter()
787			.map(|json| {
788				Ok(ScoreGoal {
789					chartkey: json["attributes"]["chartkey"].parse()?,
790					rate: json["attributes"]["rate"].rate_float()?,
791					wifescore: json["attributes"]["wife"].wifescore_proportion_float()?,
792					time_assigned: json["attributes"]["timeAssigned"].string()?,
793					time_achieved: if json["attributes"]["achieved"].bool_int()? {
794						Some(json["attributes"]["timeAchieved"].string()?)
795					} else {
796						None
797					},
798				})
799			})
800			.collect()
801	}
802
803	/// Add a new score goal.
804	///
805	/// # Errors
806	/// - [`Error::GoalAlreadyExists`] when the goal already exists in the database
807	/// - [`Error::ChartNotTracked`] if the chartkey provided is not tracked by EO
808	/// - [`Error::DatabaseError`] if there was a problem with the database
809	///
810	/// # Example
811	/// ```rust,no_run
812	/// # fn main() -> Result<(), etternaonline_api::Error> {
813	/// # use etternaonline_api::v2::*;
814	/// # let mut session: Session = unimplemented!();
815	/// // Add a Game Time 1.0x AA score goal
816	/// session.add_user_goal(
817	/// 	"kangalioo",
818	/// 	"X4a15f62b66a80b62ec64521704f98c6c03d98e03",
819	/// 	1.0,
820	/// 	0.93,
821	/// 	"2020-07-13 22:48:26",
822	/// )?;
823	/// # Ok(()) }
824	/// ```
825	// TODO: somehow enforce that `time_assigned` is valid ISO 8601
826	pub fn add_user_goal(
827		&self,
828		username: &str,
829		chartkey: impl AsRef<str>,
830		rate: f64,
831		wifescore: f64,
832		time_assigned: &str,
833	) -> Result<(), Error> {
834		self.request(
835			"POST",
836			&format!("user/{}/goals", username),
837			|mut request| {
838				request.send_form(&[
839					("chartkey", chartkey.as_ref()),
840					("rate", &format!("{}", rate)),
841					("wife", &format!("{}", wifescore)),
842					("timeAssigned", time_assigned),
843				])
844			},
845		)?;
846
847		Ok(())
848	}
849
850	/// Remove the user goal with the specified chartkey, rate and wifescore.
851	///
852	/// Note: this API call doesn't seem to do anything
853	///
854	/// # Example
855	/// ```rust,no_run
856	/// # fn main() -> Result<(), etternaonline_api::Error> {
857	/// # use etternaonline_api::v2::*;
858	/// # let mut session: Session = unimplemented!();
859	/// // Let's delete theropfather's first score goal
860	///
861	/// let score_goal = session.user_goals("theropfather")?[0];
862	///
863	/// session.remove_user_goal(
864	/// 	"theropfather",
865	/// 	score_goal.chartkey,
866	/// 	score_goal.rate,
867	/// 	score_goal.wifescore
868	/// )?;
869	/// # Ok(()) }
870	/// ```
871	pub fn remove_user_goal(
872		&self,
873		username: &str,
874		chartkey: impl AsRef<str>,
875		rate: Rate,
876		wifescore: Wifescore,
877	) -> Result<(), Error> {
878		self.request(
879			"DELETE",
880			&format!(
881				"user/{}/goals/{}/{}/{}",
882				username,
883				chartkey.as_ref(),
884				wifescore.as_proportion(),
885				rate.as_f32()
886			),
887			|mut request| request.call(),
888		)?;
889
890		Ok(())
891	}
892
893	/// Update a score goal by replacing all its attributes with the given ones.
894	///
895	/// # Example
896	/// ```rust,no_run
897	/// # fn main() -> Result<(), etternaonline_api::Error> {
898	/// # use etternaonline_api::v2::*;
899	/// # use etterna::*;
900	/// # let mut session: Session = unimplemented!();
901	/// // Let's up kangalioo's first score goal's rate by 0.05
902	///
903	/// let mut score_goal = &mut session.user_goals("kangalioo")?[0];
904	///
905	/// // Add 0.05 to the rate
906	/// score_goal.rate += Rate::from(0.05);
907	///
908	/// session.update_user_goal("kangalioo", score_goal)?;
909	/// # Ok(()) }
910	/// ```
911	pub fn update_user_goal(&self, username: &str, goal: &ScoreGoal) -> Result<(), Error> {
912		self.request(
913			"POST",
914			&format!("user/{}/goals/update", username),
915			|mut request| {
916				request.send_form(&[
917					("chartkey", goal.chartkey.as_ref()),
918					("timeAssigned", &goal.time_assigned),
919					(
920						"achieved",
921						if goal.time_achieved.is_some() {
922							"1"
923						} else {
924							"0"
925						},
926					),
927					("rate", &format!("{}", goal.rate)),
928					("wife", &format!("{}", goal.wifescore)),
929					(
930						"timeAchieved",
931						goal.time_achieved
932							.as_deref()
933							.unwrap_or("0000-00-00 00:00:00"),
934					),
935				])
936			},
937		)?;
938
939		Ok(())
940	}
941
942	// Let's find out how this works and properly implement it, when I finally find out how to login
943	// into the fucking v2 API again >:(
944	// pub fn pack_list(&self) -> Result<(), Error> {
945	// 	let json = self.request("GET", "packs", |mut r| r.call())?;
946
947	// 	println!("{:#?}", json);
948
949	// 	Ok(())
950	// }
951
952	// pub fn test(&self) -> Result<(), Error> {
953	// let best_score = &self.user_top_10_scores("kangalioo")?[0];
954
955	// println!("{:#?}", self.user_top_skillset_scores("kangalioo", Skillset7::Technical, 3)?);
956	// println!("{:#?}", self.user_top_10_scores("kangalioo")?);
957	// println!("{:#?}", self.user_details("kangalioo")?);
958	// println!("{:#?}", self.user_latest_scores("kangalioo")?);
959	// println!("{:#?}", self.user_ranks_per_skillset("kangalioo")?);
960	// println!("{:#?}", self.user_top_scores_per_skillset("kangalioo")?);
961	// println!("{:#?}", self.score_data(&best_score.scorekey));
962	// println!("{:#?}", self.chart_leaderboards("Xbbff339a2c301d7bf03dc99bc1b013c3b80e75d2"));
963	// println!("{:#?}", self.country_leaderboard("DE"));
964	// println!("{:#?}", self.add_user_favorite("kangalioo", "Xbbff339a2c301d7bf03dc99bc1b013c3b80e75d2"));
965	// println!("{:#?}", self.remove_user_favorite("kangalioo", "Xbbff339a2c301d7bf03dc99bc1b013c3b80e75d2"));
966	// println!("{:#?}", self.add_user_goal("kangalioo", "Xbbff339a2c301d7bf03dc99bc1b013c3b80e75d2", 0.75, 0.8686, "2037-06-04 15:00:00"));
967	// println!("{:#?}", self.remove_user_goal("kangalioo", "Xbbff339a2c301d7bf03dc99bc1b013c3b80e75d2", 0.7, 0.8686));
968	// let goal = &mut self.user_goals("kangalioo")?[0];
969	// goal.wifescore += 0.01;
970	// println!("{:#?}", self.update_user_goal("kangalioo", &goal));
971	// println!("{:#?}", self.user_goals("kangalioo")?);
972
973	// check if wifescores are all normalized to a max of 1.0
974	// println!("{} {} {} {} {} {}",
975	// 	self.user_top_skillset_scores("kangalioo", Skillset7::Stream, 3)?[0].wifescore,
976	// 	self.user_top_10_scores("kangalioo")?[0].wifescore,
977	// 	self.user_latest_scores("kangalioo")?[0].wifescore,
978	// 	self.user_top_scores_per_skillset("kangalioo")?.jackspeed[0].wifescore,
979	// 	self.score_data(&best_score.scorekey)?.wifescore,
980	// 	self.chart_leaderboards("Xbbff339a2c301d7bf03dc99bc1b013c3b80e75d2")?[0].wifescore,
981	// );
982
983	// Ok(())
984	// }
985}