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")]
62pub 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 pub players: Option<Vec<Player>>,
87}
88
89impl Demo {
90 #[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 #[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 #[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#[derive(Clone, Debug, Deserialize)]
145#[serde(untagged)]
146pub enum UserRef {
147 User(User),
148 Id(u32),
149}
150
151impl UserRef {
152 #[must_use]
154 pub fn id(&self) -> u32 {
155 match self {
156 UserRef::Id(id) | UserRef::User(User { id, .. }) => *id,
157 }
158 }
159
160 #[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 #[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#[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#[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 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#[derive(Clone, Copy, Debug, Deserialize, PartialOrd, PartialEq)]
226#[serde(rename_all = "lowercase")]
227pub enum Team {
228 Red,
229 Blue,
230}
231
232#[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
247fn 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#[derive(Clone, Debug, Deserialize)]
266pub struct ChatMessage {
267 pub user: String,
268 pub time: u32,
269 pub message: String,
270}
271
272#[derive(Debug, Clone, Copy, Serialize, Default)]
274#[serde(into = "&str")]
275pub enum ListOrder {
276 Ascending,
277 #[default]
278 Descending,
279}
280
281#[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#[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 #[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 #[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 #[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 #[must_use]
416 pub fn with_type(self, ty: GameType) -> Self {
417 ListParams {
418 ty: Some(ty),
419 ..self
420 }
421 }
422
423 #[must_use]
425 pub fn with_before(self, before: OffsetDateTime) -> Self {
426 ListParams {
427 before: Some(before),
428 ..self
429 }
430 }
431
432 #[must_use]
434 pub fn with_after(self, after: OffsetDateTime) -> Self {
435 ListParams {
436 after: Some(after),
437 ..self
438 }
439 }
440
441 #[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 #[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 #[must_use]
461 pub fn with_order(self, order: ListOrder) -> Self {
462 ListParams { order, ..self }
463 }
464}