humble_cli/
models.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2
3use chrono::NaiveDateTime;
4use serde::Deserialize;
5use serde_with::{serde_as, VecSkipError};
6
7#[derive(Debug, PartialEq)]
8pub enum ClaimStatus {
9    Yes,
10    No,
11    NotAvailable,
12}
13
14impl ToString for ClaimStatus {
15    fn to_string(&self) -> String {
16        match self {
17            Self::Yes => "Yes",
18            Self::No => "No",
19            Self::NotAvailable => "-",
20        }
21        .to_owned()
22    }
23}
24
25// ===========================================================================
26// Models related to the purchased Bundles
27// ===========================================================================
28pub type BundleMap = HashMap<String, Bundle>;
29
30#[serde_as]
31#[derive(Debug, Deserialize)]
32pub struct Bundle {
33    pub gamekey: String,
34    pub created: NaiveDateTime,
35    pub claimed: bool,
36
37    pub tpkd_dict: HashMap<String, serde_json::Value>,
38
39    #[serde(rename = "product")]
40    pub details: BundleDetails,
41
42    #[serde(rename = "subproducts")]
43    #[serde_as(as = "VecSkipError<_>")]
44    pub products: Vec<Product>,
45}
46
47pub struct ProductKey {
48    pub redeemed: bool,
49    pub human_name: String,
50}
51
52impl Bundle {
53    pub fn claim_status(&self) -> ClaimStatus {
54        let product_keys = self.product_keys();
55        let total_count = product_keys.len();
56        if total_count == 0 {
57            return ClaimStatus::NotAvailable;
58        }
59
60        let unused_count = product_keys.iter().filter(|k| !k.redeemed).count();
61        if unused_count > 0 {
62            ClaimStatus::No
63        } else {
64            ClaimStatus::Yes
65        }
66    }
67
68    pub fn product_keys(&self) -> Vec<ProductKey> {
69        let Some(tpks) = self.tpkd_dict.get("all_tpks") else {
70            return vec![];
71        };
72
73        let tpks = tpks.as_array().expect("cannot read all_tpks");
74
75        let mut result = vec![];
76        for tpk in tpks {
77            let redeemed = tpk["redeemed_key_val"].is_string();
78            let human_name = tpk["human_name"].as_str().unwrap_or("").to_owned();
79
80            result.push(ProductKey {
81                redeemed,
82                human_name,
83            });
84        }
85
86        result
87    }
88}
89
90#[derive(Debug, Deserialize)]
91pub struct BundleDetails {
92    pub machine_name: String,
93    pub human_name: String,
94}
95
96impl Bundle {
97    pub fn total_size(&self) -> u64 {
98        self.products.iter().map(|e| e.total_size()).sum()
99    }
100}
101
102#[derive(Debug, Deserialize, Default)]
103pub struct Product {
104    pub machine_name: String,
105    pub human_name: String,
106
107    #[serde(rename = "url")]
108    pub product_details_url: String,
109
110    /// List of associated downloads with this product.
111    ///
112    /// Note: Each product usually has one item here.
113    pub downloads: Vec<ProductDownload>,
114}
115
116impl Product {
117    pub fn total_size(&self) -> u64 {
118        self.downloads.iter().map(|e| e.total_size()).sum()
119    }
120
121    pub fn formats_as_vec(&self) -> Vec<&str> {
122        self.downloads
123            .iter()
124            .flat_map(|d| d.formats_as_vec())
125            .collect::<Vec<_>>()
126    }
127
128    pub fn formats(&self) -> String {
129        self.formats_as_vec().join(", ")
130    }
131
132    pub fn name_matches(&self, keywords: &[&str], mode: &MatchMode) -> bool {
133        let human_name = self.human_name.to_lowercase();
134        let mine: HashSet<&str> = human_name.split(" ").collect();
135
136        let mut kw_matched = 0;
137        for kw in keywords {
138            if !mine.contains(kw) {
139                continue;
140            }
141
142            match mode {
143                MatchMode::Any => return true,
144                MatchMode::All => {
145                    kw_matched += 1;
146                    if kw_matched == keywords.len() {
147                        return true;
148                    }
149                }
150            }
151        }
152
153        false
154    }
155}
156
157#[derive(Debug, Deserialize)]
158pub struct ProductDownload {
159    #[serde(rename = "download_struct")]
160    pub items: Vec<DownloadInfo>,
161}
162
163impl ProductDownload {
164    pub fn total_size(&self) -> u64 {
165        self.items.iter().map(|e| e.file_size).sum()
166    }
167
168    pub fn formats_as_vec(&self) -> Vec<&str> {
169        self.items.iter().map(|s| &s.format[..]).collect::<Vec<_>>()
170    }
171
172    pub fn formats(&self) -> String {
173        self.formats_as_vec().join(", ")
174    }
175}
176
177#[derive(Debug, Deserialize)]
178pub struct DownloadInfo {
179    pub md5: String,
180
181    #[serde(rename = "name")]
182    pub format: String,
183
184    pub file_size: u64,
185
186    pub url: DownloadUrl,
187}
188
189#[derive(Debug, Deserialize)]
190pub struct DownloadUrl {
191    pub web: String,
192    pub bittorrent: String,
193}
194
195#[derive(Debug, Deserialize)]
196pub struct GameKey {
197    pub gamekey: String,
198}
199
200// ===========================================================================
201// Models related to the Bundle Choices
202// ===========================================================================
203#[derive(Debug, Deserialize)]
204pub struct HumbleChoice {
205    #[serde(rename = "contentChoiceOptions")]
206    pub options: ContentChoiceOptions,
207}
208
209#[derive(Debug, Deserialize)]
210pub struct ContentChoiceOptions {
211    #[serde(rename = "contentChoiceData")]
212    pub data: ContentChoiceData,
213
214    pub gamekey: Option<String>,
215
216    #[serde(rename = "isActiveContent")]
217    pub is_active_content: bool,
218
219    pub title: String,
220}
221
222#[derive(Debug, Deserialize)]
223pub struct ContentChoiceData {
224    pub game_data: BTreeMap<String, GameData>,
225}
226
227#[derive(Debug, Deserialize)]
228pub struct GameData {
229    pub title: String,
230    pub tpkds: Vec<Tpkd>,
231}
232
233#[derive(Debug, Deserialize)]
234pub struct Tpkd {
235    pub gamekey: Option<String>,
236    pub human_name: String,
237    pub redeemed_key_val: Option<String>,
238}
239
240impl Tpkd {
241    pub fn claim_status(&self) -> ClaimStatus {
242        let redeemed = self.redeemed_key_val.is_some();
243        let is_active = self.gamekey.is_some();
244        if is_active && redeemed {
245            ClaimStatus::Yes
246        } else if is_active {
247            ClaimStatus::No
248        } else {
249            ClaimStatus::NotAvailable
250        }
251    }
252}
253
254#[derive(Clone, Debug)]
255pub enum ChoicePeriod {
256    Current,
257    Date { month: String, year: u16 },
258}
259
260impl ToString for ChoicePeriod {
261    fn to_string(&self) -> String {
262        match self {
263            Self::Current => "home".to_owned(),
264            Self::Date { month, year } => format!("{}-{}", month, year),
265        }
266    }
267}
268
269impl TryFrom<&str> for ChoicePeriod {
270    type Error = String;
271
272    fn try_from(value: &str) -> Result<Self, Self::Error> {
273        let value = value.to_lowercase();
274        if value == "current" {
275            return Ok(ChoicePeriod::Current);
276        }
277
278        let month_names = vec![
279            "january",
280            "february",
281            "march",
282            "april",
283            "may",
284            "june",
285            "july",
286            "august",
287            "september",
288            "october",
289            "november",
290            "december",
291        ];
292
293        let parts: Vec<_> = value.split("-").collect();
294        if parts.len() != 2 {
295            return Err("invalid format. expected {month name}-{year}".to_owned());
296        }
297
298        let month = parts[0];
299        if !month_names.contains(&month) {
300            return Err(format!("invalid month: {month}"));
301        }
302
303        let year: u16 = parts[1]
304            .parse()
305            .map_err(|e| format!("invalid year value: {}", e))?;
306
307        if year < 2018 || year > 2030 {
308            return Err("years out of 2018-2030 range are not supported".to_owned());
309        }
310
311        Ok(ChoicePeriod::Date {
312            month: month.to_owned(),
313            year,
314        })
315    }
316}
317
318#[derive(Copy, Clone, Debug)]
319pub enum MatchMode {
320    All,
321    Any,
322}
323
324impl TryFrom<&str> for MatchMode {
325    type Error = String;
326
327    fn try_from(value: &str) -> Result<Self, Self::Error> {
328        let lowercase = value.to_lowercase();
329        match lowercase.as_str() {
330            "all" => Ok(MatchMode::All),
331            "any" => Ok(MatchMode::Any),
332            _ => Err(format!("invalid match mode: {}", value)),
333        }
334    }
335}