humble_cli/
lib.rs

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 no filter is required, we can do a single call
157    // and finish quickly. Otherwise we will need to fetch
158    // all bundle data and filter them.
159    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    // Product keys
283    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    // ---------------------------------------------------------------------------------------------
331    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    //  --------------------------------------------------------------------------------------------
352    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    // To parse the item number ranges, we need to know the max value
379    // for unbounded ranges (e.g. 12-). That's why we parse this argument
380    // after we read the bundle from the API.
381    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    // Filter products based on entered criteria
389    // Note that item numbers entered by user start at 1, while our index
390    // starts as 0.
391    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    // Create the bundle directory
407    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}