demostf_client/
lib.rs

1use bytes::Bytes;
2pub use client::ApiClient;
3use futures_util::{Stream, StreamExt};
4use md5::Context;
5use reqwest::StatusCode;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use std::borrow::Cow;
8use std::fmt::{self, Debug, Display, Formatter};
9use std::io::Write;
10pub use steamid_ng::SteamID;
11use thiserror::Error;
12use time::OffsetDateTime;
13use tinyvec::TinyVec;
14use tracing::{debug, error, instrument};
15
16mod client;
17
18#[derive(Debug, Error)]
19#[non_exhaustive]
20pub enum Error {
21    #[error("Invalid base url")]
22    InvalidBaseUrl,
23    #[error("Request failed: {0}")]
24    Request(reqwest::Error),
25    #[error("Invalid page requested")]
26    InvalidPage,
27    #[error("Invalid api key")]
28    InvalidApiKey,
29    #[error("Hash mismatch")]
30    HashMisMatch,
31    #[error("Unknown server error {0}")]
32    ServerError(u16),
33    #[error("Invalid response: {0}")]
34    InvalidResponse(String),
35    #[error("Demo {0} not found")]
36    DemoNotFound(u32),
37    #[error("User {0} not found")]
38    UserNotFound(u32),
39    #[error("Error while writing demo data")]
40    Write(#[source] std::io::Error),
41    #[error("Operation timed out")]
42    TimeOut,
43}
44
45impl From<reqwest::Error> for Error {
46    fn from(error: reqwest::Error) -> Self {
47        if error.is_timeout() {
48            Error::TimeOut
49        } else {
50            match error.status() {
51                Some(StatusCode::UNAUTHORIZED) => Error::InvalidApiKey,
52                Some(StatusCode::PRECONDITION_FAILED) => Error::HashMisMatch,
53                Some(status) if status.is_server_error() => Error::ServerError(status.as_u16()),
54                _ => Error::Request(error),
55            }
56        }
57    }
58}
59
60#[derive(Clone, Debug, Deserialize)]
61#[serde(rename_all = "camelCase")]
62/// Data of an uploaded demo
63pub struct Demo {
64    pub id: u32,
65    pub url: String,
66    pub name: String,
67    pub server: String,
68    pub duration: u16,
69    pub nick: String,
70    pub map: String,
71    #[serde(with = "time::serde::timestamp")]
72    pub time: OffsetDateTime,
73    pub red: String,
74    pub blue: String,
75    pub red_score: u8,
76    pub blue_score: u8,
77    pub player_count: u8,
78    pub uploader: UserRef,
79    #[serde(deserialize_with = "hex_to_digest")]
80    pub hash: [u8; 16],
81    pub backend: String,
82    pub path: String,
83    #[serde(default)]
84    /// Demos listed using `ApiClient::list` will not have any players set, use `get_players` to automatically
85    /// load the players when not set
86    pub players: Option<Vec<Player>>,
87}
88
89impl Demo {
90    /// Return either the stored players info or get the players from the api
91    #[instrument]
92    pub async fn get_players(&self, client: &ApiClient) -> Result<Cow<'_, [Player]>, Error> {
93        match &self.players {
94            Some(players) => Ok(Cow::Borrowed(players.as_slice())),
95            None => {
96                let demo = client.get(self.id).await?;
97                Ok(Cow::Owned(demo.players.unwrap_or_default()))
98            }
99        }
100    }
101
102    /// Download a demo, returning a stream of chunks
103    #[instrument]
104    pub async fn download(
105        &self,
106        client: &ApiClient,
107    ) -> Result<impl Stream<Item = Result<Bytes, Error>>, Error> {
108        debug!(id = self.id, url = display(&self.url), "starting download");
109        Ok(client
110            .download_demo(&self.url, self.duration)
111            .await?
112            .bytes_stream()
113            .map(|chunk| chunk.map_err(Error::from)))
114    }
115
116    /// Download a demo and save it to a writer, verifying the md5 hash in the process
117    #[instrument(skip(target))]
118    pub async fn save<W: Write>(&self, client: &ApiClient, mut target: W) -> Result<(), Error> {
119        debug!(id = self.id, url = display(&self.url), "starting download");
120        let mut response = client.download_demo(&self.url, self.duration).await?;
121
122        let mut context = Context::new();
123
124        while let Some(chunk) = response.chunk().await? {
125            context.consume(&chunk);
126            target.write_all(&chunk).map_err(Error::Write)?;
127        }
128
129        let calculated = context.compute().0;
130
131        if calculated != self.hash {
132            error!(
133                calculated = display(hex::encode(calculated)),
134                expected = display(hex::encode(self.hash)),
135                "hash mismatch"
136            );
137            return Err(Error::HashMisMatch);
138        }
139        Ok(())
140    }
141}
142
143/// Reference to a user, either contains the full user information or only the user id
144#[derive(Clone, Debug, Deserialize)]
145#[serde(untagged)]
146pub enum UserRef {
147    User(User),
148    Id(u32),
149}
150
151impl UserRef {
152    /// Id of the user
153    #[must_use]
154    pub fn id(&self) -> u32 {
155        match self {
156            UserRef::Id(id) | UserRef::User(User { id, .. }) => *id,
157        }
158    }
159
160    /// Return the stored user info if available
161    #[must_use]
162    pub fn user(&self) -> Option<&User> {
163        match self {
164            UserRef::Id(_) => None,
165            UserRef::User(ref user) => Some(user),
166        }
167    }
168
169    /// Return either the stored user info or get the user information from the api
170    #[instrument]
171    pub async fn resolve(&self, client: &ApiClient) -> Result<Cow<'_, User>, Error> {
172        match self {
173            UserRef::User(ref user) => Ok(Cow::Borrowed(user)),
174            UserRef::Id(id) => Ok(Cow::Owned(client.get_user(*id).await?)),
175        }
176    }
177}
178
179/// User data
180#[derive(Clone, Debug, Deserialize)]
181pub struct User {
182    pub id: u32,
183    #[serde(rename = "steamid")]
184    pub steam_id: SteamID,
185    pub name: String,
186}
187
188/// Data of a player in a demo
189#[derive(Clone, Debug, Deserialize)]
190pub struct Player {
191    #[serde(rename = "id")]
192    pub player_id: u32,
193    #[serde(flatten)]
194    #[serde(deserialize_with = "deserialize_nested_user")]
195    pub user: User,
196    pub team: Team,
197    /// If a player has played multiple classes, the class which the user spawned the most as is taken
198    pub class: Class,
199    pub kills: u8,
200    pub assists: u8,
201    pub deaths: u8,
202}
203
204#[derive(Clone, Debug, Deserialize)]
205struct NestedPlayerUser {
206    user_id: u32,
207    #[serde(rename = "steamid")]
208    steam_id: SteamID,
209    name: String,
210}
211
212fn deserialize_nested_user<'de, D>(deserializer: D) -> Result<User, D::Error>
213where
214    D: Deserializer<'de>,
215{
216    let nested = NestedPlayerUser::deserialize(deserializer)?;
217    Ok(User {
218        id: nested.user_id,
219        steam_id: nested.steam_id,
220        name: nested.name,
221    })
222}
223
224/// Player team, red or blue
225#[derive(Clone, Copy, Debug, Deserialize, PartialOrd, PartialEq)]
226#[serde(rename_all = "lowercase")]
227pub enum Team {
228    Red,
229    Blue,
230}
231
232/// Player class
233#[derive(Clone, Copy, Debug, Deserialize, PartialOrd, PartialEq)]
234#[serde(rename_all = "lowercase")]
235pub enum Class {
236    Scout,
237    Soldier,
238    Pyro,
239    Demoman,
240    HeavyWeapons,
241    Engineer,
242    Medic,
243    Sniper,
244    Spy,
245}
246
247/// Deserializes a lowercase hex string to a `[u8; 16]`.
248fn hex_to_digest<'de, D>(deserializer: D) -> Result<[u8; 16], D::Error>
249where
250    D: Deserializer<'de>,
251{
252    use hex::FromHex;
253    use serde::de::Error;
254
255    let string = <&str>::deserialize(deserializer)?;
256
257    if string.is_empty() {
258        return Ok([0; 16]);
259    }
260
261    <[u8; 16]>::from_hex(string).map_err(|err| Error::custom(err.to_string()))
262}
263
264/// Chat message send in the demo
265#[derive(Clone, Debug, Deserialize)]
266pub struct ChatMessage {
267    pub user: String,
268    pub time: u32,
269    pub message: String,
270}
271
272/// Order for listing demos
273#[derive(Debug, Clone, Copy, Serialize, Default)]
274#[serde(into = "&str")]
275pub enum ListOrder {
276    Ascending,
277    #[default]
278    Descending,
279}
280
281/// Game type as recognized by demos.tf, HL, Prolander, 6s or 4v4
282#[derive(Debug, Clone, Copy, Serialize)]
283pub enum GameType {
284    #[serde(rename = "hl")]
285    HL,
286    #[serde(rename = "prolander")]
287    Prolander,
288    #[serde(rename = "6v6")]
289    Sixes,
290    #[serde(rename = "4v4")]
291    Fours,
292}
293
294impl Display for ListOrder {
295    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
296        Display::fmt(<&str>::from(*self), f)
297    }
298}
299
300impl From<ListOrder> for &str {
301    fn from(order: ListOrder) -> Self {
302        match order {
303            ListOrder::Ascending => "ASC",
304            ListOrder::Descending => "DESC",
305        }
306    }
307}
308
309/// Parameters for demo list command
310#[derive(Debug, Default, Serialize)]
311pub struct ListParams {
312    order: ListOrder,
313    backend: Option<String>,
314    map: Option<String>,
315    players: PlayerList,
316    #[serde(rename = "type")]
317    ty: Option<GameType>,
318    #[serde(serialize_with = "serialize_option_time")]
319    after: Option<OffsetDateTime>,
320    #[serde(serialize_with = "serialize_option_time")]
321    before: Option<OffsetDateTime>,
322    before_id: Option<u64>,
323    after_id: Option<u64>,
324}
325
326fn serialize_option_time<S>(dt: &Option<OffsetDateTime>, serializer: S) -> Result<S::Ok, S::Error>
327where
328    S: Serializer,
329{
330    match dt {
331        Some(time) => time::serde::timestamp::serialize(time, serializer),
332        None => Option::<i64>::serialize(&None, serializer),
333    }
334}
335
336#[derive(Default, Debug)]
337struct PlayerList(TinyVec<[SteamID; 2]>);
338
339impl PlayerList {
340    fn new<T: Into<SteamID>, I: IntoIterator<Item = T>>(players: I) -> Self {
341        PlayerList(players.into_iter().map(Into::into).collect())
342    }
343}
344
345impl Display for PlayerList {
346    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
347        let mut first = true;
348        for steam_id in &self.0 {
349            if first {
350                first = false;
351                write!(f, "{}", u64::from(*steam_id))?;
352            } else {
353                write!(f, ",{}", u64::from(*steam_id))?;
354            }
355        }
356
357        Ok(())
358    }
359}
360
361impl Serialize for PlayerList {
362    fn serialize<S>(&self, serializer: S) -> Result<<S as Serializer>::Ok, <S as Serializer>::Error>
363    where
364        S: Serializer,
365    {
366        serializer.collect_str(&self)
367    }
368}
369
370#[test]
371fn test_serialize_player_list() {
372    assert_eq!(
373        "76561198024494988",
374        PlayerList::new([76561198024494988]).to_string()
375    );
376    assert_eq!(
377        "76561198024494988,76561197963701107",
378        PlayerList::new([76561198024494988, 76561197963701107]).to_string()
379    );
380    assert_eq!(
381        "76561198024494988,76561197963701107,76561197963701106",
382        PlayerList::new([76561198024494988, 76561197963701107, 76561197963701106]).to_string()
383    );
384}
385
386impl ListParams {
387    /// Specify the backend name to filter demos with
388    #[must_use]
389    pub fn with_backend(self, backend: impl Into<String>) -> Self {
390        ListParams {
391            backend: Some(backend.into()),
392            ..self
393        }
394    }
395
396    /// Specify the map name to filter demos with
397    #[must_use]
398    pub fn with_map(self, map: impl Into<String>) -> Self {
399        ListParams {
400            map: Some(map.into()),
401            ..self
402        }
403    }
404
405    /// Specify the player steam ids to filter demos with
406    #[must_use]
407    pub fn with_players<T: Into<SteamID>, I: IntoIterator<Item = T>>(self, players: I) -> Self {
408        ListParams {
409            players: PlayerList::new(players),
410            ..self
411        }
412    }
413
414    /// Specify the game type to filter demos with
415    #[must_use]
416    pub fn with_type(self, ty: GameType) -> Self {
417        ListParams {
418            ty: Some(ty),
419            ..self
420        }
421    }
422
423    /// Specify the before date to filter demos with
424    #[must_use]
425    pub fn with_before(self, before: OffsetDateTime) -> Self {
426        ListParams {
427            before: Some(before),
428            ..self
429        }
430    }
431
432    /// Specify the after date to filter demos with
433    #[must_use]
434    pub fn with_after(self, after: OffsetDateTime) -> Self {
435        ListParams {
436            after: Some(after),
437            ..self
438        }
439    }
440
441    /// Specify the maximum demo id to filter demos with
442    #[must_use]
443    pub fn with_before_id(self, before: u64) -> Self {
444        ListParams {
445            before_id: Some(before),
446            ..self
447        }
448    }
449
450    /// Specify the minimum demo id to filter demos with
451    #[must_use]
452    pub fn with_after_id(self, after: u64) -> Self {
453        ListParams {
454            after_id: Some(after),
455            ..self
456        }
457    }
458
459    /// Specify the sort
460    #[must_use]
461    pub fn with_order(self, order: ListOrder) -> Self {
462        ListParams { order, ..self }
463    }
464}