1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
#![allow(
	clippy::len_zero,
	clippy::tabs_in_doc_comments,
	clippy::collapsible_if,
	clippy::needless_bool,
	clippy::too_many_arguments
)]

/*!
This crate provides an ergonomic wrapper around the v1, v2 and web API of
[EtternaOnline](https://etternaonline.com), commonly abbreviated "EO" (web API is work-in-progress).

Depending on which API you choose, you might need an API token.

# Notes
Etterna terminology:
- The calculated difficulty for a chart is called MSD: Mina standardized difficulty.
- The score rating - which is variable depending on your wifescore - is called SSR:
  score-specific-rating

# Usage
For detailed usage documentation, see [`v1::Session`] and [`v2::Session`]
*/

#[cfg(feature = "serde")]
extern crate serde_ as serde;

mod extension_traits;
#[macro_use]
mod common;
pub use common::structs::*;
pub mod v1;
pub mod v2;
pub mod web;

thiserror_lite::err_enum! {
	#[derive(Debug)]
	#[non_exhaustive]
	pub enum Error {
		// Normal errors
		#[error("User not found")]
		UserNotFound,
		#[error("Username and password combination not found")]
		InvalidLogin,
		#[error("Score not found")]
		ScoreNotFound,
		#[error("Song not found")]
		SongNotFound,
		#[error("Chart not tracked")]
		ChartNotTracked,
		#[error("Favorite already exists")]
		ChartAlreadyFavorited,
		#[error("Database error")]
		DatabaseError,
		#[error("Goal already exists")]
		GoalAlreadyExists,
		#[error("Chart already exists")]
		ChartAlreadyAdded,
		#[error("The uploaded file is not a valid XML file")]
		InvalidXml,
		#[error("No users registered")]
		NoUsersFound,

		// Meta errors
		#[error("General network error ({0})")]
		NetworkError(String),
		#[error("Internal web server error (HTTP {status_code})")]
		ServerIsDown { status_code: u16 },
		#[error("Error while parsing the json sent by the server ({0})")]
		InvalidJson(#[from] serde_json::Error),
		#[error("Sever responded to query with an unrecognized error message ({0})")]
		UnknownApiError(String),
		#[error("Server sent a payload that doesn't match expectations (debug: {0:?})")]
		InvalidDataStructure(String),
		#[error("Server timed out")]
		Timeout,
		#[error("Server response was empty")]
		EmptyServerResponse
	}
}

impl From<std::io::Error> for Error {
	fn from(e: std::io::Error) -> Self {
		if e.kind() == std::io::ErrorKind::TimedOut {
			Self::Timeout
		} else {
			Self::NetworkError(e.to_string())
		}
	}
}

fn rate_limit(last_request: &mut std::time::Instant, request_cooldown: std::time::Duration) {
	let now = std::time::Instant::now();
	let time_since_last_request = now.duration_since(*last_request);
	if time_since_last_request < request_cooldown {
		std::thread::sleep(request_cooldown - time_since_last_request);
	}
	*last_request = now;
}

/// This only works with 4k replays at the moment! All notes beyond the first four columns are
/// discarded
///
/// If the replay doesn't have sufficient information, None is returned (see
/// [`Replay::split_into_lanes`])
///
/// Panics if the replay contains NaN
pub fn rescore<S, W>(
	replay: &Replay,
	num_hit_mines: u32,
	num_dropped_holds: u32,
	judge: &etterna::Judge,
) -> Option<etterna::Wifescore>
where
	S: etterna::ScoringSystem,
	W: etterna::Wife,
{
	let mut lanes = replay.split_into_lanes()?;

	// Yes it's correct that I'm sorting the two lists separately, and yes it's correct
	// that with that, their ordering won't be the same anymore. This is all okay, because that's
	// how the rescorers accept their data and how they work.
	for lane in lanes.iter_mut() {
		// UNWRAP: documented panic behavior
		lane.note_seconds.sort_by(|a, b| a.partial_cmp(b).unwrap());
		lane.hit_seconds.sort_by(|a, b| a.partial_cmp(b).unwrap());
	}

	Some(etterna::rescore::<S, W>(
		&lanes,
		num_hit_mines,
		num_dropped_holds,
		judge,
	))
}