scratch_io/itch_api/
types.rs

1use serde::{Deserialize, Serialize};
2use serde_with::{DefaultOnError, serde_as};
3use thiserror::Error;
4use time::{OffsetDateTime, serde::rfc3339};
5
6const ITCH_API_V1_BASE_URL: &str = "https://itch.io/api/1/";
7const ITCH_API_V2_BASE_URL: &str = "https://api.itch.io/";
8
9pub type UserID = u64;
10pub type CollectionID = u64;
11pub type GameID = u64;
12pub type UploadID = u64;
13pub type BuildID = u64;
14pub type ItchKeyID = u64;
15pub type OwnedKeyID = u64;
16pub type SaleID = u64;
17
18/// Deserialize an empty object as an empty vector
19///
20/// This is needed because of how the itch.io API works
21///
22/// <https://itchapi.ryhn.link/API/index.html>
23///
24/// <https://github.com/itchio/itch.io/issues/1301>
25///
26/// # Errors
27///
28/// If deserializing the Vector fails
29pub(super) fn empty_object_as_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
30where
31  D: serde::de::Deserializer<'de>,
32  T: Deserialize<'de>,
33{
34  struct Helper<T>(std::marker::PhantomData<T>);
35
36  impl<'de, T> serde::de::Visitor<'de> for Helper<T>
37  where
38    T: Deserialize<'de>,
39  {
40    type Value = Vec<T>;
41
42    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
43      formatter.write_str("an array or an empty object")
44    }
45
46    fn visit_seq<A>(self, mut seq: A) -> Result<Vec<T>, A::Error>
47    where
48      A: serde::de::SeqAccess<'de>,
49    {
50      let mut items = Vec::new();
51      while let Some(item) = seq.next_element()? {
52        items.push(item);
53      }
54      Ok(items)
55    }
56
57    fn visit_map<A>(self, mut map: A) -> Result<Vec<T>, A::Error>
58    where
59      A: serde::de::MapAccess<'de>,
60    {
61      // Consume all keys without using them, returning empty Vec
62      while let Some((_k, _v)) = map.next_entry::<serde::de::IgnoredAny, serde::de::IgnoredAny>()? {
63        // Just ignore
64      }
65      Ok(vec![])
66    }
67  }
68
69  deserializer.deserialize_any(Helper(std::marker::PhantomData))
70}
71
72/// An itch.io API version
73///
74/// Its possible values are:
75///
76/// * `V1` - itch.io JSON API V1 <https://itch.io/api/1/>
77///
78/// * `V2` - itch.io JSON API V2 <https://api.itch.io/>
79///
80/// * `Other` - Any other URL
81#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
82pub enum ItchApiVersion {
83  V1,
84  V2,
85  Other,
86}
87
88/// An itch.io API address
89///
90/// Use the Other variant with the full URL when it isn't a known API version
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct ItchApiUrl {
93  version: ItchApiVersion,
94  url: String,
95}
96
97impl<'a> ItchApiUrl {
98  /// Creates an [`ItchApiUrl`] by combining the API version with an endpoint path
99  /// V1 and V2 prepend their base URLs
100  ///
101  /// Other uses the endpoint as-is
102  pub fn from_api_endpoint(
103    version: ItchApiVersion,
104    endpoint: impl Into<std::borrow::Cow<'a, str>>,
105  ) -> Self {
106    let endpoint = endpoint.into();
107    Self {
108      version,
109      url: match version {
110        ItchApiVersion::V1 => format!("{ITCH_API_V1_BASE_URL}{endpoint}"),
111        ItchApiVersion::V2 => format!("{ITCH_API_V2_BASE_URL}{endpoint}"),
112        ItchApiVersion::Other => endpoint.into_owned(),
113      },
114    }
115  }
116
117  /// Returns the API version of this [`ItchApiUrl`]
118  #[must_use]
119  pub fn get_version(&self) -> ItchApiVersion {
120    self.version
121  }
122}
123
124impl ItchApiUrl {
125  /// Get a reference to the full URL string
126  #[must_use]
127  pub fn as_str(&self) -> &str {
128    &self.url
129  }
130}
131
132impl std::fmt::Display for ItchApiUrl {
133  /// Format the [`ItchApiUrl`] as a string, returning the full URL
134  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135    write!(f, "{}", self.url)
136  }
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct ItchCookie {
141  pub itchio: String,
142}
143
144#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum ItchKeySource {
147  Desktop,
148  Android,
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct ItchKey {
153  pub key: String,
154  pub id: ItchKeyID,
155  pub user_id: UserID,
156  pub source: ItchKeySource,
157  pub revoked: Option<bool>,
158  #[serde(with = "rfc3339")]
159  pub created_at: OffsetDateTime,
160  #[serde(with = "rfc3339")]
161  pub updated_at: OffsetDateTime,
162  #[serde(with = "rfc3339::option", default)]
163  pub last_used_at: Option<OffsetDateTime>,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct LoginSuccess {
168  pub success: bool,
169  pub cookie: ItchCookie,
170  pub key: ItchKey,
171}
172
173// LoginCaptchaError is defined here because it's not returned by the API
174// the same way the other errors, but in its own separate struct
175#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
176#[error(
177  r#"A reCAPTCHA verification is required to continue!
178  Go to "{recaptcha_url}" and solve the reCAPTCHA.
179  To obtain the token, paste the following command on the developer console:
180    console.log(grecaptcha.getResponse())
181  Then run the login command again with the --recaptcha-response option."#
182)]
183pub struct LoginCaptchaError {
184  pub success: bool,
185  pub recaptcha_needed: bool,
186  pub recaptcha_url: String,
187}
188
189// LoginTOTPError is defined here because it's not returned by the API
190// the same way the other errors, but in its own separate struct
191#[derive(Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[error(
193  r#"The account has two-step verification enabled via TOTP.
194  To complete the login, run the totp verification command with the following options:
195    --totp-token="{token}"
196    --totp-code={{VERIFICATION_CODE}}"#
197)]
198pub struct LoginTOTPError {
199  pub success: bool,
200  pub totp_needed: bool,
201  pub token: String,
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205pub struct User {
206  pub id: UserID,
207  pub username: String,
208  pub display_name: Option<String>,
209  pub url: String,
210  pub cover_url: Option<String>,
211  /// Only present if `cover_url` is animated. URL to the first frame of the cover.
212  pub still_cover_url: Option<String>,
213}
214
215impl User {
216  /// Get the display name of the user, or the username if it is missing
217  #[must_use]
218  pub fn get_name(&self) -> &str {
219    self.display_name.as_deref().unwrap_or(&self.username)
220  }
221}
222
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224pub struct Profile {
225  #[serde(flatten)]
226  pub user: User,
227  pub gamer: bool,
228  pub developer: bool,
229  pub press_user: bool,
230}
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct GameSale {
234  pub id: SaleID,
235  /// Rate must be an integger between -100 and 100
236  /// A negative number means the game is more expensive than it was before the sale
237  pub rate: i8,
238  #[serde(with = "rfc3339")]
239  pub start_date: OffsetDateTime,
240  #[serde(with = "rfc3339")]
241  pub end_date: OffsetDateTime,
242}
243
244#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum GameType {
247  Default,
248  Html,
249  Flash,
250  Java,
251  Unity,
252}
253
254#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum GameClassification {
257  Game,
258  Assets,
259  GameMod,
260  PhysicalGame,
261  Soundtrack,
262  Tool,
263  Comic,
264  Book,
265  Other,
266}
267
268#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
269#[serde(rename_all = "snake_case")]
270pub enum GameTrait {
271  PLinux,
272  PWindows,
273  POsx,
274  PAndroid,
275  CanBeBought,
276  HasDemo,
277  InPressSystem,
278}
279
280/// This struct represents all the shared fields among the different Game structs
281///
282/// It should always be used alongside serde flattten
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284pub struct GameCommon {
285  pub id: GameID,
286  pub url: String,
287  pub title: String,
288  pub short_text: Option<String>,
289  pub r#type: GameType,
290  pub classification: GameClassification,
291  pub cover_url: Option<String>,
292  #[serde(with = "rfc3339")]
293  pub created_at: OffsetDateTime,
294  #[serde(with = "rfc3339::option", default)]
295  pub published_at: Option<OffsetDateTime>,
296  pub min_price: u64,
297  pub sale: Option<GameSale>,
298  #[serde(deserialize_with = "empty_object_as_vec")]
299  pub traits: Vec<GameTrait>,
300}
301
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303pub struct Game {
304  #[serde(flatten)]
305  pub game_info: GameCommon,
306  pub user: User,
307}
308
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310pub struct Collection {
311  pub id: CollectionID,
312  pub title: String,
313  pub games_count: u64,
314  #[serde(with = "rfc3339")]
315  pub created_at: OffsetDateTime,
316  #[serde(with = "rfc3339")]
317  pub updated_at: OffsetDateTime,
318}
319
320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321pub struct CollectionGame {
322  #[serde(flatten)]
323  pub game_info: GameCommon,
324}
325
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
327pub struct CollectionGameItem {
328  pub game: CollectionGame,
329  pub position: u64,
330  pub user_id: UserID,
331  #[serde(with = "rfc3339")]
332  pub created_at: OffsetDateTime,
333}
334
335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
336pub struct CreatedGame {
337  #[serde(flatten)]
338  pub game_info: GameCommon,
339  pub user: User,
340  pub views_count: u64,
341  pub purchases_count: u64,
342  pub downloads_count: u64,
343  pub published: bool,
344}
345
346#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
347pub struct OwnedKey {
348  pub id: OwnedKeyID,
349  pub game_id: GameID,
350  pub downloads: u64,
351  pub game: Game,
352  #[serde(with = "rfc3339")]
353  pub created_at: OffsetDateTime,
354  #[serde(with = "rfc3339")]
355  pub updated_at: OffsetDateTime,
356}
357
358/// This struct represents all the shared fields among the different Build structs
359///
360/// It should always be used alongside serde flattten
361#[serde_as]
362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363pub struct BuildCommon {
364  pub id: BuildID,
365  #[serde_as(deserialize_as = "DefaultOnError")]
366  pub parent_build_id: Option<BuildID>,
367  pub version: u64,
368  pub user_version: Option<String>,
369  #[serde(with = "rfc3339")]
370  pub created_at: OffsetDateTime,
371  #[serde(with = "rfc3339")]
372  pub updated_at: OffsetDateTime,
373}
374
375#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
376#[serde(rename_all = "snake_case")]
377pub enum BuildFileType {
378  Archive,
379  Patch,
380  Signature,
381  Manifest,
382  Unpacked,
383}
384
385#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
386#[serde(rename_all = "snake_case")]
387pub enum BuildFileSubtype {
388  Default,
389  Optimized,
390  Accelerated,
391  Gzip,
392}
393
394#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
395#[serde(rename_all = "snake_case")]
396pub enum BuildFileState {
397  Uploaded,
398}
399
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
401pub struct BuildFile {
402  pub size: u64,
403  pub r#type: BuildFileType,
404  pub sub_type: BuildFileSubtype,
405  pub state: BuildFileState,
406}
407
408#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
409#[serde(rename_all = "snake_case")]
410pub enum BuildState {
411  Completed,
412}
413
414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
415pub struct Build {
416  #[serde(flatten)]
417  pub build_info: BuildCommon,
418  pub upload_id: UploadID,
419  pub user: User,
420  pub state: BuildState,
421  #[serde(deserialize_with = "empty_object_as_vec")]
422  pub files: Vec<BuildFile>,
423}
424
425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
426pub struct UpgradePathBuild {
427  #[serde(flatten)]
428  pub build_info: BuildCommon,
429  pub upload_id: UploadID,
430  #[serde(deserialize_with = "empty_object_as_vec")]
431  pub files: Vec<BuildFile>,
432}
433
434#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
435pub struct UploadBuild {
436  #[serde(flatten)]
437  pub build_info: BuildCommon,
438}
439
440#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
441#[serde(rename_all = "snake_case")]
442pub enum UploadType {
443  Default,
444  Html,
445  Flash,
446  Java,
447  Unity,
448  Soundtrack,
449  Book,
450  Video,
451  Documentation,
452  Mod,
453  AudioAssets,
454  GraphicalAssets,
455  Sourcecode,
456  Other,
457}
458
459#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
460#[serde(rename_all = "snake_case")]
461pub enum UploadTrait {
462  PLinux,
463  PWindows,
464  POsx,
465  PAndroid,
466  Demo,
467}
468
469#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
470#[serde(tag = "storage", rename_all = "snake_case")]
471pub enum UploadStorage {
472  Hosted {
473    size: u64,
474    md5_hash: Option<String>,
475  },
476  Build {
477    size: u64,
478    build: UploadBuild,
479    build_id: BuildID,
480    channel_name: String,
481  },
482  External {
483    host: String,
484  },
485}
486
487#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
488pub struct Upload {
489  pub position: u64,
490  pub id: UploadID,
491  pub game_id: GameID,
492  pub r#type: UploadType,
493  #[serde(deserialize_with = "empty_object_as_vec")]
494  pub traits: Vec<UploadTrait>,
495  pub filename: String,
496  pub display_name: Option<String>,
497  #[serde(flatten)]
498  pub storage: UploadStorage,
499  #[serde(with = "rfc3339")]
500  pub created_at: OffsetDateTime,
501  #[serde(with = "rfc3339")]
502  pub updated_at: OffsetDateTime,
503}
504
505impl Upload {
506  /// Get the display name of the upload, or the filename if it is missing
507  #[must_use]
508  pub fn get_name(&self) -> &str {
509    self.display_name.as_deref().unwrap_or(&self.filename)
510  }
511
512  /// Get the hash of the upload, or None if it is missing
513  #[must_use]
514  pub fn get_hash(&self) -> Option<&str> {
515    match &self.storage {
516      UploadStorage::Hosted { md5_hash, .. } => md5_hash.as_deref(),
517      _ => None,
518    }
519  }
520}
521
522#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
523#[serde(rename_all = "kebab-case")]
524pub enum ManifestActionPlatform {
525  Linux,
526  Windows,
527  Osx,
528  Unknown,
529}
530
531#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532pub struct ManifestAction {
533  pub name: String,
534  pub path: String,
535  pub platform: Option<ManifestActionPlatform>,
536  pub args: Option<Vec<String>>,
537  pub sandbox: Option<bool>,
538  pub console: Option<bool>,
539  /// Games can ask for an itch.io API key by setting the `scope` parameter
540  pub scope: Option<String>,
541}
542
543#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
544pub enum ManifestPrerequisiteName {
545  #[serde(rename = "vcredist-2010-x64")]
546  Vcredist2010x64,
547  #[serde(rename = "vcredist-2010-x86")]
548  Vcredist2010x86,
549  #[serde(rename = "vcredist-2013-x64")]
550  Vcredist2013x64,
551  #[serde(rename = "vcredist-2013-x86")]
552  Vcredist2013x86,
553  #[serde(rename = "vcredist-2015-x64")]
554  Vcredist2015x64,
555  #[serde(rename = "vcredist-2015-x86")]
556  Vcredist2015x86,
557  #[serde(rename = "vcredist-2017-x64")]
558  Vcredist2017x64,
559  #[serde(rename = "vcredist-2017-x86")]
560  Vcredist2017x86,
561  #[serde(rename = "vcredist-2019-x64")]
562  Vcredist2019x64,
563  #[serde(rename = "vcredist-2019-x86")]
564  Vcredist2019x86,
565
566  #[serde(rename = "net-4.5.2")]
567  Net452,
568  #[serde(rename = "net-4.6")]
569  Net46,
570  #[serde(rename = "net-4.6.2")]
571  Net462,
572
573  #[serde(rename = "xna-4.0")]
574  Xna40,
575
576  #[serde(rename = "dx-june-2010")]
577  DxJune2010,
578}
579
580#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
581pub struct ManifestPrerequisite {
582  pub name: ManifestPrerequisiteName,
583}
584
585#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
586pub struct Manifest {
587  pub actions: Option<Vec<ManifestAction>>,
588  pub prereqs: Option<Vec<ManifestPrerequisite>>,
589}
590
591#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
592#[serde(tag = "object_type", rename_all = "snake_case")]
593pub enum ScannedArchiveObject {
594  Upload { object_id: UploadID },
595  Build { object_id: BuildID },
596}
597
598#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
599pub struct ScannedArchive {
600  #[serde(flatten)]
601  pub object_type: ScannedArchiveObject,
602  pub extracted_size: Option<u64>,
603  pub manifest: Option<Manifest>,
604  // TODO: add launch targets structure
605  //pub launch_targets: Option<Vec<>>,
606  #[serde(with = "rfc3339")]
607  pub created_at: OffsetDateTime,
608  #[serde(with = "rfc3339")]
609  pub updated_at: OffsetDateTime,
610}