1mod config;
2mod download;
3mod humble_api;
4mod key_match;
5mod models;
6mod util;
7
8pub mod prelude {
9 pub use crate::auth;
10 pub use crate::download_bundle;
11 pub use crate::list_bundles;
12 pub use crate::list_humble_choices;
13 pub use crate::search;
14 pub use crate::show_bundle_details;
15
16 pub use crate::humble_api::{ApiError, HumbleApi};
17 pub use crate::models::*;
18 pub use crate::util::byte_string_to_number;
19}
20
21use anyhow::{anyhow, Context};
22use config::{get_config, set_config, Config};
23use humble_api::{ApiError, HumbleApi};
24use key_match::KeyMatch;
25use prelude::*;
26use std::fs;
27use std::path;
28use std::time::Duration;
29use tabled::settings::object::Columns;
30use tabled::settings::Alignment;
31use tabled::settings::Merge;
32use tabled::settings::Modify;
33use tabled::settings::Style;
34
35pub fn auth(session_key: &str) -> Result<(), anyhow::Error> {
36 set_config(Config {
37 session_key: session_key.to_owned(),
38 })
39}
40
41pub fn handle_http_errors<T>(input: Result<T, ApiError>) -> Result<T, anyhow::Error> {
42 match input {
43 Ok(val) => Ok(val),
44 Err(ApiError::NetworkError(e)) if e.is_status() => match e.status().unwrap() {
45 reqwest::StatusCode::UNAUTHORIZED => Err(anyhow!(
46 "Unauthorized request (401). Is the session key correct?"
47 )),
48 reqwest::StatusCode::NOT_FOUND => Err(anyhow!(
49 "Bundle not found (404). Is the bundle key correct?"
50 )),
51 s => Err(anyhow!("failed with status: {}", s)),
52 },
53 Err(e) => Err(anyhow!("failed: {}", e)),
54 }
55}
56
57pub fn list_humble_choices(period: &ChoicePeriod) -> Result<(), anyhow::Error> {
58 let config = get_config()?;
59 let api = HumbleApi::new(&config.session_key);
60
61 let choices = api.read_bundle_choices(&period.to_string())?;
62
63 println!();
64 println!("{}", choices.options.title);
65 println!();
66
67 let options = choices.options;
68
69 let mut builder = tabled::builder::Builder::default();
70 builder.push_record(["#", "Title", "Redeemed"]);
71
72 let mut counter = 1;
73 let mut all_redeemed = true;
74 for (_, game_data) in options.data.game_data.iter() {
75 for tpkd in game_data.tpkds.iter() {
76 builder.push_record([
77 counter.to_string().as_str(),
78 tpkd.human_name.as_str(),
79 tpkd.claim_status().to_string().as_str(),
80 ]);
81
82 counter += 1;
83
84 if tpkd.claim_status() == ClaimStatus::No {
85 all_redeemed = false;
86 }
87 }
88 }
89
90 let table = builder
91 .build()
92 .with(Style::psql())
93 .with(Modify::new(Columns::new(0..=0)).with(Alignment::right()))
94 .with(Modify::new(Columns::new(1..=1)).with(Alignment::left()))
95 .to_string();
96
97 println!("{table}");
98
99 if !all_redeemed {
100 let url = "https://www.humblebundle.com/membership/home";
101 println!("Visit {url} to redeem your keys.");
102 }
103 Ok(())
104}
105
106pub fn search(keywords: &str, match_mode: MatchMode) -> Result<(), anyhow::Error> {
107 let config = get_config()?;
108 let api = HumbleApi::new(&config.session_key);
109
110 let keywords = keywords.to_lowercase();
111 let keywords: Vec<&str> = keywords.split(" ").collect();
112
113 let bundles = handle_http_errors(api.list_bundles())?;
114 type BundleItem<'a> = (&'a Bundle, String);
115 let mut search_result: Vec<BundleItem> = vec![];
116
117 for b in &bundles {
118 for p in &b.products {
119 if p.name_matches(&keywords, &match_mode) {
120 search_result.push((b, p.human_name.to_owned()));
121 }
122 }
123 }
124
125 if search_result.is_empty() {
126 println!("Nothing found");
127 return Ok(());
128 }
129
130 let mut builder = tabled::builder::Builder::default();
131 builder.push_record(["Key", "Name", "Sub Item"]);
132 for record in search_result {
133 builder.push_record([
134 record.0.gamekey.as_str(),
135 record.0.details.human_name.as_str(),
136 record.1.as_str(),
137 ]);
138 }
139
140 let table = builder
141 .build()
142 .with(Style::psql())
143 .with(Modify::new(Columns::new(1..=2)).with(Alignment::left()))
144 .with(Merge::vertical())
145 .to_string();
146
147 println!("{table}");
148 Ok(())
149}
150
151pub fn list_bundles(fields: Vec<String>, claimed_filter: &str) -> Result<(), anyhow::Error> {
152 let config = get_config()?;
153 let api = HumbleApi::new(&config.session_key);
154 let key_only = fields.len() == 1 && fields[0] == "key";
155
156 if key_only && claimed_filter == "all" {
160 let ids = handle_http_errors(api.list_bundle_keys())?;
161 for id in ids {
162 println!("{}", id);
163 }
164
165 return Ok(());
166 }
167
168 let mut bundles = handle_http_errors(api.list_bundles())?;
169
170 if claimed_filter != "all" {
171 let claimed = claimed_filter == "yes";
172 bundles.retain(|b| {
173 let status = b.claim_status();
174 status == ClaimStatus::Yes && claimed || status == ClaimStatus::No && !claimed
175 });
176 }
177
178 if !fields.is_empty() {
179 return bulk_format(&fields, &bundles);
180 }
181
182 println!("{} bundle(s) found.\n", bundles.len());
183
184 if bundles.is_empty() {
185 return Ok(());
186 }
187
188 let mut builder = tabled::builder::Builder::default();
189 builder.push_record(["Key", "Name", "Size", "Claimed"]);
190
191 for p in bundles {
192 builder.push_record([
193 p.gamekey.as_str(),
194 p.details.human_name.as_str(),
195 util::humanize_bytes(p.total_size()).as_str(),
196 p.claim_status().to_string().as_str(),
197 ]);
198 }
199
200 let table = builder
201 .build()
202 .with(Style::psql())
203 .with(Modify::new(Columns::new(1..=1)).with(Alignment::left()))
204 .with(Modify::new(Columns::new(2..=2)).with(Alignment::right()))
205 .to_string();
206 println!("{table}");
207
208 Ok(())
209}
210
211fn find_key(all_keys: Vec<String>, key_to_find: &str) -> Option<String> {
212 let key_match = KeyMatch::new(all_keys, key_to_find);
213 let keys = key_match.get_matches();
214
215 match keys.len() {
216 1 => Some(keys[0].clone()),
217 0 => {
218 eprintln!("No bundle matches '{}'", key_to_find);
219 None
220 }
221 _ => {
222 eprintln!("More than one bundle matches '{}':", key_to_find);
223 for key in keys {
224 eprintln!("{}", key);
225 }
226 None
227 }
228 }
229}
230
231pub fn show_bundle_details(bundle_key: &str) -> Result<(), anyhow::Error> {
232 let config = get_config()?;
233 let api = crate::HumbleApi::new(&config.session_key);
234
235 let bundle_key = match find_key(handle_http_errors(api.list_bundle_keys())?, bundle_key) {
236 Some(key) => key,
237 None => return Ok(()),
238 };
239
240 let bundle = handle_http_errors(api.read_bundle(&bundle_key))?;
241
242 println!();
243 println!("{}", bundle.details.human_name);
244 println!();
245 println!("Purchased : {}", bundle.created.format("%Y-%m-%d"));
246 if let (Some(amount), Some(currency)) = (bundle.amount_spent.as_ref(), bundle.currency.as_ref())
247 {
248 println!("Amount spent : {} {}", amount, currency);
249 }
250 println!(
251 "Total size : {}",
252 util::humanize_bytes(bundle.total_size())
253 );
254 println!();
255
256 if !bundle.products.is_empty() {
257 let mut builder = tabled::builder::Builder::default();
258 builder.push_record(["#", "Sub-item", "Format", "Total Size"]);
259
260 for (idx, entry) in bundle.products.iter().enumerate() {
261 builder.push_record([
262 &(idx + 1).to_string(),
263 &entry.human_name,
264 &entry.formats(),
265 &util::humanize_bytes(entry.total_size()),
266 ]);
267 }
268 let table = builder
269 .build()
270 .with(Style::psql())
271 .with(Modify::new(Columns::new(0..=0)).with(Alignment::right()))
272 .with(Modify::new(Columns::new(1..=1)).with(Alignment::left()))
273 .with(Modify::new(Columns::new(2..=2)).with(Alignment::left()))
274 .with(Modify::new(Columns::new(3..=3)).with(Alignment::right()))
275 .to_string();
276
277 println!("{table}");
278 } else {
279 println!("No items to show.");
280 }
281
282 let product_keys = bundle.product_keys();
284 if !product_keys.is_empty() {
285 println!();
286 println!("Keys in this bundle:");
287 println!();
288 let mut builder = tabled::builder::Builder::default();
289 builder.push_record(["#", "Key Name", "Redeemed"]);
290
291 let mut all_redeemed = true;
292 for (idx, entry) in product_keys.iter().enumerate() {
293 builder.push_record([
294 (idx + 1).to_string().as_str(),
295 entry.human_name.as_str(),
296 if entry.redeemed { "Yes" } else { "No" },
297 ]);
298
299 if !entry.redeemed {
300 all_redeemed = false;
301 }
302 }
303
304 let table = builder
305 .build()
306 .with(Style::psql())
307 .with(Modify::new(Columns::new(0..=0)).with(Alignment::right()))
308 .with(Modify::new(Columns::new(1..=1)).with(Alignment::left()))
309 .with(Modify::new(Columns::new(2..=2)).with(Alignment::center()))
310 .to_string();
311
312 println!("{table}");
313
314 if !all_redeemed {
315 let url = "https://www.humblebundle.com/home/keys";
316 println!("Visit {url} to redeem your keys.");
317 }
318 }
319
320 Ok(())
321}
322
323pub fn download_bundles(
324 bundle_list_file: &str,
325 formats: Vec<String>,
326 max_size: u64,
327 torrents_only: bool,
328 cur_dir: bool,
329) -> Result<(), anyhow::Error> {
330 let buffer = fs::read_to_string(bundle_list_file)?;
332
333 let mut err_vec: Vec<(String, anyhow::Error)> = Vec::new();
334 let lines = buffer.lines();
335 for line in lines {
336 let parts: Vec<&str> = line.split(',').collect();
337 let bundle_key: &str = parts[0];
338 let bundle_name: &str = if !parts.is_empty() {
339 parts[1]
340 } else {
341 parts[0]
342 };
343
344 if let Err(download_err) =
345 download_bundle(bundle_key, &formats, max_size, None, torrents_only, cur_dir)
346 {
347 err_vec.push((String::from(bundle_name), download_err));
348 }
349 }
350
351 for err_item in err_vec {
353 println!("Error handeling: {}", err_item.0);
354 println!("Error: {}", err_item.1);
355 }
356 Ok(())
357}
358
359pub fn download_bundle(
360 bundle_key: &str,
361 formats: &[String],
362 max_size: u64,
363 item_numbers: Option<&str>,
364 torrents_only: bool,
365 cur_dir: bool,
366) -> Result<(), anyhow::Error> {
367 let config = get_config()?;
368
369 let api = crate::HumbleApi::new(&config.session_key);
370
371 let bundle_key = match find_key(handle_http_errors(api.list_bundle_keys())?, bundle_key) {
372 Some(key) => key,
373 None => return Ok(()),
374 };
375
376 let bundle = handle_http_errors(api.read_bundle(&bundle_key))?;
377
378 let item_numbers = if let Some(value) = item_numbers {
382 let ranges = value.split(',').collect::<Vec<_>>();
383 util::union_usize_ranges(&ranges, bundle.products.len())?
384 } else {
385 vec![]
386 };
387
388 let products = bundle
392 .products
393 .iter()
394 .enumerate()
395 .filter(|&(i, _)| item_numbers.is_empty() || item_numbers.contains(&(i + 1)))
396 .map(|(_, p)| p)
397 .filter(|p| max_size == 0 || p.total_size() < max_size)
398 .filter(|p| formats.is_empty() || util::str_vectors_intersect(&p.formats_as_vec(), formats))
399 .collect::<Vec<_>>();
400
401 if products.is_empty() {
402 println!("Nothing to download");
403 return Ok(());
404 }
405
406 let dir_name = util::replace_invalid_chars_in_filename(&bundle.details.human_name);
408 let bundle_dir = match cur_dir {
409 false => create_dir(&dir_name)?,
410 true => open_dir(".")?,
411 };
412
413 let http_read_timeout = Duration::from_secs(30);
414 let client = reqwest::Client::builder()
415 .read_timeout(http_read_timeout)
416 .build()?;
417
418 for product in products {
419 if max_size > 0 && product.total_size() > max_size {
420 continue;
421 }
422
423 println!();
424 println!("{}", product.human_name);
425
426 let dir_name = util::replace_invalid_chars_in_filename(&product.human_name);
427 let entry_dir = bundle_dir.join(dir_name);
428 if !entry_dir.exists() {
429 fs::create_dir(&entry_dir)?;
430 }
431
432 for product_download in product.downloads.iter() {
433 for dl_info in product_download.items.iter() {
434 if !formats.is_empty() && !formats.contains(&dl_info.format.to_lowercase()) {
435 println!("Skipping '{}'", dl_info.format);
436 continue;
437 }
438
439 let download_url = if torrents_only {
440 &dl_info.url.bittorrent
441 } else {
442 &dl_info.url.web
443 };
444
445 let filename = util::extract_filename_from_url(download_url)
446 .context(format!("Cannot get file name from URL '{}'", download_url))?;
447 let download_path = entry_dir.join(&filename);
448
449 let f = download::download_file(
450 &client,
451 download_url,
452 download_path.to_str().unwrap(),
453 &filename,
454 );
455 util::run_future(f)?;
456 }
457 }
458 }
459
460 Ok(())
461}
462
463fn create_dir(dir: &str) -> Result<path::PathBuf, std::io::Error> {
464 let dir = path::Path::new(dir).to_owned();
465 if !dir.exists() {
466 fs::create_dir(&dir)?;
467 }
468 Ok(dir)
469}
470
471fn open_dir(dir: &str) -> Result<path::PathBuf, std::io::Error> {
472 let dir = path::Path::new(dir).to_owned();
473 Ok(dir)
474}
475const VALID_FIELDS: [&str; 4] = ["key", "name", "size", "claimed"];
476
477fn validate_fields(fields: &[String]) -> bool {
478 for field in fields {
479 if !VALID_FIELDS.contains(&field.to_lowercase().as_str()) {
480 return false;
481 }
482 }
483 true
484}
485
486fn bulk_format(fields: &[String], bundles: &[Bundle]) -> Result<(), anyhow::Error> {
487 if !validate_fields(fields) {
488 return Err(anyhow!("invalid field in fields: {}", fields.join(",")));
489 }
490 let print_key = fields.contains(&VALID_FIELDS[0].to_lowercase());
491 let print_name = fields.contains(&VALID_FIELDS[1].to_lowercase());
492 let print_size = fields.contains(&VALID_FIELDS[2].to_lowercase());
493 let print_claimed = fields.contains(&VALID_FIELDS[3].to_lowercase());
494 for b in bundles {
495 let mut print_vec: Vec<String> = Vec::new();
496 if print_key {
497 print_vec.push(b.gamekey.clone());
498 };
499 if print_name {
500 print_vec.push(b.details.human_name.clone());
501 };
502 if print_size {
503 print_vec.push(util::humanize_bytes(b.total_size()))
504 };
505 if print_claimed {
506 print_vec.push(b.claim_status().to_string())
507 };
508 println!("{}", print_vec.join(","));
509 }
510 Ok(())
511}