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
25pub 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 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#[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}