use bytes::Bytes; pub use client::ApiClient; use futures_util::{Stream, StreamExt}; use md5::Context; use reqwest::StatusCode; use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; use std::borrow::Cow; use std::fmt::{self, Debug, Display, Formatter}; use std::io::Write; use std::str::FromStr; pub use steamid_ng::SteamID; use steamid_ng::{Instance, InstanceFlags, InstanceType}; use thiserror::Error; use time::OffsetDateTime; use tinyvec::TinyVec; use tracing::{debug, error, instrument}; mod client; #[derive(Debug, Error)] #[non_exhaustive] pub enum Error { #[error("Invalid base url")] InvalidBaseUrl, #[error("Request failed: {0:#}")] Request(reqwest::Error), #[error("Invalid page requested")] InvalidPage, #[error("Invalid api key")] InvalidApiKey, #[error("Hash mismatch")] HashMisMatch, #[error("Unknown server error {0}")] ServerError(u16), #[error("Invalid response: {0}")] InvalidResponse(String), #[error("Demo {0} not found")] DemoNotFound(u32), #[error("User {0} not found")] UserNotFound(u32), #[error("Error while writing demo data")] Write(#[source] std::io::Error), #[error("Operation timed out")] TimeOut, } impl From for Error { fn from(error: reqwest::Error) -> Self { if error.is_timeout() { Error::TimeOut } else { match error.status() { Some(StatusCode::UNAUTHORIZED) => Error::InvalidApiKey, Some(StatusCode::PRECONDITION_FAILED) => Error::HashMisMatch, Some(status) if status.is_server_error() => Error::ServerError(status.as_u16()), _ => Error::Request(error), } } } } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Data of an uploaded demo pub struct Demo { pub id: u32, pub url: String, pub name: String, pub server: String, pub duration: u16, pub nick: String, pub map: String, #[serde(with = "time::serde::timestamp")] pub time: OffsetDateTime, pub red: String, pub blue: String, pub red_score: u8, pub blue_score: u8, pub player_count: u8, pub uploader: UserRef, #[serde(deserialize_with = "hex_to_digest")] pub hash: [u8; 16], pub backend: String, pub path: String, #[serde(default)] /// Demos listed using `ApiClient::list` will not have any players set, use `get_players` to automatically /// load the players when not set pub players: Option>, } impl Demo { /// Return either the stored players info or get the players from the api #[instrument] pub async fn get_players(&self, client: &ApiClient) -> Result, Error> { match &self.players { Some(players) => Ok(Cow::Borrowed(players.as_slice())), None => { let demo = client.get(self.id).await?; Ok(Cow::Owned(demo.players.unwrap_or_default())) } } } /// Download a demo, returning a stream of chunks #[instrument] pub async fn download( &self, client: &ApiClient, ) -> Result>, Error> { debug!(id = self.id, url = display(&self.url), "starting download"); Ok(client .download_demo(&self.url, self.duration) .await? .bytes_stream() .map(|chunk| chunk.map_err(Error::from))) } /// Download a demo and save it to a writer, verifying the md5 hash in the process #[instrument(skip(target))] pub async fn save(&self, client: &ApiClient, mut target: W) -> Result<(), Error> { debug!(id = self.id, url = display(&self.url), "starting download"); let mut response = client.download_demo(&self.url, self.duration).await?; let mut context = Context::new(); while let Some(chunk) = response.chunk().await? { context.consume(&chunk); target.write_all(&chunk).map_err(Error::Write)?; } let calculated = context.finalize().0; if calculated != self.hash { error!( calculated = display(hex::encode(calculated)), expected = display(hex::encode(self.hash)), "hash mismatch" ); return Err(Error::HashMisMatch); } Ok(()) } } /// Reference to a user, either contains the full user information or only the user id #[derive(Clone, Debug, Deserialize)] #[serde(untagged)] pub enum UserRef { User(User), Id(u32), } impl UserRef { /// Id of the user #[must_use] pub fn id(&self) -> u32 { match self { UserRef::Id(id) | UserRef::User(User { id, .. }) => *id, } } /// Return the stored user info if available #[must_use] pub fn user(&self) -> Option<&User> { match self { UserRef::Id(_) => None, UserRef::User(ref user) => Some(user), } } /// Return either the stored user info or get the user information from the api #[instrument] pub async fn resolve(&self, client: &ApiClient) -> Result, Error> { match self { UserRef::User(ref user) => Ok(Cow::Borrowed(user)), UserRef::Id(id) => Ok(Cow::Owned(client.get_user(*id).await?)), } } } /// User data #[derive(Clone, Debug, Deserialize)] pub struct User { pub id: u32, #[serde(rename = "steamid", deserialize_with = "deserialize_steamid")] pub steam_id: SteamID, pub name: String, } /// Data of a player in a demo #[derive(Clone, Debug, Deserialize)] pub struct Player { #[serde(rename = "id")] pub player_id: u32, #[serde(flatten)] #[serde(deserialize_with = "deserialize_nested_user")] pub user: User, pub team: Team, /// If a player has played multiple classes, the class which the user spawned the most as is taken pub class: Class, pub kills: u8, pub assists: u8, pub deaths: u8, } #[derive(Clone, Debug, Deserialize)] struct NestedPlayerUser { user_id: u32, #[serde(rename = "steamid", deserialize_with = "deserialize_steamid")] steam_id: SteamID, name: String, } fn deserialize_nested_user<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let nested = NestedPlayerUser::deserialize(deserializer)?; Ok(User { id: nested.user_id, steam_id: nested.steam_id, name: nested.name, }) } fn deserialize_steamid<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let s = >::deserialize(deserializer)?; SteamID::from_str(&s).map_err(D::Error::custom) } /// Player team, red or blue #[derive(Clone, Copy, Debug, Deserialize, PartialOrd, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Team { Red, Blue, } /// Player class #[derive(Clone, Copy, Debug, Deserialize, PartialOrd, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Class { Scout, Soldier, Pyro, Demoman, HeavyWeapons, Engineer, Medic, Sniper, Spy, } /// Deserializes a lowercase hex string to a `[u8; 16]`. fn hex_to_digest<'de, D>(deserializer: D) -> Result<[u8; 16], D::Error> where D: Deserializer<'de>, { use hex::FromHex; use serde::de::Error; let string = <&str>::deserialize(deserializer)?; if string.is_empty() { return Ok([0; 16]); } <[u8; 16]>::from_hex(string).map_err(|err| Error::custom(err.to_string())) } /// Chat message send in the demo #[derive(Clone, Debug, Deserialize)] pub struct ChatMessage { pub user: String, pub time: u32, pub message: String, } /// Order for listing demos #[derive(Debug, Clone, Copy, Serialize, Default)] #[serde(into = "&str")] pub enum ListOrder { Ascending, #[default] Descending, } /// Game type as recognized by demos.tf, HL, Prolander, 6s or 4v4 #[derive(Debug, Clone, Copy, Serialize)] pub enum GameType { #[serde(rename = "hl")] HL, #[serde(rename = "prolander")] Prolander, #[serde(rename = "6v6")] Sixes, #[serde(rename = "4v4")] Fours, } impl Display for ListOrder { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { Display::fmt(<&str>::from(*self), f) } } impl From for &str { fn from(order: ListOrder) -> Self { match order { ListOrder::Ascending => "ASC", ListOrder::Descending => "DESC", } } } /// Parameters for demo list command #[derive(Debug, Default, Serialize)] pub struct ListParams { order: ListOrder, backend: Option, map: Option, players: PlayerList, #[serde(rename = "type")] ty: Option, #[serde(serialize_with = "serialize_option_time")] after: Option, #[serde(serialize_with = "serialize_option_time")] before: Option, before_id: Option, after_id: Option, } fn serialize_option_time(dt: &Option, serializer: S) -> Result where S: Serializer, { match dt { Some(time) => time::serde::timestamp::serialize(time, serializer), None => Option::::serialize(&None, serializer), } } #[derive(Debug, Default)] struct PlayerList(TinyVec<[Option; 2]>); impl PlayerList { fn new>(players: I) -> Self { PlayerList( players .into_iter() .map(IntoSteamId::into_steam_id) .map(Some) .collect(), ) } } impl Display for PlayerList { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { let mut first = true; for steam_id in self.0.iter().flatten() { if first { first = false; write!(f, "{}", steam_id.steam64())?; } else { write!(f, ",{}", steam_id.steam64())?; } } Ok(()) } } impl Serialize for PlayerList { fn serialize(&self, serializer: S) -> Result<::Ok, ::Error> where S: Serializer, { serializer.collect_str(&self) } } #[test] fn test_serialize_player_list() { fn id(id: u64) -> SteamID { SteamID::from_steam64(id).unwrap() } assert_eq!( "76561198024494988", PlayerList::new([id(76561198024494988)]).to_string() ); assert_eq!( "76561198024494988,76561197963701107", PlayerList::new([id(76561198024494988), id(76561197963701107)]).to_string() ); assert_eq!( "76561198024494988,76561197963701107,76561197963701106", PlayerList::new([ id(76561198024494988), id(76561197963701107), id(76561197963701106) ]) .to_string() ); } pub trait IntoSteamId { fn into_steam_id(self) -> SteamID; } impl IntoSteamId for SteamID { fn into_steam_id(self) -> SteamID { self } } impl IntoSteamId for u64 { fn into_steam_id(self) -> SteamID { SteamID::from_steam64(self).unwrap_or(SteamID::new( 0, Instance::new(InstanceType::All, InstanceFlags::None), steamid_ng::AccountType::Invalid, steamid_ng::Universe::Invalid, )) } } impl ListParams { /// Specify the backend name to filter demos with #[must_use] pub fn with_backend(self, backend: impl Into) -> Self { ListParams { backend: Some(backend.into()), ..self } } /// Specify the map name to filter demos with #[must_use] pub fn with_map(self, map: impl Into) -> Self { ListParams { map: Some(map.into()), ..self } } /// Specify the player steam ids to filter demos with #[must_use] pub fn with_players>(self, players: I) -> Self { ListParams { players: PlayerList::new(players), ..self } } /// Specify the game type to filter demos with #[must_use] pub fn with_type(self, ty: GameType) -> Self { ListParams { ty: Some(ty), ..self } } /// Specify the before date to filter demos with #[must_use] pub fn with_before(self, before: OffsetDateTime) -> Self { ListParams { before: Some(before), ..self } } /// Specify the after date to filter demos with #[must_use] pub fn with_after(self, after: OffsetDateTime) -> Self { ListParams { after: Some(after), ..self } } /// Specify the maximum demo id to filter demos with #[must_use] pub fn with_before_id(self, before: u64) -> Self { ListParams { before_id: Some(before), ..self } } /// Specify the minimum demo id to filter demos with #[must_use] pub fn with_after_id(self, after: u64) -> Self { ListParams { after_id: Some(after), ..self } } /// Specify the sort #[must_use] pub fn with_order(self, order: ListOrder) -> Self { ListParams { order, ..self } } }