use serde deserializer functions to remove duplicated structs, remove TryFrom impls, replace Language enum with wrapper around isolang Language type

This commit is contained in:
2025-07-26 17:15:43 +02:00
parent 4a795819cc
commit 352ac3bd1a
9 changed files with 630 additions and 1464 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ Cargo.lock
*.png *.png
*.cbz *.cbz
*.json *.json
*.sh

View File

@@ -9,6 +9,7 @@ crossterm = { version = "0.29", default-features = false, features = ["events"]
futures = { version = "0.3.30", default-features = false } futures = { version = "0.3.30", default-features = false }
icy_sixel = { version = "0.1.2", default-features = false } icy_sixel = { version = "0.1.2", default-features = false }
image = { version = "0.25.2", default-features = false, features = ["jpeg", "png"] } image = { version = "0.25.2", default-features = false, features = ["jpeg", "png"] }
isolang = { version = "2.4.0", features = ["serde"] }
reqwest = { version = "0.12.5", default-features = false, features = ["default-tls"] } reqwest = { version = "0.12.5", default-features = false, features = ["default-tls"] }
reqwest-middleware = { version = "0.4", default-features = false } reqwest-middleware = { version = "0.4", default-features = false }
reqwest-retry = { version = "0.7", default-features = false } reqwest-retry = { version = "0.7", default-features = false }

View File

@@ -1,34 +1,78 @@
use crate::response_deserializer::{Chapter, ChapterFeed, Id, Language, Manga}; use crate::{
use crate::util::{ConfigSearch, ConfigSelectionType, CoverSize, SelectionRange}; response_deserializer,
use crate::BASE; response_deserializer::{Chapter, ChapterFeed, Id, Manga},
util::{ConfigSelectionType, CoverSize, Selection, SelectionRange, SelectionType},
BASE,
};
use crate::language::Language;
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
type Client = ClientWithMiddleware; type Client = ClientWithMiddleware;
// An alternate design would use different builders depending on if .search_by_query() or
// .search_by_id() was called or search(Search::Query(query)) / search(Search::Id(id))
// This would allow for flat building rather than nested
// Example:
// MangaClientBuilder::new()
// .search_by_query("query example")
// You now have a struct like MangaClientQueryBuilder
// .result_limit(3)
// .search_filter(SearchFilter::default())
// .build()
// And end up with a MangaClient struct
// MangaClientBuilder::new()
// .search_by_id("id example")
// You now have a struct like MangaClientIdBuilder
// .build()
// And end up with a MangaClient struct
//
// Example implementation:
// impl MangaClientBuilder {
// fn search_by_query(mut self, query: &str) -> MangaClientQueryBuilder {
// todo!()
// }
// fn search_by_id(mut self, id: Id) -> MangaClientIdBuilder {
// todo!()
// }
// }
pub struct MangaClientBuilder { pub struct MangaClientBuilder {
client: Client, client: Client,
search_filter: Option<SearchFilter>, search: Option<Search>,
bonus: Option<bool>, bonus: Option<bool>,
result_limit: Option<u32>,
cover_size: Option<CoverSize>,
selection_type: Option<ConfigSelectionType>, selection_type: Option<ConfigSelectionType>,
selection_range: Option<SelectionRange>, selection_range: Option<SelectionRange>,
search: Option<ConfigSearch>,
cover: Option<bool>,
language: Option<Language>, language: Option<Language>,
} }
#[derive(Debug)] #[derive(Debug)]
pub struct MangaClient { pub struct MangaClient {
pub client: Client, pub client: Client,
pub search: Search,
pub selection: Selection,
pub language: Language,
}
#[derive(Debug)]
pub enum Search {
Id(Id),
Query(SearchOptions),
}
#[derive(Debug)]
pub struct SearchOptions {
pub query: String,
pub search_filter: SearchFilter, pub search_filter: SearchFilter,
pub result_limit: u32, pub result_limit: u32,
pub cover_size: CoverSize, pub cover: Option<CoverSize>,
pub search: ConfigSearch, }
pub cover: bool,
pub selection: crate::util::Selection, #[derive(Debug, Default)]
pub language: Language, pub struct SearchOptionsBuilder {
pub query: Option<String>,
pub search_filter: Option<SearchFilter>,
pub result_limit: Option<u32>,
pub cover: Option<CoverSize>,
} }
#[derive(Default, Clone, Debug)] #[derive(Default, Clone, Debug)]
@@ -111,9 +155,40 @@ impl Default for PublicationDemographicFilter {
} }
} }
#[allow(unused)]
impl SearchOptionsBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn query(mut self, query: String) -> Self {
self.query = Some(query);
self
}
pub fn search_filter(mut self, search_filter: SearchFilter) -> Self {
self.search_filter = Some(search_filter);
self
}
pub fn result_limit(mut self, result_limit: u32) -> Self {
self.result_limit = Some(result_limit);
self
}
pub fn cover(mut self, cover_size: Option<CoverSize>) -> Self {
self.cover = cover_size;
self
}
pub fn build(self) -> SearchOptions {
SearchOptions {
query: self.query.unwrap(),
search_filter: self.search_filter.unwrap_or_default(),
result_limit: self.result_limit.unwrap_or(5),
cover: self.cover,
}
}
}
impl MangaClientBuilder { impl MangaClientBuilder {
pub fn new() -> MangaClientBuilder { pub fn new() -> Self {
MangaClientBuilder { Self {
client: ClientBuilder::new( client: ClientBuilder::new(
reqwest::Client::builder() reqwest::Client::builder()
.user_agent("manga-cli/version-0.1") .user_agent("manga-cli/version-0.1")
@@ -124,50 +199,30 @@ impl MangaClientBuilder {
ExponentialBackoff::builder().build_with_max_retries(3), ExponentialBackoff::builder().build_with_max_retries(3),
)) ))
.build(), .build(),
search_filter: None,
bonus: None,
result_limit: None,
cover_size: None,
selection_type: None, selection_type: None,
selection_range: None, selection_range: None,
search: None, search: None,
cover: None,
language: None, language: None,
bonus: None,
} }
} }
pub fn search_filter(mut self, filter: SearchFilter) -> MangaClientBuilder { pub fn bonus(mut self, bonus: bool) -> Self {
self.search_filter = Some(filter);
self
}
pub fn bonus(mut self, bonus: bool) -> MangaClientBuilder {
self.bonus = Some(bonus); self.bonus = Some(bonus);
self self
} }
pub fn result_limit(mut self, result_limit: u32) -> MangaClientBuilder { pub fn selection_type(mut self, selection_type: ConfigSelectionType) -> Self {
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.selection_type = Some(selection_type);
self self
} }
pub fn selection_range(mut self, selection_range: SelectionRange) -> MangaClientBuilder { pub fn selection_range(mut self, selection_range: SelectionRange) -> Self {
self.selection_range = Some(selection_range); self.selection_range = Some(selection_range);
self self
} }
pub fn search(mut self, search: ConfigSearch) -> MangaClientBuilder { pub fn search(mut self, search: Search) -> Self {
self.search = Some(search); self.search = Some(search);
self self
} }
pub fn cover(mut self, cover: bool) -> MangaClientBuilder { pub fn language(mut self, language: Language) -> Self {
self.cover = Some(cover);
self
}
pub fn language(mut self, language: Language) -> MangaClientBuilder {
self.language = Some(language); self.language = Some(language);
self self
} }
@@ -175,21 +230,15 @@ impl MangaClientBuilder {
pub fn build(self) -> MangaClient { pub fn build(self) -> MangaClient {
MangaClient { MangaClient {
client: self.client, client: self.client,
search_filter: self.search_filter.unwrap_or_default(), search: self.search.unwrap(),
result_limit: self.result_limit.unwrap_or(5), selection: Selection::new(
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 { match self.selection_type {
Some(a) => match a { Some(a) => match a {
ConfigSelectionType::Chapter => { ConfigSelectionType::Chapter => {
crate::util::SelectionType::Chapter(self.selection_range.unwrap()) SelectionType::Chapter(self.selection_range.unwrap())
} }
ConfigSelectionType::Volume => { ConfigSelectionType::Volume => {
crate::util::SelectionType::Volume(self.selection_range.unwrap()) SelectionType::Volume(self.selection_range.unwrap())
} }
}, },
None => panic!(), None => panic!(),
@@ -204,13 +253,13 @@ impl MangaClientBuilder {
impl MangaClient { impl MangaClient {
pub async fn get_manga(&self) -> Vec<Manga> { pub async fn get_manga(&self) -> Vec<Manga> {
match &self.search { match &self.search {
ConfigSearch::Query(query) => { Search::Query(search) => {
let limit = self.result_limit.to_string(); let limit = search.result_limit.to_string();
let mut params = vec![("title", query.as_str()), ("limit", limit.as_str())]; let mut params = vec![("title", search.query.as_str()), ("limit", limit.as_str())];
if self.cover { // if self.cover {
params.push(("includes[]", "cover_art")); params.push(("includes[]", "cover_art"));
} // }
let search_filter: Vec<(&str, &str)> = self.search_filter.clone().into(); let search_filter: Vec<(&str, &str)> = search.search_filter.clone().into();
let json = self let json = self
.client .client
.get(format!("{BASE}/manga")) .get(format!("{BASE}/manga"))
@@ -222,11 +271,11 @@ impl MangaClient {
.text() .text()
.await .await
.unwrap(); .unwrap();
crate::response_deserializer::deserialize_search(&json) response_deserializer::deserialize_search(&json)
.unwrap() .unwrap()
.data .data
} }
ConfigSearch::Id(id) => { Search::Id(id) => {
let json = self let json = self
.client .client
.get(format!("{BASE}/manga/{id}")) .get(format!("{BASE}/manga/{id}"))
@@ -237,7 +286,7 @@ impl MangaClient {
.await .await
.unwrap(); .unwrap();
vec![ vec![
crate::response_deserializer::deserialize_id_query(&json) response_deserializer::deserialize_id_query(&json)
.unwrap() .unwrap()
.data, .data,
] ]
@@ -254,6 +303,7 @@ impl MangaClient {
("limit", limit_s.as_str()), ("limit", limit_s.as_str()),
// These are apparently necessary, or else you may miss some chapters // These are apparently necessary, or else you may miss some chapters
("order[chapter]", "asc"), ("order[chapter]", "asc"),
("includeUnavailable", "1"),
("offset", "0"), ("offset", "0"),
]; ];
let url = format!("{BASE}/manga/{id}/feed"); let url = format!("{BASE}/manga/{id}/feed");
@@ -265,7 +315,7 @@ impl MangaClient {
.await? .await?
.text() .text()
.await?; .await?;
let mut result = crate::response_deserializer::deserialize_chapter_feed(&json).unwrap(); let mut result = response_deserializer::deserialize_chapter_feed(&json).unwrap();
let mut chapters = Vec::new(); let mut chapters = Vec::new();
chapters.append(&mut result.data); chapters.append(&mut result.data);
@@ -276,7 +326,13 @@ impl MangaClient {
let url = &url; let url = &url;
async move { async move {
let offset = (i * limit).to_string(); let offset = (i * limit).to_string();
let params = [params[0], params[1], params[2], ("offset", offset.as_str())]; let params = [
params[0],
params[1],
params[2],
params[3],
("offset", offset.as_str()),
];
let json = self let json = self
.client .client
.get(url) .get(url)
@@ -286,7 +342,7 @@ impl MangaClient {
.text() .text()
.await?; .await?;
Ok::<ChapterFeed, reqwest_middleware::Error>( Ok::<ChapterFeed, reqwest_middleware::Error>(
crate::response_deserializer::deserialize_chapter_feed(&json).unwrap(), response_deserializer::deserialize_chapter_feed(&json).unwrap(),
) )
} }
})) }))
@@ -310,8 +366,9 @@ mod tests {
#[ignore] #[ignore]
async fn ensure_getting_all_chapters() { async fn ensure_getting_all_chapters() {
let client = MangaClientBuilder::new() let client = MangaClientBuilder::new()
.selection_type(crate::util::ConfigSelectionType::Volume) .search(Search::Id(Id(String::new())))
.selection_range(crate::util::SelectionRange::All) .selection_type(ConfigSelectionType::Volume)
.selection_range(SelectionRange::All)
.build(); .build();
// These tests should only be done for completed manga because last chapter might change // These tests should only be done for completed manga because last chapter might change
@@ -361,15 +418,21 @@ mod tests {
#[tokio::test] #[tokio::test]
#[ignore] #[ignore]
async fn get_manga() { async fn get_manga() {
search("JoJo's Bizarre Adventure Part 1 - Phantom Blood", "5a547d1d-576b-477f-8cb3-70a3b4187f8a").await; search(
"JoJo's Bizarre Adventure Part 1 - Phantom Blood",
"5a547d1d-576b-477f-8cb3-70a3b4187f8a",
)
.await;
search("Slam Dunk!", "319df2e2-e6a6-4e3a-a31c-68539c140a84").await; search("Slam Dunk!", "319df2e2-e6a6-4e3a-a31c-68539c140a84").await;
} }
async fn search(query: &str, id: &str) { async fn search(query: &str, id: &str) {
let client = MangaClientBuilder::new() let client = MangaClientBuilder::new()
.selection_type(crate::util::ConfigSelectionType::Volume) .selection_type(ConfigSelectionType::Volume)
.selection_range(crate::util::SelectionRange::All) .selection_range(SelectionRange::All)
.search(ConfigSearch::Query(String::from(query))) .search(Search::Query(
SearchOptionsBuilder::new().query(query.to_owned()).build(),
))
.build(); .build();
let manga: Vec<Manga> = client.get_manga().await; let manga: Vec<Manga> = client.get_manga().await;

View File

@@ -171,6 +171,11 @@ pub enum IdQueryResultError {
IdQueryResult(#[from] IdQueryResponseError), IdQueryResult(#[from] IdQueryResponseError),
} }
#[derive(Debug, Error)]
pub enum PublicationDemographicError {
InvalidValue,
}
#[derive(Debug, Error, Deserialize)] #[derive(Debug, Error, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ApiError { pub struct ApiError {
@@ -208,6 +213,14 @@ impl fmt::Display for ChapterImagesContentError {
} }
} }
impl fmt::Display for PublicationDemographicError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidValue => "InvalidValue".fmt(f),
}
}
}
impl serde::de::Error for JsonError { impl serde::de::Error for JsonError {
fn custom<T: fmt::Display>(msg: T) -> Self { fn custom<T: fmt::Display>(msg: T) -> Self {
JsonError::Message(msg.to_string()) JsonError::Message(msg.to_string())

89
src/language.rs Normal file
View File

@@ -0,0 +1,89 @@
// Wrapper for including extras from https://en.wikipedia.org/wiki/IETF_language_tag
use serde::{Serialize, Serializer};
use std::fmt;
#[derive(Debug, Serialize)]
pub enum Language {
Normal(isolang::Language),
#[serde(serialize_with = "serialize_extralang_as_str")]
Extra(ExtraLang),
}
#[derive(Debug, Serialize)]
pub enum ExtraLang {
SimplifiedChinese,
TraditionalChinese,
BrazilianPortugese,
CastilianSpanish,
LatinAmericanSpanish,
RomanizedJapanese,
RomanizedKorean,
RomanizedChinese,
}
fn serialize_extralang_as_str<S>(x: &ExtraLang, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(x.to_string().as_str())
}
impl fmt::Display for ExtraLang {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use ExtraLang::*;
match self {
SimplifiedChinese => "zh".fmt(f),
TraditionalChinese => "zh-hk".fmt(f),
BrazilianPortugese => "pt-br".fmt(f),
CastilianSpanish => "es".fmt(f),
LatinAmericanSpanish => "es-la".fmt(f),
RomanizedJapanese => "ja-ro".fmt(f),
RomanizedKorean => "ko-ro".fmt(f),
RomanizedChinese => "zh-ro".fmt(f),
}
}
}
impl ExtraLang {
fn from(s: &str) -> Option<Self> {
use ExtraLang::*;
Some(match s {
"zh" => SimplifiedChinese,
"zh-hk" => TraditionalChinese,
"pt-br" => BrazilianPortugese,
"es" => CastilianSpanish,
"es-la" => LatinAmericanSpanish,
"ja-ro" => RomanizedJapanese,
"ko-ro" => RomanizedKorean,
"zh-ro" => RomanizedChinese,
_ => return None,
})
}
}
impl Language {
pub fn from(s: &str) -> Self {
if let Some(extra) = ExtraLang::from(s) {
Language::Extra(extra)
} else {
Language::Normal(isolang::Language::from_639_1(s).unwrap())
}
}
}
impl Default for Language {
fn default() -> Self {
Self::Normal(isolang::Language::from_name("English").unwrap())
}
}
impl fmt::Display for Language {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use Language::*;
match self {
Extra(extra) => extra.fmt(f),
Normal(normal) => normal.to_639_1().unwrap().fmt(f),
}
}
}

View File

@@ -1,28 +1,41 @@
use client::{MangaClient, MangaClientBuilder, SearchFilter}; // TODO: Only use a single struct for deserializing
use response_deserializer::{Chapter, Id, Language, Manga}; use client::{MangaClient, MangaClientBuilder, Search, SearchOptionsBuilder};
use language::Language;
use response_deserializer::{Chapter, DataType, Id, Manga};
use select::Entry; use select::Entry;
use std::{future::Future, pin::Pin}; use std::{fs, future::Future, path::Path, pin::Pin, process};
use util::SelectionType; use util::{ConfigSearch, CoverSize, SelectionType};
mod client; mod client;
mod error; mod error;
mod metadata;
mod response_deserializer; mod response_deserializer;
mod select; mod select;
mod test; mod test;
mod util; mod util;
mod metadata; mod language;
const BASE: &str = "https://api.mangadex.org"; const BASE: &str = "https://api.mangadex.org";
const ONLY_METADATA: bool = true;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let config = util::args(); let config = util::args();
let manga_client = let manga_client = MangaClientBuilder::new()
MangaClientBuilder::new() .search(match config.search {
.bonus(config.bonus.unwrap_or_else(util::ask_bonus)) None => Search::Query(
.cover(config.cover.unwrap_or_else(util::ask_cover)) SearchOptionsBuilder::new()
.query(util::get_input("Enter search query: "))
.build(),
),
Some(ConfigSearch::Id(id)) => Search::Id(id),
Some(ConfigSearch::Query(query)) => Search::Query(
SearchOptionsBuilder::new()
.query(query)
.result_limit(config.result_limit.unwrap_or(5)) .result_limit(config.result_limit.unwrap_or(5))
.cover_size(util::CoverSize::Full) .build(),
),
})
.selection_type( .selection_type(
config config
.selection_type .selection_type
@@ -33,16 +46,15 @@ async fn main() {
.selection_range .selection_range
.unwrap_or_else(util::ask_selection_range), .unwrap_or_else(util::ask_selection_range),
) )
.search(config.search.unwrap_or_else(|| { .language(Language::Normal(
util::ConfigSearch::Query(util::get_input("Enter search query: ")) isolang::Language::from_name("English").unwrap(),
})) ))
.search_filter(SearchFilter::default()) .bonus(config.bonus.unwrap_or_else(util::ask_bonus))
.language(Language::default())
.build(); .build();
let mut search_result = manga_client.get_manga().await; let mut search_result = manga_client.get_manga().await;
if search_result.is_empty() { if search_result.is_empty() {
eprintln!("Found no search results"); eprintln!("Found no search results");
std::process::exit(1); process::exit(1);
} }
let mut choice = 0; let mut choice = 0;
@@ -50,11 +62,35 @@ async fn main() {
choice = select_manga_from_search(&manga_client, &search_result).await; choice = select_manga_from_search(&manga_client, &search_result).await;
} }
let manga = search_result.remove(choice as usize); let manga = search_result.remove(choice as usize);
if let Some(cover_data) = &manga.relationships[2].attributes {
let image_url = format!(
"https://uploads.mangadex.org/covers/{}/{}",
&manga.id.0, &cover_data.file_name
);
let dir = format!("images/{}", manga.attributes.title.en);
if !Path::new(&dir).exists() {
fs::create_dir(&dir).unwrap();
}
let path = format!("{}/cover.jpg", &dir);
fs::write(
path,
manga_client
.client
.get(&image_url)
.send()
.await
.unwrap()
.bytes()
.await
.unwrap(),
)
.unwrap();
}
let mut chapters = match manga_client.get_chapters(&manga.id).await { let mut chapters = match manga_client.get_chapters(&manga.id).await {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
eprintln!("ERROR: {e:#?}"); eprintln!("ERROR: {e:#?}");
std::process::exit(1); process::exit(1);
} }
}; };
println!("Downloading {} chapters", chapters.len()); println!("Downloading {} chapters", chapters.len());
@@ -65,15 +101,36 @@ async fn main() {
.partial_cmp(&b.attributes.chapter.unwrap_or(-1.)) .partial_cmp(&b.attributes.chapter.unwrap_or(-1.))
.unwrap() .unwrap()
}); });
let mut actual_chapters: Vec<Chapter> = Vec::new();
for chapter in chapters {
let mut should_push = false;
for relation in &chapter.relationships {
println!(
"relation type: {:?}, id: {:?}",
relation.data_type, relation.id
);
if relation.data_type == DataType::ScanlationGroup
&& (relation.id == Id(String::from("13e87775-f8b2-4d5f-a814-8dc245820e5a"))
|| relation.id == Id(String::from("e2ffaa79-1bef-4b7d-b382-a2b98221a310")))
{
should_push = true;
}
}
if should_push {
actual_chapters.push(chapter);
}
}
let chapters = actual_chapters;
let title = &manga.attributes.title.en.clone();
if !ONLY_METADATA {
let create_volumes = matches!( let create_volumes = matches!(
manga_client.selection.selection_type, manga_client.selection.selection_type,
SelectionType::Volume(_) SelectionType::Volume(_)
); );
let selected_chapters = util::get_chapters_from_selection(manga_client.selection, &chapters); let selected_chapters =
util::get_chapters_from_selection(manga_client.selection, &chapters);
let title = &manga.attributes.title.en.clone();
util::download_selected_chapters( util::download_selected_chapters(
&manga_client.client, &manga_client.client,
&selected_chapters, &selected_chapters,
@@ -81,18 +138,32 @@ async fn main() {
create_volumes, create_volumes,
) )
.await; .await;
util::create_volumes_or_chapters(&selected_chapters, create_volumes, title); util::create_volumes_or_chapters(&selected_chapters, create_volumes, title);
}
let metadata = metadata::Metadata::new(manga, chapters); let metadata = metadata::Metadata::new(manga, chapters);
std::fs::write(format!("images/{}/metadata.json", title), serde_json::to_string(&metadata).unwrap()).unwrap(); let dir = format!("images/{}", title);
if !Path::new(&dir).exists() {
fs::create_dir(Path::new(&dir)).unwrap();
}
fs::write(
format!("images/{}/metadata.json", title),
serde_json::to_string(&metadata).unwrap(),
)
.unwrap();
} }
async fn select_manga_from_search(client: &MangaClient, results: &[Manga]) -> u16 { async fn select_manga_from_search(client: &MangaClient, results: &[Manga]) -> u16 {
let cover_ex = match client.cover_size { let cover_ex = match &client.search {
util::CoverSize::Full => "", Search::Query(q) => match &q.cover {
util::CoverSize::W256 => ".256.jpg", Some(s) => match s {
util::CoverSize::W512 => ".512.jpg", CoverSize::Full => "",
CoverSize::W256 => ".256.jpg",
CoverSize::W512 => ".512.jpg",
},
_ => "",
},
_ => "",
}; };
let mut entry_futures: Vec<Pin<Box<dyn Future<Output = Entry>>>> = Vec::new(); let mut entry_futures: Vec<Pin<Box<dyn Future<Output = Entry>>>> = Vec::new();
@@ -115,15 +186,15 @@ async fn select_manga_from_search(client: &MangaClient, results: &[Manga]) -> u1
if let Some(volumes) = result.attributes.last_volume { if let Some(volumes) = result.attributes.last_volume {
entry.add_info("volumes", volumes); entry.add_info("volumes", volumes);
} }
if let Some(cover_data) = &result.relationships[2].attributes { if let Some(cover_data) = &result.relationships[2].attributes {
if matches!(&client.search, Search::Query(q) if q.cover.is_some()) {
// The lib used for converting to sixel is abysmally slow for larger images, this // The lib used for converting to sixel is abysmally slow for larger images, this
// should be in a future to allow for multithreaded work // should be in a future to allow for multithreaded work
// Cover data should only be present if used
assert!(client.cover);
let future = async move { let future = async move {
let image_url = format!( let image_url = format!(
"https://uploads.mangadex.org/covers/{id}/{}{cover_ex}", "https://uploads.mangadex.org/covers/{id}/{}{cover_ex}",
&cover_data.file_name cover_data.file_name
); );
let data = client let data = client
.client .client
@@ -143,6 +214,9 @@ async fn select_manga_from_search(client: &MangaClient, results: &[Manga]) -> u1
} else { } else {
entry_futures.push(Box::pin(async move { entry })); entry_futures.push(Box::pin(async move { entry }));
} }
} else {
entry_futures.push(Box::pin(async move { entry }));
}
} }
let entries: Vec<Entry> = futures::future::join_all(entry_futures).await; let entries: Vec<Entry> = futures::future::join_all(entry_futures).await;
assert!(!entries.is_empty()); assert!(!entries.is_empty());
@@ -154,7 +228,7 @@ async fn select_manga_from_search(client: &MangaClient, results: &[Manga]) -> u1
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
eprintln!("ERROR: Failed to select: {e:?}"); eprintln!("ERROR: Failed to select: {e:?}");
std::process::exit(1); process::exit(1);
} }
}; };
choice choice

View File

@@ -2,8 +2,9 @@
use serde::Serialize; use serde::Serialize;
use crate::response_deserializer::{ use crate::response_deserializer::{
Chapter, ContentRating, Language, Manga, PublicationDemographic, State, Status, Titles, Chapter, ContentRating, Manga, PublicationDemographic, State, Status, Titles,
}; };
use crate::language::Language;
use chrono::{DateTime, FixedOffset}; use chrono::{DateTime, FixedOffset};
#[derive(Serialize)] #[derive(Serialize)]
@@ -26,7 +27,7 @@ pub struct Metadata {
struct ChapterMetadata { struct ChapterMetadata {
chapter: f32, chapter: f32,
volume: f32, volume: f32,
title: String, title: Option<String>,
pages: u32, pages: u32,
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
use crate::error::{ChapterImageError, ChapterImagesError}; use crate::{
use crate::response_deserializer::ChapterImages; response_deserializer,
use crate::{Chapter, Id, BASE}; response_deserializer::ChapterImages,
Chapter, Id, BASE,
};
use icy_sixel::{DiffusionMethod, MethodForLargest, MethodForRep, PixelFormat, Quality}; use icy_sixel::{DiffusionMethod, MethodForLargest, MethodForRep, PixelFormat, Quality};
use image::DynamicImage; use image::DynamicImage;
use std::{fs::File, io, io::Write, path::Path}; use std::{fs::File, io, io::Write, path::Path};
@@ -72,7 +74,7 @@ pub struct Config {
#[derive(Debug)] #[derive(Debug)]
pub enum ConfigSearch { pub enum ConfigSearch {
Query(String), Query(String),
Id(crate::Id), Id(Id),
} }
fn filter_bonus(bonus: bool, volume: Option<f32>, chapter: Option<f32>) -> bool { fn filter_bonus(bonus: bool, volume: Option<f32>, chapter: Option<f32>) -> bool {
@@ -311,7 +313,6 @@ pub async fn download_selected_chapters(
) { ) {
let mut items = Vec::new(); let mut items = Vec::new();
for (i, chapter) in selected_chapters.iter().enumerate() { for (i, chapter) in selected_chapters.iter().enumerate() {
let chapter_image_data = loop {
let json = client let json = client
.get(format!("{BASE}/at-home/server/{}", chapter.id)) .get(format!("{BASE}/at-home/server/{}", chapter.id))
.send() .send()
@@ -320,30 +321,7 @@ pub async fn download_selected_chapters(
.text() .text()
.await .await
.unwrap(); .unwrap();
let result = crate::response_deserializer::deserialize_chapter_images(&json); let chapter_image_data = 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!( println!(
"\x1b[1A\x1b[2Kdownloaded chapter json image data: [{i}/{}]", "\x1b[1A\x1b[2Kdownloaded chapter json image data: [{i}/{}]",
selected_chapters.len() selected_chapters.len()
@@ -360,7 +338,7 @@ pub async fn download_selected_chapters(
.await .await
.unwrap(); .unwrap();
println!( println!(
"\x1b[1A\x1b[2KDownloaded volume: {:?}, chapter: {:?}, title: {}, [{}/{}]", "\x1b[1A\x1b[2KDownloaded volume: {:?}, chapter: {:?}, title: {:?}, [{}/{}]",
chapter.attributes.volume, chapter.attributes.volume,
chapter.attributes.chapter, chapter.attributes.chapter,
chapter.attributes.title, chapter.attributes.title,
@@ -401,13 +379,13 @@ fn save_images(
String::from("_none") String::from("_none")
}; };
let chapter_path = format!( let chapter_path = format!(
"images/{}/chapter_{:0>4}_image_{:0>4}.png", "images/{}/chapter_{:0>4}_page_{:0>4}.png",
title, chapter_text, n.index title, chapter_text, n.index
); );
let path = if let Some(v) = n.volume { let path = if let Some(v) = n.volume {
if create_volumes { if create_volumes {
format!( format!(
"images/{}/volume_{:0>4}/chapter_{:0>4}_image_{:0>4}.png", "images/{}/volume_{:0>4}/chapter_{:0>4}_page_{:0>4}.png",
title, v, chapter_text, n.index title, v, chapter_text, n.index
) )
} else { } else {
@@ -427,9 +405,6 @@ fn save_images(
} }
} }
// Path structure
// /images/Manga Name/Volume 00XX - Volume Name/Chapter 00XX - Chapter Name/00XX.png
// /images/Manga Name/Volume 00XX - Volume Name/Chapter 00XX - page 00XX - Chapter Name.png
pub fn create_volumes_or_chapters( pub fn create_volumes_or_chapters(
selected_chapters: &Vec<&Chapter>, selected_chapters: &Vec<&Chapter>,
create_volumes: bool, create_volumes: bool,
@@ -489,7 +464,7 @@ pub fn create_volumes_or_chapters(
continue; continue;
} }
if entry.file_name().to_str().unwrap()[..18] if entry.file_name().to_str().unwrap()[..18]
== *format!("chapter_{:0>4}_image_", chapter).as_str() == *format!("chapter_{:0>4}_page_", chapter).as_str()
{ {
zip.start_file(entry.file_name().to_str().unwrap(), options) zip.start_file(entry.file_name().to_str().unwrap(), options)
.unwrap(); .unwrap();
@@ -517,7 +492,7 @@ fn construct_image_items(image_data: &ChapterImages, chapter: &Chapter) -> Vec<I
), ),
chapter: chapter.attributes.chapter, chapter: chapter.attributes.chapter,
volume: chapter.attributes.volume, volume: chapter.attributes.volume,
index: i, index: i + 1,
}) })
.collect() .collect()
} }