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