From af002b0149401234cc12bc6c83b5de0ded156943 Mon Sep 17 00:00:00 2001 From: Vegard Matthey Date: Wed, 28 May 2025 17:33:00 +0200 Subject: [PATCH] allow for more config and cli args --- Cargo.toml | 1 + src/main.rs | 212 ++++++++++++++++++++++++++--------- src/response_deserializer.rs | 46 ++++++-- src/select.rs | 14 ++- src/util.rs | 136 ++++++++++++++++------ 5 files changed, 302 insertions(+), 107 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7878caf..ae0d219 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,4 @@ reqwest-retry = "0.6.0" serde = { version = "1.0.204", features = ["derive"] } serde_json = "1.0.121" tokio = { version = "1.39.2", default-features = false, features = ["macros", "rt-multi-thread"] } +zip = "4.0.0" diff --git a/src/main.rs b/src/main.rs index ba9f7bd..13e154e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,25 +18,36 @@ async fn main() { let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3); let client = ClientBuilder::new( reqwest::Client::builder() - .user_agent("Chrome/127") + .user_agent("manga-cli/version-0.1") .build() .unwrap(), ) .with(RetryTransientMiddleware::new_with_policy(retry_policy)) .build(); + let client = &client; let filters = [ // ("publicationDemographic[]", "seinen"), //("status[]", "completed"), // ("contentRating[]", "suggestive"), ]; let limit = config.result_limit; + let results = if let Some(query) = config.search { - search(&client, &query, &filters, limit).await + match query { + util::ConfigSearch::Query(query) => search(client, &query, &filters, limit).await, + util::ConfigSearch::Id(_) => todo!(), + } } else { let input = util::get_input("Enter search query: "); - search(&client, &input, &filters, limit).await + search(client, &input, &filters, limit).await }; - let mut entries = vec![]; + let cover_ex = match config.cover_size { + util::CoverSize::Full => "", + util::CoverSize::W256 => ".256.jpg", + util::CoverSize::W512 => ".512.jpg", + }; + + let mut entry_futures = Vec::new(); for result in results.data.iter() { let mut entry = Entry::new(result.attributes.title.en.clone()); if let Some(year) = result.attributes.year { @@ -56,23 +67,30 @@ async fn main() { entry.add_info("volumes", volumes); } if let Some(cover_data) = &result.relationships[2].attributes { - let data = client - .get(format!( - "https://uploads.mangadex.org/covers/{id}/{}", + // The lib used for converting to sixel is abysmally slow for larger images, this + // should be in a future to allow for multithreaded work + let future = async move { + let image_url = format!( + "https://uploads.mangadex.org/covers/{id}/{}{cover_ex}", &cover_data.file_name - )) - .send() - .await - .unwrap() - .bytes() - .await - .unwrap(); - let result = util::convert_to_sixel(&data); + ); + let data = client + .get(&image_url) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + let result = util::convert_to_sixel(&data); - entry.set_image(result) + entry.set_image(result); + entry + }; + entry_futures.push(future); } - entries.push(entry); } + let entries = futures::future::join_all(entry_futures).await; let choice = match select::select(&entries) { Ok(v) => v, Err(e) => { @@ -92,7 +110,43 @@ async fn main() { } } }; - let mut chapters = match get_chapters(&client, choice_id).await { + let selection_type = if let Some(selection_type) = config.selection_type { + match selection_type { + util::ConfigSelectionType::Volume => loop { + let input = util::get_input("Choose volumes: ").replace(" ", ""); + if let Some(selection) = util::choose_volumes(input.as_str()) { + break util::SelectionType::Volume(selection); + } + }, + util::ConfigSelectionType::Chapter => loop { + let input = util::get_input("Choose chapters: ").replace(" ", ""); + if let Some(selection) = util::choose_chapters(input.as_str()) { + break util::SelectionType::Chapter(selection); + } + }, + } + } else { + 'outer: loop { + match util::get_input("Select by volume or chapter? [v/c] : ").as_str() { + "v" | "volume" => loop { + let input = util::get_input("Choose volumes: ").replace(" ", ""); + if let Some(selection) = util::choose_volumes(input.as_str()) { + break 'outer util::SelectionType::Volume(selection); + } + }, + "c" | "chapter" => { + let input = util::get_input("Choose chapters: ").replace(" ", ""); + if let Some(selection) = util::choose_chapters(input.as_str()) { + break 'outer util::SelectionType::Chapter(selection); + } + } + _ => { + eprintln!("Invalid input"); + } + } + } + }; + let mut chapters = match get_chapters(client, choice_id).await { Ok(v) => v, Err(e) => { eprintln!("ERROR: {:#?}", e); @@ -106,67 +160,113 @@ async fn main() { .partial_cmp(&b.attributes.chapter.unwrap_or(-1.)) .unwrap() }); + dbg!("got chapters"); - let selection_type = loop { - match util::get_input("Select by volume or chapter? [v/c] : ").as_str() { - "v" | "volume" => break util::SelectionType::Volume(util::choose_volumes()), - "c" | "chapter" => break util::SelectionType::Chapter(util::choose_chapters()), - _ => { - eprintln!("Invalid input"); - continue; - } - } - }; + let create_volumes = matches!(selection_type, util::SelectionType::Volume(_)); let selected_chapters = util::get_chapters_from_selection(util::Selection::new(selection_type, bonus), &chapters); - let mut chapter_json_futures = vec![]; + let mut chapters_image_data = Vec::new(); + let mut i = 0; for chapter in &selected_chapters { - let chapter_id = &chapter.id; - let client = &client; - let future = async move { - client - .get(format!("{BASE}/at-home/server/{}", chapter_id)) + // rate limits beware + let r = loop { + let json = client + .get(format!("{BASE}/at-home/server/{}", chapter.id)) .send() .await .unwrap() .text() .await - .unwrap() + .unwrap(); + let result = response_deserializer::deserialize_chapter_images(&json); + match result { + Ok(v) => break v, + Err(e) => { + if e.result != "error" { + panic!("brotha, api gone wrong (wild)"); + } + for error in e.errors { + if error.status == 429 { + println!("you sent too many requests"); + } + std::thread::sleep(std::time::Duration::from_millis(20000)); + } + } + } }; - chapter_json_futures.push(future); + std::thread::sleep(std::time::Duration::from_millis(800)); + println!("downloaded chapter json image data: {i}"); + i += 1; + chapters_image_data.push(r); } - let chapters_image_data: Vec = futures::future::join_all(chapter_json_futures) - .await - .iter() - .map(|m| response_deserializer::deserialize_chapter_images(m)) - .collect(); - - let mut chapter_futures = vec![]; - for (i, image_data) in chapters_image_data.iter().enumerate() { - chapter_futures.push(download_chapter_images( - &client, - image_data, - selected_chapters[i], - )); - } - let chapters = futures::future::join_all(chapter_futures).await; + let chapters = futures::future::join_all( + chapters_image_data + .iter() + .enumerate() + .map(|(i, image_data)| { + download_chapter_images(client, image_data, selected_chapters[i]) + }), + ) + .await; for (i, chapter) in chapters.iter().enumerate() { match chapter { Ok(chapter) => { for (j, image) in chapter.iter().enumerate() { - image - .save(format!("images/chapter{:0>3}_image_{:0>3}.png", i, j)) - .unwrap(); + let chapter_n = selected_chapters[i].attributes.chapter.unwrap(); + let path = if let Some(v) = selected_chapters[i].attributes.volume { + format!( + "images/{}/volume_{:0>3}/chapter{:0>3}_image_{:0>3}.png", + results.data[choice as usize].attributes.title.en, v, chapter_n, j + ) + } else { + format!( + "images/{}/chapter{:0>3}_image_{:0>3}.png", + results.data[choice as usize].attributes.title.en, chapter_n, j + ) + }; + let path = std::path::Path::new(&path); + if selected_chapters[i].attributes.volume.is_some() + && !&path.parent().unwrap().exists() + { + if !path.parent().unwrap().parent().unwrap().exists() { + std::fs::create_dir(path.parent().unwrap().parent().unwrap()).unwrap(); + } + std::fs::create_dir(path.parent().unwrap()).unwrap(); + } + image.save(path).unwrap(); } } Err(e) => { - panic!("{}", e); + panic!("{e}"); } } } + + let title = &results.data[choice as usize].attributes.title; + if create_volumes { + let mut volumes = Vec::new(); + selected_chapters + .iter() + .filter_map(|m| m.attributes.volume) + .for_each(|v| { + if !volumes.contains(&v) { + volumes.push(v); + } + }); + for volume in volumes { + let path = format!("images/{}/volume_{:0>3}", title.en, volume); + let file_name = format!("{} - Volume {:0>3}.cbz", title.en, volume); + std::process::Command::new("/usr/bin/zip") + .args(["-j", "-r", &file_name, &path]) + .spawn() + .unwrap() + .wait() + .unwrap(); + } + } } async fn download_chapter_images( @@ -208,7 +308,7 @@ async fn download_chapter_images( } async fn get_chapters(client: &Client, id: &Id) -> Result, reqwest_middleware::Error> { - let limit = 100; + let limit = 50; let limit = limit.to_string(); let params = [("limit", limit.as_str()), ("translatedLanguage[]", "en")]; let url = format!("{BASE}/manga/{id}/feed"); diff --git a/src/response_deserializer.rs b/src/response_deserializer.rs index a6ad7d2..0b06159 100644 --- a/src/response_deserializer.rs +++ b/src/response_deserializer.rs @@ -5,7 +5,7 @@ use serde::Deserialize; use std::fmt::Display; #[derive(Debug, Clone)] -pub struct Id(String); +pub struct Id(pub String); #[derive(Debug)] pub enum ResponseResult { @@ -41,6 +41,7 @@ pub enum Language { Spanish, Esperanto, Polish, + Croatian, } #[derive(Debug)] @@ -106,6 +107,23 @@ struct ChapterImagesContent { chapter: ChapterImageDataContent, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiError { + pub id: String, + pub status: u32, + pub title: String, + pub detail: String, + pub context: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChapterImagesContentError { + pub result: String, + pub errors: Vec, +} + #[derive(Debug, Deserialize)] struct ChapterImageDataContent { hash: String, @@ -379,6 +397,11 @@ enum ResponseConversionError { ContentType(String), } +#[derive(Debug)] +enum ChapterImageError { + Result(String), +} + impl TryInto for &str { type Error = (); @@ -434,6 +457,7 @@ impl TryInto for &str { "el" => Language::Greek, "zh" => Language::SimplifiedChinese, "tl" => Language::Tagalog, + "hr" => Language::Croatian, _ => return Err(()), }) } @@ -904,11 +928,6 @@ fn convert_chapter_attributes( }) } -#[derive(Debug)] -enum ChapterImageError { - Result(String), -} - fn convert_chapter_images(data: ChapterImagesContent) -> Result { Ok(ChapterImages { result: (data.result.as_str()) @@ -922,14 +941,19 @@ fn convert_chapter_images(data: ChapterImagesContent) -> Result ChapterImages { +pub fn deserialize_chapter_images(json: &str) -> Result { let chapter_images: ChapterImagesContent = match serde_json::from_str(json) { Ok(v) => v, Err(e) => { - std::fs::write("out.json", json).unwrap(); - eprintln!("ERROR: {:#?}", e); - std::process::exit(1); + match serde_json::from_str::(json) { + Ok(v) => return Err(v), + Err(e) => { + // If you can't parse the error then there is no point in continuing. + eprintln!("ERROR: {:#?}", e); + std::process::exit(1); + } + } } }; - convert_chapter_images(chapter_images).unwrap() + Ok(convert_chapter_images(chapter_images).unwrap()) } diff --git a/src/select.rs b/src/select.rs index 786663f..7a24e6f 100644 --- a/src/select.rs +++ b/src/select.rs @@ -22,6 +22,7 @@ enum Action { MoveDown, MoveUp, Select, + Exit, } #[derive(Default)] @@ -59,7 +60,7 @@ fn get_input() -> Option { KeyCode::Char('j') => Action::MoveDown, KeyCode::Char('k') => Action::MoveUp, KeyCode::Enter => Action::Select, - KeyCode::Char('q') => exit(), + KeyCode::Char('q') => Action::Exit, _ => return None, }), Err(e) => { @@ -83,9 +84,6 @@ fn exit() -> ! { std::process::exit(1); } -// pub fn multi_select(entries: &[Entry]) -> Result, std::io::Error> { -// } - pub fn select(entries: &[Entry]) -> Result { let (width, height) = terminal::size()?; let mut stdout = io::stdout(); @@ -125,6 +123,14 @@ pub fn select(entries: &[Entry]) -> Result { .flush()?; return Ok(selected); } + Action::Exit => { + stdout + .queue(MoveTo(0, 0))? + .queue(Clear(ClearType::All))? + .queue(Show)? + .flush()?; + exit(); + } } stdout.queue(MoveTo(0, selected))?; } diff --git a/src/util.rs b/src/util.rs index dc88e82..ad4d136 100644 --- a/src/util.rs +++ b/src/util.rs @@ -17,6 +17,14 @@ impl Selection { } } +#[derive(Default)] +pub enum CoverSize { + #[default] + Full, + W256, + W512, +} + pub enum SelectionType { Volume(VolumeSelection), Chapter(ChapterSelection), @@ -38,8 +46,16 @@ pub enum ChapterSelection { pub struct Config { pub bonus: Option, - pub search: Option, pub result_limit: u32, + pub cover_size: CoverSize, + pub selection_type: Option, + pub selection_range: Option, + pub search: Option, +} + +pub enum ConfigSearch { + Query(String), + Id(crate::Id), } fn filter_bonus(bonus: bool, volume: Option, chapter: Option) -> bool { @@ -136,29 +152,28 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) - } } -pub fn choose_volumes() -> VolumeSelection { - let input = get_input("Choose volumes: "); +pub fn choose_volumes(input: &str) -> Option { if let Some(x) = input.find("..") { match (input[0..x].parse(), input[x + 2..].parse::()) { (Ok(a), Ok(b)) => { if a > b { eprintln!("Invalid range: a > b"); - choose_volumes() + None } else { - VolumeSelection::Range(a, b) + Some(VolumeSelection::Range(a, b)) } } _ => { eprintln!("Invalid range"); - choose_volumes() + None } } } else if let Some(x) = input.find(":") { match (input[0..x].parse(), input[x + 2..].parse::()) { - (Ok(a), Ok(b)) => VolumeSelection::Range(a, b), + (Ok(a), Ok(b)) => Some(VolumeSelection::Range(a, b)), _ => { eprintln!("Invalid range"); - choose_volumes() + None } } } else if input.contains(",") { @@ -167,62 +182,66 @@ pub fn choose_volumes() -> VolumeSelection { .map(|m| m.parse::()) .collect::, _>>() { - Ok(v) => VolumeSelection::List(v), + Ok(v) => Some(VolumeSelection::List(v)), Err(e) => { eprintln!("Invalid number in list: {:#?}", e); - choose_volumes() + None } } - } else if input.as_str() == "all" { - VolumeSelection::All + } else if input == "all" { + Some(VolumeSelection::All) } else { if let Ok(n) = input.parse() { - return VolumeSelection::Single(n); + return Some(VolumeSelection::Single(n)); } eprintln!("Invalid input"); - choose_volumes() + None } } -pub fn choose_chapters() -> ChapterSelection { - let input = get_input("Choose chapters: "); +pub fn choose_chapters(input: &str) -> Option { if let Some(x) = input.find("..") { match (input[0..x].parse(), input[x + 2..].parse()) { // Inclusive range (Ok(a), Ok(b)) => { if a > b { eprintln!("Invalid range: a > b"); - choose_chapters() + None } else { - ChapterSelection::Range(a, b) + Some(ChapterSelection::Range(a, b)) } } _ => { eprintln!("Invalid range"); - choose_chapters() + None } } } else if input.contains(",") { + let mut invalid = false; let list = input .split(",") .map(|m| match m.parse() { Ok(v) => v, Err(e) => { eprintln!("Invalid input: {:#?}", e); - choose_chapters(); + invalid = true; 0. } }) .collect(); - ChapterSelection::List(list) - } else if input.as_str() == "all" { - ChapterSelection::All + if invalid { + None + } else { + Some(ChapterSelection::List(list)) + } + } else if input == "all" { + Some(ChapterSelection::All) } else { if let Ok(n) = input.parse() { - return ChapterSelection::Single(n); + return Some(ChapterSelection::Single(n)); } eprintln!("Invalid input"); - choose_chapters() + None } } @@ -239,7 +258,7 @@ pub fn get_input(msg: &str) -> String { pub fn convert_to_sixel(data: &[u8]) -> String { let image = image::load_from_memory(data).unwrap(); - let mut pixels = Vec::new(); + let mut pixels = Vec::with_capacity(image.height() as usize * image.width() as usize * 3); match &image { DynamicImage::ImageRgb8(image) => image.pixels().for_each(|m| { pixels.push(m.0[0]); @@ -262,12 +281,6 @@ pub fn convert_to_sixel(data: &[u8]) -> String { DynamicImage::ImageRgba16(_) => println!("Found rgba16 image"), _ => panic!(), } - // let mut pixels = vec![]; - // a.pixels().for_each(|m| { - // pixels.push(m.0[0]); - // pixels.push(m.0[1]); - // pixels.push(m.0[2]); - // }); icy_sixel::sixel_string( &pixels, image.width() as i32, @@ -281,28 +294,59 @@ pub fn convert_to_sixel(data: &[u8]) -> String { .unwrap() } -// pub fn convert_to_/ -// +pub enum ConfigSelectionType { + Volume, + Chapter, +} impl Config { fn new() -> Self { Self { bonus: None, search: None, + cover_size: CoverSize::default(), result_limit: 5, + selection_type: None, + selection_range: None, } } } +use crate::Id; + pub fn args() -> Config { let mut args = std::env::args().skip(1); let mut config = Config::new(); while args.len() != 0 { match args.next().unwrap().as_ref() { + "-t" | "--selection-type" => { + config.selection_type = match args.next() { + Some(selection_type) => Some(match selection_type.as_str() { + "volume" => ConfigSelectionType::Volume, + "chapter" => ConfigSelectionType::Chapter, + _ => { + eprintln!("Invalid value for selection type, type: SelectionType"); + std::process::exit(1); + } + }), + None => { + eprintln!("Missing value for selection type, type: SelectionType"); + std::process::exit(1); + } + }; + } + "-i" | "--id" => { + if let Some(id) = args.next() { + config.search = Some(ConfigSearch::Id(Id(id))); + } else { + eprintln!("Missing value for id"); + std::process::exit(1); + } + } "-s" | "--search" => { if let Some(query) = args.next() { - config.search = Some(query); + config.search = Some(ConfigSearch::Query(query)); } else { eprintln!("Missing query for search"); std::process::exit(1); @@ -339,7 +383,27 @@ pub fn args() -> Config { } }; } - _ => (), + "-c" | "--cover-size" => { + config.cover_size = match args.next() { + Some(s) => match s.as_str() { + "full" => CoverSize::Full, + "256" => CoverSize::W256, + "512" => CoverSize::W512, + s => { + eprintln!("Invalid value for cover size, valid values: [\"256\", \"512\", \"full\"], found: {s}"); + std::process::exit(1); + } + }, + None => { + eprintln!("Missing value for cover size, valid values: [\"256\", \"512\", \"full\"]"); + std::process::exit(1); + } + }; + } + s => { + eprintln!("Found invalid argument: {s}"); + std::process::exit(1); + } } } config