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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ Cargo.lock
|
|||||||
*.png
|
*.png
|
||||||
*.cbz
|
*.cbz
|
||||||
*.json
|
*.json
|
||||||
|
*.sh
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
203
src/client.rs
203
src/client.rs
@@ -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;
|
||||||
|
|||||||
13
src/error.rs
13
src/error.rs
@@ -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
89
src/language.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
src/main.rs
222
src/main.rs
@@ -1,48 +1,60 @@
|
|||||||
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()
|
||||||
.result_limit(config.result_limit.unwrap_or(5))
|
.query(util::get_input("Enter search query: "))
|
||||||
.cover_size(util::CoverSize::Full)
|
.build(),
|
||||||
.selection_type(
|
),
|
||||||
config
|
Some(ConfigSearch::Id(id)) => Search::Id(id),
|
||||||
.selection_type
|
Some(ConfigSearch::Query(query)) => Search::Query(
|
||||||
.unwrap_or_else(util::ask_selection_type),
|
SearchOptionsBuilder::new()
|
||||||
)
|
.query(query)
|
||||||
.selection_range(
|
.result_limit(config.result_limit.unwrap_or(5))
|
||||||
config
|
.build(),
|
||||||
.selection_range
|
),
|
||||||
.unwrap_or_else(util::ask_selection_range),
|
})
|
||||||
)
|
.selection_type(
|
||||||
.search(config.search.unwrap_or_else(|| {
|
config
|
||||||
util::ConfigSearch::Query(util::get_input("Enter search query: "))
|
.selection_type
|
||||||
}))
|
.unwrap_or_else(util::ask_selection_type),
|
||||||
.search_filter(SearchFilter::default())
|
)
|
||||||
.language(Language::default())
|
.selection_range(
|
||||||
.build();
|
config
|
||||||
|
.selection_range
|
||||||
|
.unwrap_or_else(util::ask_selection_range),
|
||||||
|
)
|
||||||
|
.language(Language::Normal(
|
||||||
|
isolang::Language::from_name("English").unwrap(),
|
||||||
|
))
|
||||||
|
.bonus(config.bonus.unwrap_or_else(util::ask_bonus))
|
||||||
|
.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,34 +101,69 @@ 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();
|
||||||
let create_volumes = matches!(
|
for chapter in chapters {
|
||||||
manga_client.selection.selection_type,
|
let mut should_push = false;
|
||||||
SelectionType::Volume(_)
|
for relation in &chapter.relationships {
|
||||||
);
|
println!(
|
||||||
let selected_chapters = util::get_chapters_from_selection(manga_client.selection, &chapters);
|
"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();
|
let title = &manga.attributes.title.en.clone();
|
||||||
|
|
||||||
util::download_selected_chapters(
|
if !ONLY_METADATA {
|
||||||
&manga_client.client,
|
let create_volumes = matches!(
|
||||||
&selected_chapters,
|
manga_client.selection.selection_type,
|
||||||
title,
|
SelectionType::Volume(_)
|
||||||
create_volumes,
|
);
|
||||||
)
|
let selected_chapters =
|
||||||
.await;
|
util::get_chapters_from_selection(manga_client.selection, &chapters);
|
||||||
|
util::download_selected_chapters(
|
||||||
util::create_volumes_or_chapters(&selected_chapters, create_volumes, title);
|
&manga_client.client,
|
||||||
|
&selected_chapters,
|
||||||
|
title,
|
||||||
|
create_volumes,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
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,31 +186,34 @@ 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 {
|
|
||||||
// The lib used for converting to sixel is abysmally slow for larger images, this
|
|
||||||
// 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 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);
|
if let Some(cover_data) = &result.relationships[2].attributes {
|
||||||
entry
|
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
|
||||||
entry_futures.push(Box::pin(future));
|
// 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
|
||||||
|
);
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
entry_futures.push(Box::pin(async move { entry }));
|
entry_futures.push(Box::pin(async move { entry }));
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
65
src/util.rs
65
src/util.rs
@@ -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,39 +313,15 @@ 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()
|
.await
|
||||||
.await
|
.unwrap()
|
||||||
.unwrap()
|
.text()
|
||||||
.text()
|
.await
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
let chapter_image_data = response_deserializer::deserialize_chapter_images(&json);
|
||||||
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!(
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user