Skip to main content

demostf_client/
lib.rs

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