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
35
36pub fn auth(session_key: &str) -> Result<(), anyhow::Error> {
37    set_config(Config {
38        session_key: session_key.to_owned(),
39    })
40}
41
42pub fn handle_http_errors<T>(input: Result<T, ApiError>) -> Result<T, anyhow::Error> {
43    match input {
44        Ok(val) => Ok(val),
45        Err(ApiError::NetworkError(e)) if e.is_status() => match e.status().unwrap() {
46            reqwest::StatusCode::UNAUTHORIZED => Err(anyhow!(
47                "Unauthorized request (401). Is the session key correct?"
48            )),
49            reqwest::StatusCode::NOT_FOUND => Err(anyhow!(
50                "Bundle not found (404). Is the bundle key correct?"
51            )),
52            s => Err(anyhow!("failed with status: {}", s)),
53        },
54        Err(e) => Err(anyhow!("failed: {}", e)),
55    }
56}
57
58pub fn list_humble_choices(period: &ChoicePeriod) -> Result<(), anyhow::Error> {
59    let config = get_config()?;
60    let api = HumbleApi::new(&config.session_key);
61
62    let choices = api.read_bundle_choices(&period.to_string())?;
63
64    println!();
65    println!("{}", choices.options.title);
66    println!();
67
68    let options = choices.options;
69
70    let mut builder = tabled::builder::Builder::default();
71    builder.set_header(["#", "Title", "Redeemed"]);
72
73    let mut counter = 1;
74    let mut all_redeemed = true;
75    for (_, game_data) in options.data.game_data.iter() {
76        for tpkd in game_data.tpkds.iter() {
77            builder.push_record([
78                counter.to_string().as_str(),
79                tpkd.human_name.as_str(),
80                tpkd.claim_status().to_string().as_str(),
81            ]);
82
83            counter += 1;
84
85            if tpkd.claim_status() == ClaimStatus::No {
86                all_redeemed = false;
87            }
88        }
89    }
90
91    let table = builder
92        .build()
93        .with(Style::psql())
94        .with(Modify::new(Columns::single(0)).with(Alignment::right()))
95        .with(Modify::new(Columns::single(1)).with(Alignment::left()))
96        .to_string();
97
98    println!("{table}");
99
100    if !all_redeemed {
101        let url = "https://www.humblebundle.com/membership/home";
102        println!("Visit {url} to redeem your keys.");
103    }
104    Ok(())
105}
106
107pub fn search(keywords: &str, match_mode: MatchMode) -> Result<(), anyhow::Error> {
108    let config = get_config()?;
109    let api = HumbleApi::new(&config.session_key);
110
111    let keywords = keywords.to_lowercase();
112    let keywords: Vec<&str> = keywords.split(" ").collect();
113
114    let bundles = handle_http_errors(api.list_bundles())?;
115    type BundleItem<'a> = (&'a Bundle, String);
116    let mut search_result: Vec<BundleItem> = vec![];
117
118    for b in &bundles {
119        for p in &b.products {
120            if p.name_matches(&keywords, &match_mode) {
121                search_result.push((b, p.human_name.to_owned()));
122            }
123        }
124    }
125
126    if search_result.is_empty() {
127        println!("Nothing found");
128        return Ok(());
129    }
130
131    let mut builder = tabled::builder::Builder::default();
132    builder.set_header(["Key", "Name", "Sub Item"]);
133    for record in search_result {
134        builder.push_record([
135            record.0.gamekey.as_str(),
136            record.0.details.human_name.as_str(),
137            record.1.as_str(),
138        ]);
139    }
140
141    let table = builder
142        .build()
143        .with(Style::psql())
144        .with(Modify::new(Columns::single(1)).with(Alignment::left()))
145        .with(Modify::new(Columns::single(2)).with(Alignment::left()))
146        .with(Merge::vertical())
147        .to_string();
148
149    println!("{table}");
150    Ok(())
151}
152
153pub fn list_bundles(id_only: bool, claimed_filter: &str) -> Result<(), anyhow::Error> {
154    let config = get_config()?;
155    let api = HumbleApi::new(&config.session_key);
156
157    // If no filter is required, we can do a single call
158    // and finish quickly. Otherwise we will need to fetch
159    // all bundle data and filter them.
160    if id_only && claimed_filter == "all" {
161        let ids = handle_http_errors(api.list_bundle_keys())?;
162        for id in ids {
163            println!("{}", id);
164        }
165
166        return Ok(());
167    }
168
169    let mut bundles = handle_http_errors(api.list_bundles())?;
170
171    if claimed_filter != "all" {
172        let claimed = claimed_filter == "yes";
173        bundles.retain(|b| {
174            let status = b.claim_status();
175            status == ClaimStatus::Yes && claimed || status == ClaimStatus::No && !claimed
176        });
177    }
178
179    if id_only {
180        for b in bundles {
181            println!("{}", b.gamekey);
182        }
183
184        return Ok(());
185    }
186
187    println!("{} bundle(s) found.\n", bundles.len());
188
189    if bundles.is_empty() {
190        return Ok(());
191    }
192
193    let mut builder = tabled::builder::Builder::default();
194    builder.set_header(["Key", "Name", "Size", "Claimed"]);
195
196    for p in bundles {
197        builder.push_record([
198            p.gamekey.as_str(),
199            p.details.human_name.as_str(),
200            util::humanize_bytes(p.total_size()).as_str(),
201            p.claim_status().to_string().as_str(),
202        ]);
203    }
204
205    let table = builder
206        .build()
207        .with(Style::psql())
208        .with(Modify::new(Columns::single(1)).with(Alignment::left()))
209        .with(Modify::new(Columns::single(2)).with(Alignment::right()))
210        .to_string();
211    println!("{table}");
212
213    Ok(())
214}
215
216fn find_key(all_keys: Vec<String>, key_to_find: &str) -> Option<String> {
217    let key_match = KeyMatch::new(all_keys, key_to_find);
218    let keys = key_match.get_matches();
219
220    match keys.len() {
221        1 => Some(keys[0].clone()),
222        0 => {
223            eprintln!("No bundle matches '{}'", key_to_find);
224            None
225        }
226        _ => {
227            eprintln!("More than one bundle matches '{}':", key_to_find);
228            for key in keys {
229                eprintln!("{}", key);
230            }
231            None
232        }
233    }
234}
235
236pub fn show_bundle_details(bundle_key: &str) -> Result<(), anyhow::Error> {
237    let config = get_config()?;
238    let api = crate::HumbleApi::new(&config.session_key);
239
240    let bundle_key = match find_key(handle_http_errors(api.list_bundle_keys())?, bundle_key) {
241        Some(key) => key,
242        None => return Ok(()),
243    };
244
245    let bundle = handle_http_errors(api.read_bundle(&bundle_key))?;
246
247    println!();
248    println!("{}", bundle.details.human_name);
249    println!();
250    println!("Purchased  : {}", bundle.created.format("%v %I:%M %p"));
251    println!("Total size : {}", util::humanize_bytes(bundle.total_size()));
252    println!();
253
254    if !bundle.products.is_empty() {
255        let mut builder = tabled::builder::Builder::default();
256        builder.set_header(["#", "Sub-item", "Format", "Total Size"]);
257
258        for (idx, entry) in bundle.products.iter().enumerate() {
259            builder.push_record([
260                &(idx + 1).to_string(),
261                &entry.human_name,
262                &entry.formats(),
263                &util::humanize_bytes(entry.total_size()),
264            ]);
265        }
266        let table = builder
267            .build()
268            .with(Style::psql())
269            .with(Modify::new(Columns::single(0)).with(Alignment::right()))
270            .with(Modify::new(Columns::single(1)).with(Alignment::left()))
271            .with(Modify::new(Columns::single(2)).with(Alignment::left()))
272            .with(Modify::new(Columns::single(3)).with(Alignment::right()))
273            .to_string();
274
275        println!("{table}");
276    } else {
277        println!("No items to show.");
278    }
279
280    // Product keys
281    let product_keys = bundle.product_keys();
282    if !product_keys.is_empty() {
283        println!();
284        println!("Keys in this bundle:");
285        println!();
286        let mut builder = tabled::builder::Builder::default();
287        builder.set_header(["#", "Key Name", "Redeemed"]);
288
289        let mut all_redeemed = true;
290        for (idx, entry) in product_keys.iter().enumerate() {
291            builder.push_record([
292                (idx + 1).to_string().as_str(),
293                entry.human_name.as_str(),
294                if entry.redeemed { "Yes" } else { "No" },
295            ]);
296
297            if !entry.redeemed {
298                all_redeemed = false;
299            }
300        }
301
302        let table = builder
303            .build()
304            .with(Style::psql())
305            .with(Modify::new(Columns::single(0)).with(Alignment::right()))
306            .with(Modify::new(Columns::single(1)).with(Alignment::left()))
307            .with(Modify::new(Columns::single(2)).with(Alignment::center()))
308            .to_string();
309
310        println!("{table}");
311
312        if !all_redeemed {
313            let url = "https://www.humblebundle.com/home/keys";
314            println!("Visit {url} to redeem your keys.");
315        }
316    }
317
318    Ok(())
319}
320
321pub fn download_bundle(
322    bundle_key: &str,
323    formats: Vec<String>,
324    max_size: u64,
325    item_numbers: Option<&str>,
326    torrents_only: bool,
327) -> Result<(), anyhow::Error> {
328    let config = get_config()?;
329
330    let api = crate::HumbleApi::new(&config.session_key);
331
332    let bundle_key = match find_key(handle_http_errors(api.list_bundle_keys())?, bundle_key) {
333        Some(key) => key,
334        None => return Ok(()),
335    };
336
337    let bundle = handle_http_errors(api.read_bundle(&bundle_key))?;
338
339    // To parse the item number ranges, we need to know the max value
340    // for unbounded ranges (e.g. 12-). That's why we parse this argument
341    // after we read the bundle from the API.
342    let item_numbers = if let Some(value) = item_numbers {
343        let ranges = value.split(',').collect::<Vec<_>>();
344        util::union_usize_ranges(&ranges, bundle.products.len())?
345    } else {
346        vec![]
347    };
348
349    // Filter products based on entered criteria
350    // Note that item numbers entered by user start at 1, while our index
351    // starts as 0.
352    let products = bundle
353        .products
354        .iter()
355        .enumerate()
356        .filter(|&(i, _)| item_numbers.is_empty() || item_numbers.contains(&(i + 1)))
357        .map(|(_, p)| p)
358        .filter(|p| max_size == 0 || p.total_size() < max_size)
359        .filter(|p| {
360            formats.is_empty() || util::str_vectors_intersect(&p.formats_as_vec(), &formats)
361        })
362        .collect::<Vec<_>>();
363
364    if products.is_empty() {
365        println!("Nothing to download");
366        return Ok(());
367    }
368
369    // Create the bundle directory
370    let dir_name = util::replace_invalid_chars_in_filename(&bundle.details.human_name);
371    let bundle_dir = create_dir(&dir_name)?;
372
373    let http_read_timeout = Duration::from_secs(30);
374    let client = reqwest::Client::builder()
375        .read_timeout(http_read_timeout)
376        .build()?;
377
378    for product in products {
379        if max_size > 0 && product.total_size() > max_size {
380            continue;
381        }
382
383        println!();
384        println!("{}", product.human_name);
385
386        let dir_name = util::replace_invalid_chars_in_filename(&product.human_name);
387        let entry_dir = bundle_dir.join(dir_name);
388        if !entry_dir.exists() {
389            fs::create_dir(&entry_dir)?;
390        }
391
392        for product_download in product.downloads.iter() {
393            for dl_info in product_download.items.iter() {
394                if !formats.is_empty() && !formats.contains(&dl_info.format.to_lowercase()) {
395                    println!("Skipping '{}'", dl_info.format);
396                    continue;
397                }
398
399                let download_url = if torrents_only {
400                    &dl_info.url.bittorrent
401                } else {
402                    &dl_info.url.web
403                };
404
405                let filename = util::extract_filename_from_url(&download_url).context(
406                    format!("Cannot get file name from URL '{}'", &download_url),
407                )?;
408                let download_path = entry_dir.join(&filename);
409
410                let f = download::download_file(
411                    &client,
412                    &download_url,
413                    download_path.to_str().unwrap(),
414                    &filename,
415                );
416                util::run_future(f)?;
417            }
418        }
419    }
420
421    Ok(())
422}
423
424fn create_dir(dir: &str) -> Result<path::PathBuf, std::io::Error> {
425    let dir = path::Path::new(dir).to_owned();
426    if !dir.exists() {
427        fs::create_dir(&dir)?;
428    }
429    Ok(dir)
430}