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")]
64pub 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 pub players: Option<Vec<Player>>,
89}
90
91impl Demo {
92 #[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 #[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 #[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#[derive(Clone, Debug, Deserialize)]
147#[serde(untagged)]
148pub enum UserRef {
149 User(User),
150 Id(u32),
151}
152
153impl UserRef {
154 #[must_use]
156 pub fn id(&self) -> u32 {
157 match self {
158 UserRef::Id(id) | UserRef::User(User { id, .. }) => *id,
159 }
160 }
161
162 #[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 #[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#[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#[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 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#[derive(Clone, Copy, Debug, Deserialize, PartialOrd, PartialEq)]
236#[serde(rename_all = "lowercase")]
237pub enum Team {
238 Red,
239 Blue,
240}
241
242#[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
257fn 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#[derive(Clone, Debug, Deserialize)]
276pub struct ChatMessage {
277 pub user: String,
278 pub time: u32,
279 pub message: String,
280}
281
282#[derive(Debug, Clone, Copy, Serialize, Default)]
284#[serde(into = "&str")]
285pub enum ListOrder {
286 Ascending,
287 #[default]
288 Descending,
289}
290
291#[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#[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 #[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 #[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 #[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 #[must_use]
462 pub fn with_type(self, ty: GameType) -> Self {
463 ListParams {
464 ty: Some(ty),
465 ..self
466 }
467 }
468
469 #[must_use]
471 pub fn with_before(self, before: OffsetDateTime) -> Self {
472 ListParams {
473 before: Some(before),
474 ..self
475 }
476 }
477
478 #[must_use]
480 pub fn with_after(self, after: OffsetDateTime) -> Self {
481 ListParams {
482 after: Some(after),
483 ..self
484 }
485 }
486
487 #[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 #[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 #[must_use]
507 pub fn with_order(self, order: ListOrder) -> Self {
508 ListParams { order, ..self }
509 }
510}