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