From 1a29600d88b46dd1427d2960af85c656799b9306 Mon Sep 17 00:00:00 2001 From: Vegard Matthey Date: Mon, 30 Jun 2025 22:14:49 +0200 Subject: [PATCH] use builder pattern for getting manga --- Cargo.toml | 3 +- src/client.rs | 287 ++++++++++++++++++++ src/main.rs | 503 ++++++----------------------------- src/response_deserializer.rs | 258 +++++++++++++++++- src/select.rs | 59 ++-- src/test.rs | 4 +- src/util.rs | 388 +++++++++++++++++++++------ 7 files changed, 970 insertions(+), 532 deletions(-) create mode 100644 src/client.rs diff --git a/Cargo.toml b/Cargo.toml index 86c5fad..0e3f196 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,11 @@ crossterm = { version = "0.29", default-features = false, features = ["events"] futures = { version = "0.3.30", default-features = false } icy_sixel = { version = "0.1.2", default-features = false } image = { version = "0.25.2", default-features = false, features = ["jpeg", "png"] } -reqwest = { version = "0.12.5", default-features = false } +reqwest = { version = "0.12.5", default-features = false, features = ["default-tls"] } reqwest-middleware = { version = "0.4", default-features = false } reqwest-retry = { version = "0.7", default-features = false } serde = { version = "1.0.204", default-features = false, features = ["derive"] } serde_json = { version = "1.0.121", default-features = false, features = ["std"] } +thiserror = { version = "2.0.12", default-features = false } tokio = { version = "1.39.2", default-features = false, features = ["macros", "rt-multi-thread"] } zip = { version = "4.0.0", default-features = false, features = ["deflate"] } diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..7df4e5b --- /dev/null +++ b/src/client.rs @@ -0,0 +1,287 @@ +use crate::response_deserializer::{Chapter, Id, Language}; +use crate::util::{ConfigSearch, ConfigSelectionType, CoverSize, SelectionRange}; +use crate::BASE; +use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; +use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; + +pub struct MangaClientBuilder { + client: ClientWithMiddleware, + search_filter: Option, + bonus: Option, + result_limit: Option, + cover_size: Option, + selection_type: Option, + selection_range: Option, + search: Option, + cover: Option, + language: Option, +} + +#[derive(Debug)] +pub struct MangaClient { + pub client: ClientWithMiddleware, + pub search_filter: SearchFilter, + pub result_limit: u32, + pub cover_size: CoverSize, + pub search: ConfigSearch, + pub cover: bool, + pub selection: crate::util::Selection, + pub language: Language, +} + +#[derive(Default, Clone, Debug)] +pub struct SearchFilter { + pub content_rating: ContentRatingFilter, + pub publication_demographic: PublicationDemographicFilter, +} + +#[derive(Clone, Debug)] +pub struct PublicationDemographicFilter { + pub shounen: bool, + pub shoujo: bool, + pub seinen: bool, + pub josei: bool, +} + +#[derive(Clone, Debug)] +pub struct ContentRatingFilter { + pub safe: bool, + pub suggestive: bool, + pub erotica: bool, + pub pornographic: bool, +} + +impl From for Vec<(&str, &str)> { + fn from(s: SearchFilter) -> Self { + let mut v = Vec::new(); + + let p = s.publication_demographic; + if p.shounen { + v.push(("publicationDemographic[]", "shounen")); + } + if p.shoujo { + v.push(("publicationDemographic[]", "shoujo")); + } + if p.seinen { + v.push(("publicationDemographic[]", "seinen")); + } + if p.josei { + v.push(("publicationDemographic[]", "josei")); + } + + let c = s.content_rating; + if c.safe { + v.push(("contentRating[]", "safe")); + } + if c.suggestive { + v.push(("contentRating[]", "suggestive")); + } + if c.erotica { + v.push(("contentRating[]", "erotica")); + } + if c.pornographic { + v.push(("contentRating[]", "pornographic")); + } + + v + } +} + +impl Default for ContentRatingFilter { + fn default() -> Self { + Self { + safe: true, + suggestive: true, + erotica: true, + pornographic: false, + } + } +} + +impl Default for PublicationDemographicFilter { + fn default() -> Self { + Self { + shounen: true, + shoujo: true, + seinen: true, + josei: true, + } + } +} + +impl MangaClientBuilder { + pub fn new() -> MangaClientBuilder { + MangaClientBuilder { + client: ClientBuilder::new( + reqwest::Client::builder() + .user_agent("manga-cli/version-0.1") + .build() + .unwrap(), + ) + .with(RetryTransientMiddleware::new_with_policy( + ExponentialBackoff::builder().build_with_max_retries(3), + )) + .build(), + search_filter: None, + bonus: None, + result_limit: None, + cover_size: None, + selection_type: None, + selection_range: None, + search: None, + cover: None, + language: None, + } + } + pub fn search_filter(mut self, filter: SearchFilter) -> MangaClientBuilder { + self.search_filter = Some(filter); + self + } + pub fn bonus(mut self, bonus: bool) -> MangaClientBuilder { + self.bonus = Some(bonus); + self + } + pub fn result_limit(mut self, result_limit: u32) -> MangaClientBuilder { + self.result_limit = Some(result_limit); + self + } + pub fn cover_size(mut self, cover_size: CoverSize) -> MangaClientBuilder { + self.cover_size = Some(cover_size); + self + } + pub fn selection_type(mut self, selection_type: ConfigSelectionType) -> MangaClientBuilder { + self.selection_type = Some(selection_type); + self + } + pub fn selection_range(mut self, selection_range: SelectionRange) -> MangaClientBuilder { + self.selection_range = Some(selection_range); + self + } + pub fn search(mut self, search: ConfigSearch) -> MangaClientBuilder { + self.search = Some(search); + self + } + pub fn cover(mut self, cover: bool) -> MangaClientBuilder { + self.cover = Some(cover); + self + } + pub fn language(mut self, language: Language) -> MangaClientBuilder { + self.language = Some(language); + self + } + + pub fn build(self) -> MangaClient { + MangaClient { + client: self.client, + search_filter: self.search_filter.unwrap_or_default(), + result_limit: self.result_limit.unwrap_or(5), + cover_size: self.cover_size.unwrap_or_default(), + search: self + .search + .unwrap_or(crate::util::ConfigSearch::Query(String::new())), + cover: self.cover.unwrap_or(true), + selection: crate::util::Selection::new( + match self.selection_type { + Some(a) => match a { + ConfigSelectionType::Chapter => crate::util::SelectionType::Chapter( + self.selection_range.unwrap(), + ), + ConfigSelectionType::Volume => { + crate::util::SelectionType::Volume(self.selection_range.unwrap()) + } + }, + None => panic!(), + }, + self.bonus.unwrap_or(true), + ), + language: self.language.unwrap_or_default(), + } + } +} + +use crate::response_deserializer::Manga; + +impl MangaClient { + pub async fn get_manga(&self) -> Vec { + match &self.search { + ConfigSearch::Query(query) => { + let limit = self.result_limit.to_string(); + let mut params = vec![("title", query.as_str()), ("limit", limit.as_str())]; + if self.cover { + params.push(("includes[]", "cover_art")); + } + let search_filter: Vec<(&str, &str)> = self.search_filter.clone().into(); + let json = self + .client + .get(format!("{BASE}/manga")) + .query(¶ms) + .query(&search_filter) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + crate::response_deserializer::deserializer(&json) + .unwrap() + .data + } + ConfigSearch::Id(id) => { + let json = self + .client + .get(format!("{BASE}/manga/{id}")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + vec![ + crate::response_deserializer::deserialize_id_query(&json) + .unwrap() + .data, + ] + } + } + } + + pub async fn get_chapters(&self, id: &Id) -> Result, reqwest_middleware::Error> { + let limit = 50; + let limit = limit.to_string(); + let params = [ + ("limit", limit.as_str()), + ("translatedLanguage[]", &self.language.to_string()), + ]; + let url = format!("{BASE}/manga/{id}/feed"); + let json = self + .client + .get(url) + .query(¶ms) + .send() + .await? + .text() + .await?; + let mut result = crate::response_deserializer::deserialize_chapter_feed(&json).unwrap(); + + let mut total_chapters_received = result.limit; + while total_chapters_received < result.total { + let offset = total_chapters_received.to_string(); + let params = [params[0], params[1], ("offset", offset.as_str())]; + let url = format!("{BASE}/manga/{id}/feed"); + let json = self + .client + .get(url) + .query(¶ms) + .send() + .await? + .text() + .await?; + let mut new_result = + crate::response_deserializer::deserialize_chapter_feed(&json).unwrap(); + result.data.append(&mut new_result.data); + total_chapters_received += result.limit; + } + assert_eq!(result.data.len(), result.total as usize); + Ok(result.data) + } +} diff --git a/src/main.rs b/src/main.rs index bc9c344..175471e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,160 +1,59 @@ -use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; -use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; -use response_deserializer::{ChapterImages, SearchResult}; -use error::{ChapterImageError, ChapterImagesError}; -use std::fs::File; -use std::io::Write; -use std::path::Path; +use client::{MangaClient, MangaClientBuilder, SearchFilter}; +use response_deserializer::{Chapter, Id, Language, Manga}; +use select::Entry; +use std::future::Future; use std::pin::Pin; -use zip::write::{SimpleFileOptions, ZipWriter}; -use zip::CompressionMethod; +use util::SelectionType; +mod client; +mod error; mod response_deserializer; mod select; mod test; mod util; -mod error; -mod client; -use response_deserializer::{Chapter, Id}; -use select::Entry; - -const BASE: &str = "https://api.mangadex.org"; -type Client = ClientWithMiddleware; +const BASE: &str = "https://api.mangadex.dev"; #[tokio::main] async fn main() { let config = util::args(); - let retry_policy = ExponentialBackoff::builder().build_with_max_retries(10); - let client = ClientBuilder::new( - reqwest::Client::builder() - .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 manga_client = + MangaClientBuilder::new() + .bonus(config.bonus.unwrap_or_else(util::ask_bonus)) + .cover(config.cover.unwrap_or_else(util::ask_cover)) + .result_limit(config.result_limit.unwrap_or(5)) + .cover_size(util::CoverSize::W512) + .selection_type( + config + .selection_type + .unwrap_or_else(util::ask_selection_type), + ) + .selection_range( + config + .selection_range + .unwrap_or_else(util::ask_selection_range), + ) + .search(config.search.unwrap_or_else(|| { + util::ConfigSearch::Query(util::get_input("Enter search query: ")) + })) + .search_filter(SearchFilter::default()) + .language(Language::default()) + .build(); + let search_result = manga_client.get_manga().await; let mut choice = 0; - let results = if let Some(util::ConfigSearch::Id(set_id)) = config.search { - let id_query_result: response_deserializer::IdQueryResult = - id_query_get_info(client, &set_id).await; - vec![id_query_result.data] - } else { - let results = if let Some(ref query) = config.search { - match query { - util::ConfigSearch::Query(query) => search(client, query, &filters, limit).await, - _ => unreachable!(), - } - } else { - let input = util::get_input("Enter search query: "); - search(client, &input, &filters, limit).await - }; - choice = select_manga_from_search(client, &config, &results).await; - results.data - }; - let manga = &results[choice as usize]; - - let bonus = if let Some(bonus) = config.bonus { - bonus - } else { - loop { - match util::get_input("Read bonus chapters? [y/n] : ").as_str() { - "y" | "yes" => break true, - "n" | "no" => break false, - _ => continue, - } - } - }; - let selection_type = if let Some(selection_type) = config.selection_type { - if let Some(selection_range) = config.selection_range { - match selection_type { - util::ConfigSelectionType::Volume => { - if let Some(selection) = util::choose_volumes(selection_range.as_str()) { - util::SelectionType::Volume(selection) - } else { - std::process::exit(1) - } - } - util::ConfigSelectionType::Chapter => { - if let Some(selection) = util::choose_chapters(selection_range.as_str()) { - util::SelectionType::Chapter(selection) - } else { - std::process::exit(1) - } - } - } - } else { - 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" => { - if let Some(ref selection_range) = config.selection_range { - if let Some(selection) = util::choose_volumes(selection_range.as_str()) { - break util::SelectionType::Volume(selection); - } else { - std::process::exit(1); - } - } else { - 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" => { - if let Some(ref selection_range) = config.selection_range { - if let Some(selection) = util::choose_chapters(selection_range.as_str()) { - break util::SelectionType::Chapter(selection); - } else { - std::process::exit(1); - } - } else { - loop { - 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, &manga.id).await { + if search_result.len() > 1 { + choice = select_manga_from_search(&manga_client, &search_result).await; + } + let manga = &search_result[choice as usize]; + let mut chapters = match manga_client.get_chapters(&manga.id).await { Ok(v) => v, Err(e) => { eprintln!("ERROR: {e:#?}"); std::process::exit(1); } }; + println!("Downloading {} chapters", chapters.len()); chapters.sort_by(|a, b| { a.attributes .chapter @@ -162,263 +61,35 @@ async fn main() { .partial_cmp(&b.attributes.chapter.unwrap_or(-1.)) .unwrap() }); - dbg!("got chapters"); + let create_volumes = matches!( + manga_client.selection.selection_type, + SelectionType::Volume(_) + ); + let selected_chapters = util::get_chapters_from_selection(manga_client.selection, &chapters); - 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 title = &manga.attributes.title.en; - let title = &manga.attributes.title; - for (i, chapter) in selected_chapters.iter().enumerate() { - let chapter_image_data = loop { - let json = client - .get(format!("{BASE}/at-home/server/{}", chapter.id)) - .send() - .await - .unwrap() - .text() - .await - .unwrap(); - let result = response_deserializer::deserialize_chapter_images(&json); - match result { - Ok(v) => break v, - Err(e) => match e { - ChapterImagesError::Image(i) => match i { - ChapterImageError::Result(s) => { - eprintln!("chapter image error: {s}"); - std::process::exit(1); - } - }, - ChapterImagesError::Content(e) => { - if e.result != "error" { - panic!("brotha, api gone wrong (wild)"); - } - for error in e.errors { - if error.status == 429 { - eprintln!("you sent too many requests"); - } - std::thread::sleep(std::time::Duration::from_millis(20000)); - } - } - }, - } - }; - println!( - "\x1b[1A\x1b[2Kdownloaded chapter json image data: [{i}/{}]", - selected_chapters.len() - ); - let chapter = - download_chapter_images(client, &chapter_image_data, selected_chapters[i]).await; - match chapter { - Ok(chapter) => { - for (j, image) in chapter.iter().enumerate() { - let chapter_n = selected_chapters[i].attributes.chapter; - let chapter_text = if let Some(n) = chapter_n { - n.to_string() - } else { - String::from("_none") - }; - let chapter_path = format!( - "images/{}/chapter_{:0>3}_image_{:0>3}.png", - title.en, chapter_text, j - ); - let path = if let Some(v) = selected_chapters[i].attributes.volume { - if create_volumes { - format!( - "images/{}/volume_{:0>3}/chapter_{:0>3}_image_{:0>3}.png", - title.en, v, chapter_text, j - ) - } else { - chapter_path - } - } else { - chapter_path - }; - let path = std::path::Path::new(&path); - if create_volumes - && 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}"); - } - } - } + util::download_the_stuff( + &manga_client.client, + &selected_chapters, + title, + create_volumes, + ) + .await; - 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 image_paths = format!("images/{}/volume_{:0>3}", title.en, volume); - let image_paths = Path::new(&image_paths); - - let zip_file_path = format!("{} - Volume {:0>3}.cbz", title.en, volume); - let zip_file_path = Path::new(&zip_file_path); - let zip_file = File::create(zip_file_path).unwrap(); - - let mut zip = ZipWriter::new(zip_file); - let options = - SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); - - for entry in std::fs::read_dir(image_paths).unwrap() { - let entry = entry.unwrap(); - zip.start_file(entry.file_name().to_str().unwrap(), options) - .unwrap(); - - let buffer = std::fs::read(entry.path()).unwrap(); - zip.write_all(&buffer).unwrap(); - } - - zip.finish().unwrap(); - } - } else { - for chapter in selected_chapters { - let chapter = chapter.attributes.chapter.unwrap(); - let image_paths = format!("images/{}", title.en); - let image_paths = Path::new(&image_paths); - - let zip_file_path = format!("{} - Chapter {:0>3}.cbz", title.en, chapter); - let zip_file_path = Path::new(&zip_file_path); - let zip_file = File::create(zip_file_path).unwrap(); - - let mut zip = ZipWriter::new(zip_file); - let options = - SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); - - for entry in std::fs::read_dir(image_paths).unwrap() { - let entry = entry.unwrap(); - if entry.path().is_dir() { - continue; - } - zip.start_file(entry.file_name().to_str().unwrap(), options) - .unwrap(); - - let buffer = std::fs::read(entry.path()).unwrap(); - zip.write_all(&buffer).unwrap(); - } - - zip.finish().unwrap(); - } - } + util::create_volumes_or_chapters(&selected_chapters, create_volumes, title); } -async fn download_chapter_images( - client: &Client, - image_data: &ChapterImages, - chapter: &Chapter, -) -> Result, reqwest_middleware::Error> { - let mut data_futures = vec![]; - for (i, file_name) in image_data.chapter.data.iter().enumerate() { - let base_url: &str = image_data.base_url.as_str(); - let hash: &str = image_data.chapter.hash.as_str(); - let future = async move { - let data = client - .clone() - .get(format!("{base_url}/data/{hash}/{file_name}")) - .send() - .await - .unwrap() - .bytes() - .await - .unwrap(); - println!( - "\x1b[1A\x1b[2KDownloaded volume: {:?}, chapter: {:?}, title: {}, [{}/{}]", - chapter.attributes.volume, - chapter.attributes.chapter, - chapter.attributes.title, - i, - chapter.attributes.pages - ); - data - }; - data_futures.push(future); - } - Ok(futures::future::join_all(data_futures) - .await - .iter() - .map(|m| image::load_from_memory(m).unwrap()) - .collect()) -} - -async fn get_chapters(client: &Client, id: &Id) -> Result, reqwest_middleware::Error> { - let limit = 50; - let limit = limit.to_string(); - let params = [("limit", limit.as_str()), ("translatedLanguage[]", "en")]; - let url = format!("{BASE}/manga/{id}/feed"); - let json = client.get(url).query(¶ms).send().await?.text().await?; - let mut result = response_deserializer::deserialize_chapter_feed(&json).unwrap(); - - let mut total_chapters_received = result.limit; - while total_chapters_received < result.total { - let offset = total_chapters_received.to_string(); - let params = [ - ("limit", limit.as_str()), - ("translatedLanguage[]", "en"), - ("offset", offset.as_str()), - ]; - let url = format!("{BASE}/manga/{id}/feed"); - let json = client.get(url).query(¶ms).send().await?.text().await?; - let mut new_result = response_deserializer::deserialize_chapter_feed(&json).unwrap(); - result.data.append(&mut new_result.data); - total_chapters_received += result.limit; - } - assert_eq!(result.data.len(), result.total as usize); - Ok(result.data) -} - -async fn search( - client: &Client, - query: &str, - filters: &[(&str, &str)], - limit: u32, -) -> SearchResult { - let params = [ - ("title", query), - ("limit", &limit.to_string()), - ("includes[]", "cover_art"), - ]; - let json = client - .get(format!("{BASE}/manga")) - .query(¶ms) - .query(filters) - .send() - .await - .unwrap() - .text() - .await - .unwrap(); - response_deserializer::deserializer(&json).unwrap() -} - -async fn select_manga_from_search( - client: &Client, - config: &util::Config, - results: &SearchResult, -) -> u16 { - let cover_ex = match config.cover_size { +async fn select_manga_from_search(client: &MangaClient, results: &[Manga]) -> u16 { + let cover_ex = match client.cover_size { util::CoverSize::Full => "", util::CoverSize::W256 => ".256.jpg", util::CoverSize::W512 => ".512.jpg", }; - use std::future::Future; let mut entry_futures: Vec>>> = Vec::new(); - for result in results.data.iter() { + assert!(!results.is_empty()); + for result in results.iter() { let mut entry = Entry::new(result.attributes.title.en.clone()); if let Some(year) = result.attributes.year { entry.add_info("year", year); @@ -439,32 +110,38 @@ async fn select_manga_from_search( if let Some(cover_data) = &result.relationships[2].attributes { // The lib used for converting to sixel is abysmally slow for larger images, this // should be in a future to allow for multithreaded work - if config.cover != Some(false) { - let future = async move { - let image_url = format!( - "https://uploads.mangadex.org/covers/{id}/{}{cover_ex}", - &cover_data.file_name - ); - let data = client - .get(&image_url) - .send() - .await - .unwrap() - .bytes() - .await - .unwrap(); - let result = util::convert_to_sixel(&data); + // Cover data should only be present if used + assert!(client.cover); + let future = async move { + let image_url = format!( + "https://uploads.mangadex.org/covers/{id}/{}{cover_ex}", + &cover_data.file_name + ); + let data = client + .client + .get(&image_url) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + let result = util::convert_to_sixel(&data); - entry.set_image(result); - entry - }; - entry_futures.push(Box::pin(future)); - } else { - entry_futures.push(Box::pin(async move { entry })); - } + entry.set_image(result); + entry + }; + entry_futures.push(Box::pin(future)); + } else { + entry_futures.push(Box::pin(async move { entry })); } } - let entries = futures::future::join_all(entry_futures).await; + let entries: Vec = futures::future::join_all(entry_futures).await; + assert!(!entries.is_empty()); + if entries.len() == 1 { + return 0; + } + let choice = match select::select(&entries) { Ok(v) => v, Err(e) => { @@ -474,15 +151,3 @@ async fn select_manga_from_search( }; choice } - -async fn id_query_get_info(client: &Client, id: &Id) -> response_deserializer::IdQueryResult { - let json = client - .get(format!("{BASE}/manga/{id}")) - .send() - .await - .unwrap() - .text() - .await - .unwrap(); - response_deserializer::deserialize_id_query(&json).unwrap() -} diff --git a/src/response_deserializer.rs b/src/response_deserializer.rs index b3a08db..4b45844 100644 --- a/src/response_deserializer.rs +++ b/src/response_deserializer.rs @@ -1,5 +1,5 @@ -#![allow(unused)] // TODO: Remove this +#![allow(unused)] use crate::error::*; use chrono::{DateTime, FixedOffset}; use serde::Deserialize; @@ -8,7 +8,7 @@ use std::fmt; #[derive(Debug, Clone)] pub struct Id(pub String); -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum ResponseResult { Ok, } @@ -24,7 +24,9 @@ pub enum ResponseResult { // and Norwegian Bokmål, many questions to ask, but few answers to get. Best part of this is that // the updated ISO pdf is behind a paywall. https://www.iso.org/standard/74575.html // If the code works 98% of the time and is 95% correct I guess that is "good enough". -#[derive(Debug)] +// There are a bunch of duplicates, I don't know if I wan't to fix it even. +#[allow(unused)] +#[derive(Debug, Default)] pub enum Language { Abkhazian, Afar, @@ -75,6 +77,7 @@ pub enum Language { Divehi, Dutch, Dzongkha, + #[default] English, Esperanto, Estonian, @@ -364,8 +367,8 @@ pub struct MangaAttributes { pub is_locked: bool, pub links: Option, pub original_language: Language, - pub last_volume: Option, - pub last_chapter: Option, + pub last_volume: Option, + pub last_chapter: Option, pub publication_demographic: Option, pub status: Status, pub year: Option, @@ -414,7 +417,7 @@ pub struct Chapter { #[derive(Debug)] pub struct ChapterAttributes { - pub volume: Option, + pub volume: Option, pub chapter: Option, pub title: String, pub translated_language: Language, @@ -500,7 +503,7 @@ struct ContentCoverAttributes { #[derive(Debug)] pub struct CoverAttributes { pub description: String, - pub volume: Option, + pub volume: Option, pub file_name: Id, pub locale: Language, pub created_at: DateTime, @@ -622,6 +625,7 @@ impl TryFrom<&str> for Language { type Error = (); fn try_from(s: &str) -> Result { + #[allow(unused)] Ok(match s { "ab" => Language::Abkhazian, "aa" => Language::Afar, @@ -957,14 +961,248 @@ impl TryFrom for SearchResult { } } +impl fmt::Display for Language { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + #[allow(unused)] + match self { + Language::Abkhazian => "ab".fmt(f), + Language::Afar => "aa".fmt(f), + Language::Afrikaans => "af".fmt(f), + Language::Akan => "ak".fmt(f), + Language::Albanian => "sq".fmt(f), + Language::Amharic => "am".fmt(f), + Language::Arabic => "ar".fmt(f), + Language::Aragonese => "an".fmt(f), + Language::Armenian => "hy".fmt(f), + Language::Assamese => "as".fmt(f), + Language::Avaric => "av".fmt(f), + Language::Avestan => "ae".fmt(f), + Language::Aymara => "ay".fmt(f), + Language::Azerbaijani => "az".fmt(f), + Language::Bambara => "bm".fmt(f), + Language::Bashkir => "ba".fmt(f), + Language::Basque => "eu".fmt(f), + Language::Belarusian => "be".fmt(f), + Language::Bengali => "bn".fmt(f), + Language::Bislama => "bi".fmt(f), + Language::NorwegianBokmål => "nb".fmt(f), + Language::Bosnian => "bs".fmt(f), + Language::Breton => "br".fmt(f), + Language::Bulgarian => "bg".fmt(f), + Language::Burmese => "my".fmt(f), + Language::Castilian => "es".fmt(f), + Language::Catalan => "ca".fmt(f), + Language::Central => "km".fmt(f), + Language::Chamorro => "ch".fmt(f), + Language::Chechen => "ce".fmt(f), + Language::Chewa => "ny".fmt(f), + Language::Chichewa => "ny".fmt(f), + Language::SimplifiedChinese => "zh".fmt(f), + Language::Chuang => "za".fmt(f), + Language::ChurchSlavic => "cu".fmt(f), + Language::ChurchSlavonic => "cu".fmt(f), + Language::Chuvash => "cv".fmt(f), + Language::Cornish => "kw".fmt(f), + Language::Corsican => "co".fmt(f), + Language::Cree => "cr".fmt(f), + Language::Croatian => "hr".fmt(f), + Language::Czech => "cs".fmt(f), + Language::Danish => "da".fmt(f), + Language::Dhivehi => "dv".fmt(f), + Language::Divehi => "dv".fmt(f), + Language::Dutch => "nl".fmt(f), + Language::Dzongkha => "dz".fmt(f), + Language::English => "en".fmt(f), + Language::Esperanto => "eo".fmt(f), + Language::Estonian => "et".fmt(f), + Language::Ewe => "ee".fmt(f), + Language::Faroese => "fo".fmt(f), + Language::Fijian => "fj".fmt(f), + Language::Finnish => "fi".fmt(f), + Language::Flemish => "nl".fmt(f), + Language::French => "fr".fmt(f), + Language::Fulah => "ff".fmt(f), + Language::Gaelic => "gd".fmt(f), + Language::Galician => "gl".fmt(f), + Language::Ganda => "lg".fmt(f), + Language::Georgian => "ka".fmt(f), + Language::German => "de".fmt(f), + Language::Gikuyu => "ki".fmt(f), + Language::Greek => "el".fmt(f), + Language::Greenlandic => "kl".fmt(f), + Language::Guarani => "gn".fmt(f), + Language::Gujarati => "gu".fmt(f), + Language::Haitian => "ht".fmt(f), + Language::Hausa => "ha".fmt(f), + Language::Hebrew => "he".fmt(f), + Language::Herero => "hz".fmt(f), + Language::Hindi => "hi".fmt(f), + Language::Hiri => "ho".fmt(f), + Language::Hungarian => "hu".fmt(f), + Language::Icelandic => "is".fmt(f), + Language::Ido => "io".fmt(f), + Language::Igbo => "ig".fmt(f), + Language::Indonesian => "id".fmt(f), + Language::Interlingua => "ia".fmt(f), + Language::Interlingue => "ie".fmt(f), + Language::Inuktitut => "iu".fmt(f), + Language::Inupiaq => "ik".fmt(f), + Language::Irish => "ga".fmt(f), + Language::Italian => "it".fmt(f), + Language::Japanese => "ja".fmt(f), + Language::Javanese => "jv".fmt(f), + Language::Kalaallisut => "kl".fmt(f), + Language::Kannada => "kn".fmt(f), + Language::Kanuri => "kr".fmt(f), + Language::Kashmiri => "ks".fmt(f), + Language::Kazakh => "kk".fmt(f), + Language::Kikuyu => "ki".fmt(f), + Language::Kinyarwanda => "rw".fmt(f), + Language::Kirghiz => "ky".fmt(f), + Language::Komi => "kv".fmt(f), + Language::Kongo => "kg".fmt(f), + Language::Korean => "ko".fmt(f), + Language::Kuanyama => "kj".fmt(f), + Language::Kurdish => "ku".fmt(f), + Language::Kwanyama => "kj".fmt(f), + Language::Kyrgyz => "ky".fmt(f), + Language::Lao => "lo".fmt(f), + Language::Latin => "la".fmt(f), + Language::Latvian => "lv".fmt(f), + Language::Letzeburgesch => "lb".fmt(f), + Language::Limburgan => "li".fmt(f), + Language::Limburger => "li".fmt(f), + Language::Limburgish => "li".fmt(f), + Language::Lingala => "ln".fmt(f), + Language::Lithuanian => "lt".fmt(f), + Language::LubaKatanga => "lu".fmt(f), + Language::Luxembourgish => "lb".fmt(f), + Language::Macedonian => "mk".fmt(f), + Language::Malagasy => "mg".fmt(f), + Language::Malay => "ms".fmt(f), + Language::Malayalam => "ml".fmt(f), + Language::Maldivian => "dv".fmt(f), + Language::Maltese => "mt".fmt(f), + Language::Manx => "gv".fmt(f), + Language::Maori => "mi".fmt(f), + Language::Marathi => "mr".fmt(f), + Language::Marshallese => "mh".fmt(f), + Language::MiMoldavian => "ro".fmt(f), + Language::Moldovan => "ro".fmt(f), + Language::Mongolian => "mn".fmt(f), + Language::NNauru => "na".fmt(f), + Language::Navaho => "nv".fmt(f), + Language::Navajo => "nv".fmt(f), + Language::NdebeleNorth => "nd".fmt(f), + Language::NdebeleSouth => "nr".fmt(f), + Language::Ndonga => "ng".fmt(f), + Language::Nepali => "ne".fmt(f), + Language::North => "nd".fmt(f), + Language::Northern => "se".fmt(f), + Language::Norwegian => "no".fmt(f), + Language::NorwegianBokmål => "nb".fmt(f), + Language::NorwegianNynorsk => "nn".fmt(f), + Language::Nuosu => "ii".fmt(f), + Language::Nyanja => "ny".fmt(f), + Language::NorwegianNynorsk => "nn".fmt(f), + Language::Occidental => "ie".fmt(f), + Language::Occitan => "oc".fmt(f), + Language::Ojibwa => "oj".fmt(f), + Language::OldBulgarian => "cu".fmt(f), + Language::OldChurchSlavonic => "cu".fmt(f), + Language::OldSlavonic => "cu".fmt(f), + Language::Oriya => "or".fmt(f), + Language::Oromo => "om".fmt(f), + Language::Ossetian => "os".fmt(f), + Language::Ossetic => "os".fmt(f), + Language::Pali => "pi".fmt(f), + Language::Panjabi => "pa".fmt(f), + Language::Pashto => "ps".fmt(f), + Language::Persian => "fa".fmt(f), + Language::Polish => "pl".fmt(f), + Language::Portuguese => "pt".fmt(f), + Language::ProvençPunjabi => "pa".fmt(f), + Language::Pushto => "ps".fmt(f), + Language::Quechua => "qu".fmt(f), + Language::Romanian => "ro".fmt(f), + Language::Romansh => "rm".fmt(f), + Language::Rundi => "rn".fmt(f), + Language::Russian => "ru".fmt(f), + Language::Samoan => "sm".fmt(f), + Language::Sango => "sg".fmt(f), + Language::Sanskrit => "sa".fmt(f), + Language::Sardinian => "sc".fmt(f), + Language::Scottish => "gd".fmt(f), + Language::Serbian => "sr".fmt(f), + Language::Shona => "sn".fmt(f), + Language::Sichuan => "ii".fmt(f), + Language::Sindhi => "sd".fmt(f), + Language::Sinhala => "si".fmt(f), + Language::Sinhalese => "si".fmt(f), + Language::Slovak => "sk".fmt(f), + Language::Slovenian => "sl".fmt(f), + Language::Somali => "so".fmt(f), + Language::Sotho => "st".fmt(f), + Language::Spanish => "es".fmt(f), + Language::Sundanese => "su".fmt(f), + Language::Swahili => "sw".fmt(f), + Language::Swati => "ss".fmt(f), + Language::Swedish => "sv".fmt(f), + Language::Tagalog => "tl".fmt(f), + Language::Tahitian => "ty".fmt(f), + Language::Tajik => "tg".fmt(f), + Language::Tamil => "ta".fmt(f), + Language::Tatar => "tt".fmt(f), + Language::Telugu => "te".fmt(f), + Language::Thai => "th".fmt(f), + Language::Tibetan => "bo".fmt(f), + Language::Tigrinya => "ti".fmt(f), + Language::Tonga => "to".fmt(f), + Language::Tsonga => "ts".fmt(f), + Language::Tswana => "tn".fmt(f), + Language::Turkish => "tr".fmt(f), + Language::Turkmen => "tk".fmt(f), + Language::Twi => "tw".fmt(f), + Language::Uighur => "ug".fmt(f), + Language::Ukrainian => "uk".fmt(f), + Language::Urdu => "ur".fmt(f), + Language::Uyghur => "ug".fmt(f), + Language::Uzbek => "uz".fmt(f), + Language::Valencian => "ca".fmt(f), + Language::Venda => "ve".fmt(f), + Language::Vietnamese => "vi".fmt(f), + Language::Volapük => "vo".fmt(f), + Language::Walloon => "wa".fmt(f), + Language::Welsh => "cy".fmt(f), + Language::Western => "fy".fmt(f), + Language::Wolof => "wo".fmt(f), + Language::Xhosa => "xh".fmt(f), + Language::Yiddish => "yi".fmt(f), + Language::Yoruba => "yo".fmt(f), + Language::Zhuang => "za".fmt(f), + Language::Zulu => "zu".fmt(f), + + Language::RomanizedChinese => "zh-ro".fmt(f), + Language::SimplifiedChinese => "zh".fmt(f), + Language::TraditionalChinese => "zh-hk".fmt(f), + Language::RomanizedKorean => "ko-ro".fmt(f), + Language::Portuguese => "pt".fmt(f), + Language::CastilianSpanish => "es".fmt(f), + Language::LatinAmericanSpanish => "es-la".fmt(f), + Language::RomanizedJapanese => "ja-ro".fmt(f), + Language::BrazilianPortugese => "pt-br".fmt(f), + } + } +} + impl fmt::Display for Id { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl fmt::Display for Status { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Ongoing => "ongoing".fmt(f), Self::Completed => "completed".fmt(f), @@ -1168,7 +1406,7 @@ pub fn deserialize_chapter_feed(json: &str) -> Result Result { diff --git a/src/select.rs b/src/select.rs index 6e2c552..f5b2e9d 100644 --- a/src/select.rs +++ b/src/select.rs @@ -6,14 +6,13 @@ use crossterm::{ terminal::{Clear, ClearType}, QueueableCommand, }; - -use std::fmt::Display; - -use std::time::Duration; use std::{ + fmt::Display, io, io::{Stdout, Write}, + time::Duration, }; +use thiserror::Error; const CURRENT: char = '>'; const NON_CURRENT: char = ' '; @@ -22,10 +21,10 @@ enum Action { MoveDown, MoveUp, Select, - Exit, + // Exit, } -#[derive(Default)] +#[derive(Default, Debug)] pub struct Entry { title: String, info: Vec<(String, String)>, @@ -60,7 +59,7 @@ fn get_input() -> Option { KeyCode::Char('j') => Action::MoveDown, KeyCode::Char('k') => Action::MoveUp, KeyCode::Enter => Action::Select, - KeyCode::Char('q') => Action::Exit, + // KeyCode::Char('q') => Action::Exit, _ => return None, }), Err(e) => { @@ -81,15 +80,39 @@ fn get_input() -> Option { fn exit() -> ! { io::stdout().queue(Show).unwrap().flush().unwrap(); terminal::disable_raw_mode().unwrap(); - std::process::exit(1); + std::process::exit(1) } -pub fn select(entries: &[Entry]) -> Result { +#[derive(Error, Debug)] +pub enum SelectionError { + #[error("found empty list of entries")] + NoEntries, + #[error("found singular entry in list")] + SingularEntry, + #[error("io error")] + IO(#[from] std::io::Error), +} + +pub fn select(entries: &[Entry]) -> Result { + if entries.is_empty() { + return Err(SelectionError::NoEntries); + } + if entries.len() == 1 { + return Err(SelectionError::SingularEntry); + } + actual_selection(entries).map_err(SelectionError::IO) +} + +fn actual_selection(entries: &[Entry]) -> Result { let (width, height) = terminal::size()?; let mut stdout = io::stdout(); stdout.queue(Hide)?; let mut selected: u16 = 0; - let offset = width / 3; + let offset = if entries.iter().any(|m| m.image.is_some()) { + width / 3 + } else { + width / 2 + }; let mut should_render = true; loop { if should_render { @@ -123,14 +146,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(); - } + // Action::Exit => { + // stdout + // .queue(MoveTo(0, 0))? + // .queue(Clear(ClearType::All))? + // .queue(Show)? + // .flush()?; + // exit(); + // } } stdout.queue(MoveTo(0, selected))?; } diff --git a/src/test.rs b/src/test.rs index ff24029..2e438ce 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,10 +1,10 @@ #[cfg(test)] mod tests { - use crate::response_deserializer; + use crate::{response_deserializer, response_deserializer::ResponseResult}; #[test] fn loops() { let search_result = std::fs::read_to_string("test_data/search_result.json").unwrap(); - response_deserializer::deserializer(&search_result); + assert_eq!(response_deserializer::deserializer(&search_result).unwrap().result , ResponseResult::Ok); } } diff --git a/src/util.rs b/src/util.rs index 6659f5b..4514f03 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,8 +1,14 @@ +use crate::response_deserializer::ChapterImages; use crate::Chapter; +use crate::BASE; use icy_sixel::{DiffusionMethod, MethodForLargest, MethodForRep, PixelFormat, Quality}; use image::DynamicImage; +use reqwest_middleware::ClientWithMiddleware; use std::{io, io::Write}; +type Client = ClientWithMiddleware; + +#[derive(Debug)] pub struct Selection { pub selection_type: SelectionType, pub bonus: bool, // Allows including or excluding bonus chapters and volumes @@ -17,7 +23,7 @@ impl Selection { } } -#[derive(Default)] +#[derive(Default, Debug)] pub enum CoverSize { #[default] Full, @@ -25,49 +31,48 @@ pub enum CoverSize { W512, } -pub enum SelectionType { - Volume(VolumeSelection), - Chapter(ChapterSelection), -} - -pub enum VolumeSelection { - All, - Range(u32, u32), - List(Vec), - Single(u32), -} - -pub enum ChapterSelection { +#[derive(Default, Debug)] +pub enum SelectionRange { Range(f32, f32), List(Vec), + #[default] All, Single(f32), } +#[derive(Debug)] +pub enum SelectionType { + Volume(SelectionRange), + Chapter(SelectionRange), +} + +#[derive(Debug)] pub struct Config { pub bonus: Option, - pub result_limit: u32, + pub result_limit: Option, pub cover_size: CoverSize, pub selection_type: Option, - pub selection_range: Option, + pub selection_range: Option, pub search: Option, pub cover: Option, } +#[derive(Debug)] pub enum ConfigSearch { Query(String), Id(crate::Id), } -fn filter_bonus(bonus: bool, volume: Option, chapter: Option) -> bool { +fn filter_bonus(bonus: bool, volume: Option, chapter: Option) -> bool { if bonus { return true; } if let Some(volume) = volume { - if volume > 0 { - if let Some(chapter) = chapter { - return chapter.round() == chapter; - } + if let Some(chapter) = chapter { + return chapter.round() == chapter + && volume.round() == volume + && chapter >= 1. + && volume >= 1.; } } false @@ -77,11 +82,11 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) - let bonus = selection.bonus; match selection.selection_type { SelectionType::Volume(v) => match v { - VolumeSelection::All => chapters + SelectionRange::All => chapters .iter() .filter(|m| filter_bonus(bonus, m.attributes.volume, m.attributes.chapter)) .collect(), - VolumeSelection::Single(a) => chapters + SelectionRange::Single(a) => chapters .iter() .filter(|m| { if let Some(n) = m.attributes.volume { @@ -91,7 +96,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) - false }) .collect(), - VolumeSelection::List(list) => chapters + SelectionRange::List(list) => chapters .iter() .filter(|m| { if let Some(v) = m.attributes.volume { @@ -101,7 +106,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) - false }) .collect(), - VolumeSelection::Range(a, b) => chapters + SelectionRange::Range(a, b) => chapters .iter() .filter(|m| { if let Some(v) = m.attributes.volume { @@ -114,11 +119,11 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) - .collect(), }, SelectionType::Chapter(c) => match c { - ChapterSelection::All => chapters + SelectionRange::All => chapters .iter() .filter(|m| filter_bonus(bonus, m.attributes.volume, m.attributes.chapter)) .collect(), - ChapterSelection::Single(a) => chapters + SelectionRange::Single(a) => chapters .iter() .filter(|m| { if let Some(c) = m.attributes.chapter { @@ -128,7 +133,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) - false }) .collect(), - ChapterSelection::List(list) => chapters + SelectionRange::List(list) => chapters .iter() .filter(|m| { if let Some(n) = m.attributes.chapter { @@ -138,7 +143,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) - false }) .collect(), - ChapterSelection::Range(a, b) => chapters + SelectionRange::Range(a, b) => chapters .iter() .filter(|m| { if let Some(c) = m.attributes.chapter { @@ -153,54 +158,16 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) - } } -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"); - None - } else { - Some(VolumeSelection::Range(a, b)) - } - } - _ => { - eprintln!("Invalid range"); - None - } +pub fn ask_selection_range() -> SelectionRange { + loop { + match parse_selection_range(&get_input("Choose chapters/volumes: ")) { + Some(v) => return v, + None => eprintln!("Invalid selection"), } - } else if let Some(x) = input.find(":") { - match (input[0..x].parse(), input[x + 2..].parse::()) { - (Ok(a), Ok(b)) => Some(VolumeSelection::Range(a, b)), - _ => { - eprintln!("Invalid range"); - None - } - } - } else if input.contains(",") { - match input - .split(",") - .map(|m| m.parse::()) - .collect::, _>>() - { - Ok(v) => Some(VolumeSelection::List(v)), - Err(e) => { - eprintln!("Invalid number in list: {e:#?}"); - None - } - } - } else if input == "all" { - Some(VolumeSelection::All) - } else { - if let Ok(n) = input.parse() { - return Some(VolumeSelection::Single(n)); - } - eprintln!("Invalid input"); - None } } -pub fn choose_chapters(input: &str) -> Option { +fn parse_selection_range(input: &str) -> Option { if let Some(x) = input.find("..") { match (input[0..x].parse(), input[x + 2..].parse()) { // Inclusive range @@ -209,7 +176,7 @@ pub fn choose_chapters(input: &str) -> Option { eprintln!("Invalid range: a > b"); None } else { - Some(ChapterSelection::Range(a, b)) + Some(SelectionRange::Range(a, b)) } } _ => { @@ -233,19 +200,31 @@ pub fn choose_chapters(input: &str) -> Option { if invalid { None } else { - Some(ChapterSelection::List(list)) + Some(SelectionRange::List(list)) } } else if input == "all" { - Some(ChapterSelection::All) + Some(SelectionRange::All) + } else if let Ok(n) = input.parse() { + Some(SelectionRange::Single(n)) } else { - if let Ok(n) = input.parse() { - return Some(ChapterSelection::Single(n)); - } eprintln!("Invalid input"); None } } +pub fn ask_selection_type() -> ConfigSelectionType { + loop { + match get_input("Select by volume or chapter? [v/c] : ").as_str() { + "v" | "volume" => break ConfigSelectionType::Volume, + "c" | "chapter" => break ConfigSelectionType::Chapter, + _ => { + eprintln!("Invalid response"); + continue; + } + } + } +} + pub fn get_input(msg: &str) -> String { print!("{msg}"); io::stdout().flush().expect("failed to flush stdout"); @@ -295,8 +274,10 @@ pub fn convert_to_sixel(data: &[u8]) -> String { .unwrap() } +#[derive(Default, Debug)] pub enum ConfigSelectionType { Volume, + #[default] Chapter, } @@ -306,7 +287,7 @@ impl Config { bonus: None, search: None, cover_size: CoverSize::default(), - result_limit: 5, + result_limit: None, selection_type: None, selection_range: None, cover: None, @@ -316,6 +297,230 @@ impl Config { use crate::Id; +use crate::error::{ChapterImageError, ChapterImagesError}; +pub async fn download_the_stuff( + client: &Client, + selected_chapters: &Vec<&Chapter>, + title: &str, + create_volumes: bool, +) { + for (i, chapter) in selected_chapters.iter().enumerate() { + let chapter_image_data = loop { + let json = client + .get(format!("{BASE}/at-home/server/{}", chapter.id)) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + let result = crate::response_deserializer::deserialize_chapter_images(&json); + match result { + Ok(v) => break v, + Err(e) => match e { + ChapterImagesError::Image(i) => match i { + ChapterImageError::Result(s) => { + eprintln!("chapter image error: {s}"); + std::process::exit(1); + } + }, + ChapterImagesError::Content(e) => { + if e.result != "error" { + panic!("brotha, api gone wrong (wild)"); + } + for error in e.errors { + if error.status == 429 { + eprintln!("you sent too many requests"); + } + std::thread::sleep(std::time::Duration::from_millis(20000)); + } + } + }, + } + }; + println!( + "\x1b[1A\x1b[2Kdownloaded chapter json image data: [{i}/{}]", + selected_chapters.len() + ); + let chapter = + download_chapter_images(client, &chapter_image_data, selected_chapters[i]).await; + match chapter { + Ok(chapter) => { + for (j, image) in chapter.iter().enumerate() { + let chapter_n = selected_chapters[i].attributes.chapter; + let chapter_text = if let Some(n) = chapter_n { + n.to_string() + } else { + String::from("_none") + }; + let chapter_path = format!( + "images/{}/chapter_{:0>3}_image_{:0>3}.png", + title, chapter_text, j + ); + let path = if let Some(v) = selected_chapters[i].attributes.volume { + if create_volumes { + format!( + "images/{}/volume_{:0>3}/chapter_{:0>3}_image_{:0>3}.png", + title, v, chapter_text, j + ) + } else { + chapter_path + } + } else { + chapter_path + }; + let path = std::path::Path::new(&path); + if selected_chapters[i].attributes.volume.is_some() + && !&path.parent().unwrap().exists() + { + if create_volumes && !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}"); + } + } + } +} + +pub fn create_volumes_or_chapters( + selected_chapters: &Vec<&Chapter>, + create_volumes: bool, + title: &str, +) { + use std::fs::File; + use std::io::Write; + use std::path::Path; + use zip::write::{SimpleFileOptions, ZipWriter}; + use zip::CompressionMethod; + + 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 image_paths = format!("images/{}/volume_{:0>3}", title, volume); + let image_paths = Path::new(&image_paths); + + let zip_file_path = format!("{} - Volume {:0>3}.cbz", title, volume); + let zip_file_path = Path::new(&zip_file_path); + let zip_file = File::create(zip_file_path).unwrap(); + + let mut zip = ZipWriter::new(zip_file); + let options = + SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + + for entry in std::fs::read_dir(image_paths).unwrap() { + let entry = entry.unwrap(); + zip.start_file(entry.file_name().to_str().unwrap(), options) + .unwrap(); + + let buffer = std::fs::read(entry.path()).unwrap(); + zip.write_all(&buffer).unwrap(); + } + + zip.finish().unwrap(); + } + } else { + for chapter in selected_chapters { + let chapter = chapter.attributes.chapter.unwrap(); + let image_paths = format!("images/{}", title); + let image_paths = Path::new(&image_paths); + + let zip_file_path = format!("{} - Chapter {:0>3}.cbz", title, chapter); + println!("creating cbz chapter at: {zip_file_path}"); + let zip_file_path = Path::new(&zip_file_path); + let zip_file = File::create(zip_file_path).unwrap(); + + let mut zip = ZipWriter::new(zip_file); + let options = + SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + + for entry in std::fs::read_dir(image_paths).unwrap() { + let entry = entry.unwrap(); + if entry.path().is_dir() { + continue; + } + zip.start_file(entry.file_name().to_str().unwrap(), options) + .unwrap(); + + let buffer = std::fs::read(entry.path()).unwrap(); + zip.write_all(&buffer).unwrap(); + } + + zip.finish().unwrap(); + } + } +} + +async fn download_chapter_images( + client: &Client, + image_data: &ChapterImages, + chapter: &Chapter, +) -> Result, reqwest_middleware::Error> { + let mut data_futures = vec![]; + for (i, file_name) in image_data.chapter.data.iter().enumerate() { + let base_url: &str = image_data.base_url.as_str(); + let hash: &str = image_data.chapter.hash.as_str(); + let future = async move { + let data = client + .clone() + .get(format!("{base_url}/data/{hash}/{file_name}")) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + println!( + "\x1b[1A\x1b[2KDownloaded volume: {:?}, chapter: {:?}, title: {}, [{}/{}]", + chapter.attributes.volume, + chapter.attributes.chapter, + chapter.attributes.title, + i, + chapter.attributes.pages + ); + data + }; + data_futures.push(future); + } + Ok(futures::future::join_all(data_futures) + .await + .iter() + .map(|m| image::load_from_memory(m).unwrap()) + .collect()) +} + +pub fn ask_cover() -> bool { + loop { + match get_input("Search with covers? [y/n] : ").as_str() { + "y" | "yes" => break true, + "n" | "no" => break false, + _ => continue, + } + } +} +pub fn ask_bonus() -> bool { + loop { + match get_input("Read bonus chapters? [y/n] : ").as_str() { + "y" | "yes" => break true, + "n" | "no" => break false, + _ => continue, + } + } +} + pub fn args() -> Config { let mut args = std::env::args().skip(1); @@ -328,7 +533,7 @@ pub fn args() -> Config { std::process::exit(1); } config.selection_range = match args.next() { - Some(selection_type) => Some(selection_type), + Some(selection_range) => parse_selection_range(&selection_range), None => { eprintln!("Missing value for selection range, type: String"); std::process::exit(1); @@ -402,7 +607,7 @@ pub fn args() -> Config { "-r" | "--result-limit" => { config.result_limit = match args.next() { Some(a) => match a.parse() { - Ok(v) => v, + Ok(v) => Some(v), Err(e) => { eprintln!("Failed to parse value for result-limit: {e:?}, type: u32"); std::process::exit(1); @@ -455,3 +660,22 @@ pub fn args() -> Config { } config } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filter() { + assert!(filter_bonus(false, Some(1.), Some(1.))); + assert!(filter_bonus(false, Some(2.), Some(1.))); + assert!(filter_bonus(false, Some(1.), Some(2.))); + assert!(filter_bonus(false, Some(2.), Some(2.))); + assert!(!filter_bonus(false, Some(1.), Some(0.))); + assert!(!filter_bonus(false, Some(0.), Some(1.))); + assert!(!filter_bonus(false, Some(1.), Some(-1.))); + assert!(!filter_bonus(false, Some(-1.), Some(1.))); + assert!(!filter_bonus(false, Some(1.5), Some(1.))); + assert!(!filter_bonus(false, Some(1.), Some(1.5))); + } +}