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