Compare commits
12 Commits
af002b0149
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f2c0c6bf5 | |||
| 8daa4f0863 | |||
| 352ac3bd1a | |||
| 4a795819cc | |||
| 66daaad6a7 | |||
| e797a8b8ae | |||
| 1a29600d88 | |||
| 920d9ee25a | |||
| 9b649a39fe | |||
| b685c3a831 | |||
| 2f381d540b | |||
| 6d4ffa209a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@ Cargo.lock
|
|||||||
/cba
|
/cba
|
||||||
*.jpg
|
*.jpg
|
||||||
*.png
|
*.png
|
||||||
|
*.cbz
|
||||||
|
*.json
|
||||||
|
*.sh
|
||||||
|
|||||||
23
Cargo.toml
23
Cargo.toml
@@ -4,15 +4,18 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = "0.4.38"
|
chrono = { version = "0.4.38", default-features = false, features = ["serde"] }
|
||||||
crossterm = "0.28.1"
|
crossterm = { version = "0.29", default-features = false, features = ["events"] }
|
||||||
futures = "0.3.30"
|
futures = { version = "0.3.30", default-features = false }
|
||||||
icy_sixel = "0.1.2"
|
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"] }
|
||||||
reqwest = "0.12.5"
|
isolang = { version = "2.4.0", features = ["serde"] }
|
||||||
reqwest-middleware = "0.3.2"
|
reqwest = { version = "0.12.5", default-features = false, features = ["default-tls"] }
|
||||||
reqwest-retry = "0.6.0"
|
reqwest-middleware = { version = "0.4", default-features = false }
|
||||||
serde = { version = "1.0.204", features = ["derive"] }
|
reqwest-retry = { version = "0.7", default-features = false }
|
||||||
serde_json = "1.0.121"
|
ron = {version = "0.10.1", 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"] }
|
tokio = { version = "1.39.2", default-features = false, features = ["macros", "rt-multi-thread"] }
|
||||||
zip = "4.0.0"
|
zip = { version = "4.0.0", default-features = false, features = ["deflate"] }
|
||||||
|
|||||||
441
src/client.rs
Normal file
441
src/client.rs
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
use crate::{
|
||||||
|
response_deserializer,
|
||||||
|
types::{Chapter, ChapterFeed, Id, Manga},
|
||||||
|
util::{ConfigSelectionType, CoverSize, Selection, SelectionRange, SelectionType},
|
||||||
|
BASE,
|
||||||
|
};
|
||||||
|
use crate::language::Language;
|
||||||
|
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
|
||||||
|
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
client: Client,
|
||||||
|
search: Option<Search>,
|
||||||
|
bonus: Option<bool>,
|
||||||
|
selection_type: Option<ConfigSelectionType>,
|
||||||
|
selection_range: Option<SelectionRange>,
|
||||||
|
language: Option<Language>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MangaClient {
|
||||||
|
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 result_limit: u32,
|
||||||
|
pub cover: Option<CoverSize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
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)]
|
||||||
|
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<SearchFilter> 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
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(),
|
||||||
|
selection_type: None,
|
||||||
|
selection_range: None,
|
||||||
|
search: None,
|
||||||
|
language: None,
|
||||||
|
bonus: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn bonus(mut self, bonus: bool) -> Self {
|
||||||
|
self.bonus = Some(bonus);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn selection_type(mut self, selection_type: ConfigSelectionType) -> Self {
|
||||||
|
self.selection_type = Some(selection_type);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn selection_range(mut self, selection_range: SelectionRange) -> Self {
|
||||||
|
self.selection_range = Some(selection_range);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn search(mut self, search: Search) -> Self {
|
||||||
|
self.search = Some(search);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn language(mut self, language: Language) -> Self {
|
||||||
|
self.language = Some(language);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> MangaClient {
|
||||||
|
MangaClient {
|
||||||
|
client: self.client,
|
||||||
|
search: self.search.unwrap(),
|
||||||
|
selection: Selection::new(
|
||||||
|
match self.selection_type {
|
||||||
|
Some(a) => match a {
|
||||||
|
ConfigSelectionType::Chapter => {
|
||||||
|
SelectionType::Chapter(self.selection_range.unwrap())
|
||||||
|
}
|
||||||
|
ConfigSelectionType::Volume => {
|
||||||
|
SelectionType::Volume(self.selection_range.unwrap())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => panic!(),
|
||||||
|
},
|
||||||
|
self.bonus.unwrap_or(true),
|
||||||
|
),
|
||||||
|
language: self.language.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MangaClient {
|
||||||
|
pub async fn get_manga(&self) -> Vec<Manga> {
|
||||||
|
match &self.search {
|
||||||
|
Search::Query(search) => {
|
||||||
|
let limit = search.result_limit.to_string();
|
||||||
|
let mut params = vec![("title", search.query.as_str()), ("limit", limit.as_str())];
|
||||||
|
// if self.cover {
|
||||||
|
params.push(("includes[]", "cover_art"));
|
||||||
|
// }
|
||||||
|
let search_filter: Vec<(&str, &str)> = search.search_filter.clone().into();
|
||||||
|
let json = self
|
||||||
|
.client
|
||||||
|
.get(format!("{BASE}/manga"))
|
||||||
|
.query(¶ms)
|
||||||
|
.query(&search_filter)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
response_deserializer::deserialize_search(&json)
|
||||||
|
.unwrap()
|
||||||
|
.data
|
||||||
|
}
|
||||||
|
Search::Id(id) => {
|
||||||
|
let json = self
|
||||||
|
.client
|
||||||
|
.get(format!("{BASE}/manga/{id}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
vec![
|
||||||
|
response_deserializer::deserialize_id_query(&json)
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_chapters(&self, id: &Id) -> Result<Vec<Chapter>, reqwest_middleware::Error> {
|
||||||
|
let limit = 100;
|
||||||
|
let limit_s = limit.to_string();
|
||||||
|
let lang = self.language.to_string();
|
||||||
|
let params = [
|
||||||
|
("translatedLanguage[]", lang.as_str()),
|
||||||
|
("limit", limit_s.as_str()),
|
||||||
|
// These are apparently necessary, or else you may miss some chapters
|
||||||
|
("order[chapter]", "asc"),
|
||||||
|
("includeUnavailable", "1"),
|
||||||
|
("offset", "0"),
|
||||||
|
];
|
||||||
|
let url = format!("{BASE}/manga/{id}/feed");
|
||||||
|
let json = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.text()
|
||||||
|
.await?;
|
||||||
|
let mut result = response_deserializer::deserialize_chapter_feed(&json).unwrap();
|
||||||
|
let mut chapters = Vec::new();
|
||||||
|
chapters.append(&mut result.data);
|
||||||
|
|
||||||
|
let total = result.total;
|
||||||
|
let iters = total / limit + if total % limit != 0 { 1 } else { 0 };
|
||||||
|
|
||||||
|
futures::future::join_all((1..iters).map(|i| {
|
||||||
|
let url = &url;
|
||||||
|
async move {
|
||||||
|
let offset = (i * limit).to_string();
|
||||||
|
let params = [
|
||||||
|
params[0],
|
||||||
|
params[1],
|
||||||
|
params[2],
|
||||||
|
params[3],
|
||||||
|
("offset", offset.as_str()),
|
||||||
|
];
|
||||||
|
let json = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.text()
|
||||||
|
.await?;
|
||||||
|
Ok::<ChapterFeed, reqwest_middleware::Error>(
|
||||||
|
response_deserializer::deserialize_chapter_feed(&json).unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<Vec<ChapterFeed>, reqwest_middleware::Error>>()?
|
||||||
|
.iter_mut()
|
||||||
|
.for_each(|m| chapters.append(&mut m.data));
|
||||||
|
|
||||||
|
assert_eq!(chapters.len(), total as usize);
|
||||||
|
|
||||||
|
Ok(chapters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn ensure_getting_all_chapters() {
|
||||||
|
let client = MangaClientBuilder::new()
|
||||||
|
.search(Search::Id(Id(String::new())))
|
||||||
|
.selection_type(ConfigSelectionType::Volume)
|
||||||
|
.selection_range(SelectionRange::All)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// These tests should only be done for completed manga because last chapter might change
|
||||||
|
// otherwise
|
||||||
|
chapters_for_manga(
|
||||||
|
&client,
|
||||||
|
&Id(String::from("5a547d1d-576b-477f-8cb3-70a3b4187f8a")),
|
||||||
|
"JoJo's Bizarre Adventure Part 1 - Phantom Blood",
|
||||||
|
44,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
chapters_for_manga(
|
||||||
|
&client,
|
||||||
|
&Id(String::from("0d545e62-d4cd-4e65-a65c-a5c46b794918")),
|
||||||
|
"JoJo's Bizarre Adventure Part 3 - Stardust Crusaders",
|
||||||
|
152,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
chapters_for_manga(
|
||||||
|
&client,
|
||||||
|
&Id(String::from("319df2e2-e6a6-4e3a-a31c-68539c140a84")),
|
||||||
|
"Slam Dunk!",
|
||||||
|
276,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chapters_for_manga(client: &MangaClient, id: &Id, title: &str, len: usize) {
|
||||||
|
let chapters = client.get_chapters(id).await.unwrap();
|
||||||
|
let mut test = vec![false; len];
|
||||||
|
for chapter in &chapters {
|
||||||
|
let n = chapter.attributes.chapter.unwrap();
|
||||||
|
if n.round() == n && n >= 1. {
|
||||||
|
test[n as usize - 1] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i in 0..test.len() {
|
||||||
|
if !test[i] {
|
||||||
|
println!("chapter {} not found for: {title}", i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(!test.iter().any(|m| *m == false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn get_manga() {
|
||||||
|
search(
|
||||||
|
"JoJo's Bizarre Adventure Part 1 - Phantom Blood",
|
||||||
|
"5a547d1d-576b-477f-8cb3-70a3b4187f8a",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
search("Slam Dunk!", "319df2e2-e6a6-4e3a-a31c-68539c140a84").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(query: &str, id: &str) {
|
||||||
|
let client = MangaClientBuilder::new()
|
||||||
|
.selection_type(ConfigSelectionType::Volume)
|
||||||
|
.selection_range(SelectionRange::All)
|
||||||
|
.search(Search::Query(
|
||||||
|
SearchOptionsBuilder::new().query(query.to_owned()).build(),
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let manga: Vec<Manga> = client.get_manga().await;
|
||||||
|
assert!(manga.iter().any(|m| m.id.0 == String::from(id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/error.rs
Normal file
235
src/error.rs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
#![allow(unused)]
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::fmt;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum ResponseConversionError {
|
||||||
|
#[error("failed to convert Attribute")]
|
||||||
|
Attribute(#[from] AttributeConversionError),
|
||||||
|
#[error("failed to convert Response, got {0}")]
|
||||||
|
Response(String),
|
||||||
|
#[error("failed to convert Result, got {0}")]
|
||||||
|
Result(String),
|
||||||
|
#[error("failed to convert ContentType, got {0}")]
|
||||||
|
ContentType(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AttributeConversionError {
|
||||||
|
#[error("failed to convert attribute Language, got {0}")]
|
||||||
|
Language(String),
|
||||||
|
#[error("failed to convert attribute Locale, got {0}")]
|
||||||
|
Locale(String),
|
||||||
|
#[error("failed to convert attribute LastVolume, got {0}")]
|
||||||
|
LastVolume(String),
|
||||||
|
#[error("failed to convert attribute LastChapter, got {0}")]
|
||||||
|
LastChapter(String),
|
||||||
|
#[error("failed to convert attribute CreatedAtDateTime, got {0}")]
|
||||||
|
CreatedAtDateTime(String),
|
||||||
|
#[error("failed to convert attribute UpdatedAtDateTime, got {0}")]
|
||||||
|
UpdatedAtDateTime(String),
|
||||||
|
#[error("failed to convert attribute State, got {0}")]
|
||||||
|
State(String),
|
||||||
|
#[error("failed to convert attribute ContentRating, got {0}")]
|
||||||
|
ContentRating(String),
|
||||||
|
#[error("failed to convert attribute Status, got {0}")]
|
||||||
|
Status(String),
|
||||||
|
#[error("failed to convert attribute PublicationDemographic, got {0}")]
|
||||||
|
PublicationDemographic(String),
|
||||||
|
#[error("failed to convert attribute DataType, got {0}")]
|
||||||
|
DataType(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ChapterImageError {
|
||||||
|
#[error("chapter image result {0}")]
|
||||||
|
Result(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum IdQueryResponseError {
|
||||||
|
#[error("failed to convert id query response Result")]
|
||||||
|
Result,
|
||||||
|
#[error("failed to convert id query response Response")]
|
||||||
|
Response,
|
||||||
|
#[error("failed to convert id query response Data")]
|
||||||
|
Data(#[from] ResponseConversionError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum JsonError {
|
||||||
|
#[error("failed to parse json, msg: {0}")]
|
||||||
|
Message(String),
|
||||||
|
#[error("failed to parse json, unexpected end of file")]
|
||||||
|
Eof,
|
||||||
|
#[error("failed to parse json, syntax error")]
|
||||||
|
Syntax,
|
||||||
|
#[error("failed to parse json, expected boolean")]
|
||||||
|
ExpectedBoolean,
|
||||||
|
#[error("failed to parse json, expected integer")]
|
||||||
|
ExpectedInteger,
|
||||||
|
#[error("failed to parse json, expected string")]
|
||||||
|
ExpectedString,
|
||||||
|
#[error("failed to parse json, expected null")]
|
||||||
|
ExpectedNull,
|
||||||
|
#[error("failed to parse json, expected array")]
|
||||||
|
ExpectedArray,
|
||||||
|
#[error("failed to parse json, expected array comma")]
|
||||||
|
ExpectedArrayComma,
|
||||||
|
#[error("failed to parse json, expected array end")]
|
||||||
|
ExpectedArrayEnd,
|
||||||
|
#[error("failed to parse json, expected map")]
|
||||||
|
ExpectedMap,
|
||||||
|
#[error("failed to parse json, expected map colon")]
|
||||||
|
ExpectedMapColon,
|
||||||
|
#[error("failed to parse json, expected map comma")]
|
||||||
|
ExpectedMapComma,
|
||||||
|
#[error("failed to parse json, expected map end")]
|
||||||
|
ExpectedMapEnd,
|
||||||
|
#[error("failed to parse json, expected enum")]
|
||||||
|
ExpectedEnum,
|
||||||
|
#[error("failed to parse json, expected trailing characters")]
|
||||||
|
TrailingCharacters,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ChapterFeedError {
|
||||||
|
#[error("failed to parse json")]
|
||||||
|
Serde(#[from] JsonError),
|
||||||
|
#[error("failed to convert chapter feed")]
|
||||||
|
Conversion(#[from] ChapterFeedConversionError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ChapterFeedConversionError {
|
||||||
|
#[error("failed to convert chapter feed result, got {0}")]
|
||||||
|
Result(String),
|
||||||
|
#[error("failed to convert chapter feed response, got {0}")]
|
||||||
|
Response(String),
|
||||||
|
#[error("failed to convert chapter feed chapter")]
|
||||||
|
Chapter(#[from] ChapterConversionError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ChapterConversionError {
|
||||||
|
#[error("failed to convert chapter DataType, got {0}")]
|
||||||
|
DataType(String),
|
||||||
|
#[error("failed to convert chapter Id, got {0}")]
|
||||||
|
Id(String),
|
||||||
|
#[error("failed to convert chapter Relationship")]
|
||||||
|
Relationship(#[from] ChapterRelationshipError),
|
||||||
|
#[error("failed to convert chapter Attributes")]
|
||||||
|
Attributes(#[from] ChapterAttributeConversionError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ChapterRelationshipError {
|
||||||
|
#[error("failed to convert chapter relationship TypeData, got {0}")]
|
||||||
|
TypeData(String),
|
||||||
|
#[error("failed to convert chapter relationship Id, got {0}")]
|
||||||
|
Id(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum ChapterAttributeConversionError {
|
||||||
|
#[error("unable to convert chapter attribute Volume, got {0}")]
|
||||||
|
Volume(String),
|
||||||
|
#[error("unable to convert chapter attribute Chapter, got {0}")]
|
||||||
|
Chapter(String),
|
||||||
|
#[error("unable to convert chapter attribute CreatedAt, got {0}")]
|
||||||
|
CreatedAt(String),
|
||||||
|
#[error("unable to convert chapter attribute UpdatedAt, got {0}")]
|
||||||
|
UpdatedAt(String),
|
||||||
|
#[error("unable to convert chapter attribute PublishedAt, got {0}")]
|
||||||
|
PublishedAt(String),
|
||||||
|
#[error("unable to convert chapter attribute TranslatedLanguage, got {0}")]
|
||||||
|
TranslatedLanguage(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ChapterImagesError {
|
||||||
|
#[error("failed to deserialize chapter images")]
|
||||||
|
Image(#[from] ChapterImageError),
|
||||||
|
#[error("failed to deserialize chapter images")]
|
||||||
|
Content(#[from] ChapterImagesContentError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SearchResultError {
|
||||||
|
#[error("failed to deserialize json")]
|
||||||
|
Serde(#[from] JsonError),
|
||||||
|
#[error("failed to convert response to SearchResult")]
|
||||||
|
SearchResult(#[from] ResponseConversionError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum IdQueryResultError {
|
||||||
|
#[error("failed to deserialize json")]
|
||||||
|
Serde(#[from] JsonError),
|
||||||
|
#[error("failed to convert to IdQueryResult")]
|
||||||
|
IdQueryResult(#[from] IdQueryResponseError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum PublicationDemographicError {
|
||||||
|
InvalidValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum ParseLangErr {
|
||||||
|
#[error("found invalid lang")]
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ApiError {
|
||||||
|
pub id: String,
|
||||||
|
pub status: u32,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub context: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChapterImagesContentError {
|
||||||
|
pub result: String,
|
||||||
|
pub errors: Vec<ApiError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ApiError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
format!(
|
||||||
|
"Api Error:\nid: {}\nstatus: {}\ntitle: {}\ndetail: {}\ncontext{:?}",
|
||||||
|
self.id, self.status, self.title, self.detail, self.context
|
||||||
|
)
|
||||||
|
.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ChapterImagesContentError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
format!(
|
||||||
|
"chapter images content error:\nresult: {}\nerrors: \n{:#?}",
|
||||||
|
self.result, self.errors
|
||||||
|
)
|
||||||
|
.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
fn custom<T: fmt::Display>(msg: T) -> Self {
|
||||||
|
JsonError::Message(msg.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/language.rs
Normal file
96
src/language.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Wrapper for including extras from https://en.wikipedia.org/wiki/IETF_language_tag
|
||||||
|
|
||||||
|
use serde::{Serialize, Serializer};
|
||||||
|
use std::fmt;
|
||||||
|
use crate::error::ParseLangErr;
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
impl FromStr for Language {
|
||||||
|
type Err = ParseLangErr;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
if let Some(extra) = ExtraLang::from(s) {
|
||||||
|
return Ok(Language::Extra(extra));
|
||||||
|
}
|
||||||
|
if let Some(lang) = isolang::Language::from_639_1(s) {
|
||||||
|
return Ok(Language::Normal(lang));
|
||||||
|
}
|
||||||
|
Err(Self::Err::Invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
512
src/main.rs
512
src/main.rs
@@ -1,54 +1,175 @@
|
|||||||
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
|
// TODO: Only use a single struct for deserializing
|
||||||
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
|
use client::{MangaClient, MangaClientBuilder, Search, SearchOptionsBuilder};
|
||||||
use response_deserializer::{ChapterImages, SearchResult};
|
use language::Language;
|
||||||
|
use types::{Chapter, DataType, Id, Manga};
|
||||||
|
use select::Entry;
|
||||||
|
use std::{fs, future::Future, path::Path, pin::Pin, process};
|
||||||
|
use util::{ConfigSearch, CoverSize, SelectionType};
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
mod error;
|
||||||
|
mod metadata;
|
||||||
mod response_deserializer;
|
mod response_deserializer;
|
||||||
mod select;
|
mod select;
|
||||||
|
mod test;
|
||||||
mod util;
|
mod util;
|
||||||
|
mod language;
|
||||||
use response_deserializer::{Chapter, Id};
|
mod types;
|
||||||
use select::Entry;
|
|
||||||
|
|
||||||
const BASE: &str = "https://api.mangadex.org";
|
const BASE: &str = "https://api.mangadex.org";
|
||||||
type Client = ClientWithMiddleware;
|
const ONLY_METADATA: bool = true;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let config = util::args();
|
let config = util::args();
|
||||||
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
|
let manga_client = MangaClientBuilder::new()
|
||||||
let client = ClientBuilder::new(
|
.search(match config.search {
|
||||||
reqwest::Client::builder()
|
None => Search::Query(
|
||||||
.user_agent("manga-cli/version-0.1")
|
SearchOptionsBuilder::new()
|
||||||
.build()
|
.query(util::get_input("Enter search query: "))
|
||||||
.unwrap(),
|
.build(),
|
||||||
)
|
),
|
||||||
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
|
Some(ConfigSearch::Id(id)) => Search::Id(id),
|
||||||
.build();
|
Some(ConfigSearch::Query(query)) => Search::Query(
|
||||||
let client = &client;
|
SearchOptionsBuilder::new()
|
||||||
let filters = [
|
.query(query)
|
||||||
// ("publicationDemographic[]", "seinen"),
|
.result_limit(config.result_limit.unwrap_or(5))
|
||||||
//("status[]", "completed"),
|
.build(),
|
||||||
// ("contentRating[]", "suggestive"),
|
),
|
||||||
];
|
})
|
||||||
let limit = config.result_limit;
|
.selection_type(
|
||||||
|
config
|
||||||
|
.selection_type
|
||||||
|
.unwrap_or_else(util::ask_selection_type),
|
||||||
|
)
|
||||||
|
.selection_range(
|
||||||
|
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;
|
||||||
|
if search_result.is_empty() {
|
||||||
|
eprintln!("Found no search results");
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
let results = if let Some(query) = config.search {
|
let mut choice = 0;
|
||||||
match query {
|
if search_result.len() > 1 {
|
||||||
util::ConfigSearch::Query(query) => search(client, &query, &filters, limit).await,
|
choice = select_manga_from_search(&manga_client, &search_result).await;
|
||||||
util::ConfigSearch::Id(_) => todo!(),
|
}
|
||||||
|
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 {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("ERROR: {e:#?}");
|
||||||
|
process::exit(1);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
let input = util::get_input("Enter search query: ");
|
|
||||||
search(client, &input, &filters, limit).await
|
|
||||||
};
|
};
|
||||||
let cover_ex = match config.cover_size {
|
println!("Downloading {} chapters", chapters.len());
|
||||||
util::CoverSize::Full => "",
|
chapters.sort_by(|a, b| {
|
||||||
util::CoverSize::W256 => ".256.jpg",
|
a.attributes
|
||||||
util::CoverSize::W512 => ".512.jpg",
|
.chapter
|
||||||
|
.unwrap_or(-1.)
|
||||||
|
.partial_cmp(&b.attributes.chapter.unwrap_or(-1.))
|
||||||
|
.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!(
|
||||||
|
manga_client.selection.selection_type,
|
||||||
|
SelectionType::Volume(_)
|
||||||
|
);
|
||||||
|
let selected_chapters =
|
||||||
|
util::get_chapters_from_selection(manga_client.selection, &chapters);
|
||||||
|
util::download_selected_chapters(
|
||||||
|
&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 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 {
|
||||||
|
let cover_ex = match &client.search {
|
||||||
|
Search::Query(q) => match &q.cover {
|
||||||
|
Some(s) => match s {
|
||||||
|
CoverSize::Full => "",
|
||||||
|
CoverSize::W256 => ".256.jpg",
|
||||||
|
CoverSize::W512 => ".512.jpg",
|
||||||
|
},
|
||||||
|
_ => "",
|
||||||
|
},
|
||||||
|
_ => "",
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut entry_futures = Vec::new();
|
let mut entry_futures: Vec<Pin<Box<dyn Future<Output = Entry>>>> = 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());
|
let mut entry = Entry::new(result.attributes.title.en.clone());
|
||||||
if let Some(year) = result.attributes.year {
|
if let Some(year) = result.attributes.year {
|
||||||
entry.add_info("year", year);
|
entry.add_info("year", year);
|
||||||
@@ -66,293 +187,50 @@ async fn main() {
|
|||||||
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
|
|
||||||
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);
|
|
||||||
|
|
||||||
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(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 {
|
||||||
|
entry_futures.push(Box::pin(async move { entry }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let entries = futures::future::join_all(entry_futures).await;
|
let entries: Vec<Entry> = futures::future::join_all(entry_futures).await;
|
||||||
|
assert!(!entries.is_empty());
|
||||||
|
if entries.len() == 1 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
let choice = match select::select(&entries) {
|
let choice = match select::select(&entries) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let choice_id = &results.data[choice as usize].id;
|
choice
|
||||||
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 {
|
|
||||||
match selection_type {
|
|
||||||
util::ConfigSelectionType::Volume => loop {
|
|
||||||
let input = util::get_input("Choose volumes: ").replace(" ", "");
|
|
||||||
if let Some(selection) = util::choose_volumes(input.as_str()) {
|
|
||||||
break util::SelectionType::Volume(selection);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
util::ConfigSelectionType::Chapter => loop {
|
|
||||||
let input = util::get_input("Choose chapters: ").replace(" ", "");
|
|
||||||
if let Some(selection) = util::choose_chapters(input.as_str()) {
|
|
||||||
break util::SelectionType::Chapter(selection);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
'outer: loop {
|
|
||||||
match util::get_input("Select by volume or chapter? [v/c] : ").as_str() {
|
|
||||||
"v" | "volume" => loop {
|
|
||||||
let input = util::get_input("Choose volumes: ").replace(" ", "");
|
|
||||||
if let Some(selection) = util::choose_volumes(input.as_str()) {
|
|
||||||
break 'outer util::SelectionType::Volume(selection);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"c" | "chapter" => {
|
|
||||||
let input = util::get_input("Choose chapters: ").replace(" ", "");
|
|
||||||
if let Some(selection) = util::choose_chapters(input.as_str()) {
|
|
||||||
break 'outer util::SelectionType::Chapter(selection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
eprintln!("Invalid input");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut chapters = match get_chapters(client, choice_id).await {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("ERROR: {:#?}", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
chapters.sort_by(|a, b| {
|
|
||||||
a.attributes
|
|
||||||
.chapter
|
|
||||||
.unwrap_or(-1.)
|
|
||||||
.partial_cmp(&b.attributes.chapter.unwrap_or(-1.))
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
dbg!("got 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 mut chapters_image_data = Vec::new();
|
|
||||||
let mut i = 0;
|
|
||||||
for chapter in &selected_chapters {
|
|
||||||
// rate limits beware
|
|
||||||
let r = 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) => {
|
|
||||||
if e.result != "error" {
|
|
||||||
panic!("brotha, api gone wrong (wild)");
|
|
||||||
}
|
|
||||||
for error in e.errors {
|
|
||||||
if error.status == 429 {
|
|
||||||
println!("you sent too many requests");
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(20000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(800));
|
|
||||||
println!("downloaded chapter json image data: {i}");
|
|
||||||
i += 1;
|
|
||||||
chapters_image_data.push(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
let chapters = futures::future::join_all(
|
|
||||||
chapters_image_data
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, image_data)| {
|
|
||||||
download_chapter_images(client, image_data, selected_chapters[i])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
for (i, chapter) in chapters.iter().enumerate() {
|
|
||||||
match chapter {
|
|
||||||
Ok(chapter) => {
|
|
||||||
for (j, image) in chapter.iter().enumerate() {
|
|
||||||
let chapter_n = selected_chapters[i].attributes.chapter.unwrap();
|
|
||||||
let path = if let Some(v) = selected_chapters[i].attributes.volume {
|
|
||||||
format!(
|
|
||||||
"images/{}/volume_{:0>3}/chapter{:0>3}_image_{:0>3}.png",
|
|
||||||
results.data[choice as usize].attributes.title.en, v, chapter_n, j
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"images/{}/chapter{:0>3}_image_{:0>3}.png",
|
|
||||||
results.data[choice as usize].attributes.title.en, chapter_n, j
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let path = std::path::Path::new(&path);
|
|
||||||
if selected_chapters[i].attributes.volume.is_some()
|
|
||||||
&& !&path.parent().unwrap().exists()
|
|
||||||
{
|
|
||||||
if !path.parent().unwrap().parent().unwrap().exists() {
|
|
||||||
std::fs::create_dir(path.parent().unwrap().parent().unwrap()).unwrap();
|
|
||||||
}
|
|
||||||
std::fs::create_dir(path.parent().unwrap()).unwrap();
|
|
||||||
}
|
|
||||||
image.save(path).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
panic!("{e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let title = &results.data[choice as usize].attributes.title;
|
|
||||||
if create_volumes {
|
|
||||||
let mut volumes = Vec::new();
|
|
||||||
selected_chapters
|
|
||||||
.iter()
|
|
||||||
.filter_map(|m| m.attributes.volume)
|
|
||||||
.for_each(|v| {
|
|
||||||
if !volumes.contains(&v) {
|
|
||||||
volumes.push(v);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
for volume in volumes {
|
|
||||||
let path = format!("images/{}/volume_{:0>3}", title.en, volume);
|
|
||||||
let file_name = format!("{} - Volume {:0>3}.cbz", title.en, volume);
|
|
||||||
std::process::Command::new("/usr/bin/zip")
|
|
||||||
.args(["-j", "-r", &file_name, &path])
|
|
||||||
.spawn()
|
|
||||||
.unwrap()
|
|
||||||
.wait()
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn download_chapter_images(
|
|
||||||
client: &Client,
|
|
||||||
image_data: &ChapterImages,
|
|
||||||
chapter: &Chapter,
|
|
||||||
) -> Result<Vec<image::DynamicImage>, 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!(
|
|
||||||
"Downloaded 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<Vec<Chapter>, 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);
|
|
||||||
|
|
||||||
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);
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/metadata.rs
Normal file
60
src/metadata.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#![allow(unused)]
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use crate::types::{
|
||||||
|
Chapter, ContentRating, Manga, PublicationDemographic, State, Status, Titles,
|
||||||
|
};
|
||||||
|
use crate::language::Language;
|
||||||
|
use chrono::{DateTime, FixedOffset};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Metadata {
|
||||||
|
title: String,
|
||||||
|
original_language: Language,
|
||||||
|
last_volume: f32,
|
||||||
|
last_chapter: f32,
|
||||||
|
publication_demographic: Option<PublicationDemographic>,
|
||||||
|
status: Status,
|
||||||
|
year: Option<u32>,
|
||||||
|
content_rating: ContentRating,
|
||||||
|
state: State,
|
||||||
|
created_at: String,
|
||||||
|
updated_at: String,
|
||||||
|
chapters: Vec<ChapterMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct ChapterMetadata {
|
||||||
|
chapter: f32,
|
||||||
|
volume: f32,
|
||||||
|
title: Option<String>,
|
||||||
|
pages: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metadata {
|
||||||
|
pub fn new(manga: Manga, chapters: Vec<Chapter>) -> Self {
|
||||||
|
let attributes = manga.attributes;
|
||||||
|
Self {
|
||||||
|
title: attributes.title.en,
|
||||||
|
original_language: attributes.original_language,
|
||||||
|
last_volume: attributes.last_volume.unwrap(),
|
||||||
|
last_chapter: attributes.last_chapter.unwrap(),
|
||||||
|
publication_demographic: attributes.publication_demographic,
|
||||||
|
status: attributes.status,
|
||||||
|
year: attributes.year,
|
||||||
|
content_rating: attributes.content_rating,
|
||||||
|
state: attributes.state,
|
||||||
|
created_at: attributes.created_at.to_string(),
|
||||||
|
updated_at: attributes.updated_at.to_string(),
|
||||||
|
chapters: chapters
|
||||||
|
.iter()
|
||||||
|
.map(|m| ChapterMetadata {
|
||||||
|
chapter: m.attributes.chapter.unwrap(),
|
||||||
|
volume: m.attributes.volume.unwrap(),
|
||||||
|
title: m.attributes.title.clone(),
|
||||||
|
pages: m.attributes.pages,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,14 +6,13 @@ use crossterm::{
|
|||||||
terminal::{Clear, ClearType},
|
terminal::{Clear, ClearType},
|
||||||
QueueableCommand,
|
QueueableCommand,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::fmt::Display;
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
use std::{
|
use std::{
|
||||||
|
fmt::Display,
|
||||||
io,
|
io,
|
||||||
io::{Stdout, Write},
|
io::{Stdout, Write},
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
const CURRENT: char = '>';
|
const CURRENT: char = '>';
|
||||||
const NON_CURRENT: char = ' ';
|
const NON_CURRENT: char = ' ';
|
||||||
@@ -22,10 +21,10 @@ enum Action {
|
|||||||
MoveDown,
|
MoveDown,
|
||||||
MoveUp,
|
MoveUp,
|
||||||
Select,
|
Select,
|
||||||
Exit,
|
// Exit,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default, Debug)]
|
||||||
pub struct Entry {
|
pub struct Entry {
|
||||||
title: String,
|
title: String,
|
||||||
info: Vec<(String, String)>,
|
info: Vec<(String, String)>,
|
||||||
@@ -60,11 +59,11 @@ fn get_input() -> Option<Action> {
|
|||||||
KeyCode::Char('j') => Action::MoveDown,
|
KeyCode::Char('j') => Action::MoveDown,
|
||||||
KeyCode::Char('k') => Action::MoveUp,
|
KeyCode::Char('k') => Action::MoveUp,
|
||||||
KeyCode::Enter => Action::Select,
|
KeyCode::Enter => Action::Select,
|
||||||
KeyCode::Char('q') => Action::Exit,
|
// KeyCode::Char('q') => Action::Exit,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
}),
|
}),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("ERROR: {:#?}", e);
|
eprintln!("ERROR: {e:#?}");
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -72,7 +71,7 @@ fn get_input() -> Option<Action> {
|
|||||||
}
|
}
|
||||||
Ok(false) => None,
|
Ok(false) => None,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("ERROR: {:#?}", e);
|
eprintln!("ERROR: {e:#?}");
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,15 +80,39 @@ fn get_input() -> Option<Action> {
|
|||||||
fn exit() -> ! {
|
fn exit() -> ! {
|
||||||
io::stdout().queue(Show).unwrap().flush().unwrap();
|
io::stdout().queue(Show).unwrap().flush().unwrap();
|
||||||
terminal::disable_raw_mode().unwrap();
|
terminal::disable_raw_mode().unwrap();
|
||||||
std::process::exit(1);
|
std::process::exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn select(entries: &[Entry]) -> Result<u16, std::io::Error> {
|
#[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<u16, SelectionError> {
|
||||||
|
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<u16, std::io::Error> {
|
||||||
let (width, height) = terminal::size()?;
|
let (width, height) = terminal::size()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
stdout.queue(Hide)?;
|
stdout.queue(Hide)?;
|
||||||
let mut selected: u16 = 0;
|
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;
|
let mut should_render = true;
|
||||||
loop {
|
loop {
|
||||||
if should_render {
|
if should_render {
|
||||||
@@ -123,14 +146,14 @@ pub fn select(entries: &[Entry]) -> Result<u16, std::io::Error> {
|
|||||||
.flush()?;
|
.flush()?;
|
||||||
return Ok(selected);
|
return Ok(selected);
|
||||||
}
|
}
|
||||||
Action::Exit => {
|
// Action::Exit => {
|
||||||
stdout
|
// stdout
|
||||||
.queue(MoveTo(0, 0))?
|
// .queue(MoveTo(0, 0))?
|
||||||
.queue(Clear(ClearType::All))?
|
// .queue(Clear(ClearType::All))?
|
||||||
.queue(Show)?
|
// .queue(Show)?
|
||||||
.flush()?;
|
// .flush()?;
|
||||||
exit();
|
// exit();
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
stdout.queue(MoveTo(0, selected))?;
|
stdout.queue(MoveTo(0, selected))?;
|
||||||
}
|
}
|
||||||
|
|||||||
4
src/test.rs
Normal file
4
src/test.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//
|
||||||
|
}
|
||||||
292
src/types.rs
Normal file
292
src/types.rs
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
#![allow(unused)]
|
||||||
|
use crate::language::Language;
|
||||||
|
use crate::response_deserializer::opt_string_to_opt_f32;
|
||||||
|
use chrono::{DateTime, FixedOffset};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
|
pub struct Id(pub String);
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ResponseResult {
|
||||||
|
Ok,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Status {
|
||||||
|
Completed,
|
||||||
|
Ongoing,
|
||||||
|
Hiatus,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ContentRating {
|
||||||
|
Safe,
|
||||||
|
Suggestive,
|
||||||
|
Erotica,
|
||||||
|
Pornographic,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub enum PublicationDemographic {
|
||||||
|
Shounen,
|
||||||
|
Shoujo,
|
||||||
|
Seinen,
|
||||||
|
Josei,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum State {
|
||||||
|
Published,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Response {
|
||||||
|
Collection,
|
||||||
|
Entity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DataType {
|
||||||
|
Manga,
|
||||||
|
Chapter,
|
||||||
|
CoverArt,
|
||||||
|
Author,
|
||||||
|
Artist,
|
||||||
|
ScanlationGroup,
|
||||||
|
Tag,
|
||||||
|
User,
|
||||||
|
CustomList,
|
||||||
|
Creator,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ChapterImages {
|
||||||
|
pub result: ResponseResult,
|
||||||
|
pub base_url: String,
|
||||||
|
pub chapter: ChapterImageData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ChapterImageData {
|
||||||
|
pub hash: String,
|
||||||
|
pub data: Vec<String>,
|
||||||
|
pub data_saver: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ChapterFeed {
|
||||||
|
pub result: ResponseResult,
|
||||||
|
pub response: Response,
|
||||||
|
pub data: Vec<Chapter>,
|
||||||
|
pub limit: u32,
|
||||||
|
pub offset: u32,
|
||||||
|
pub total: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Chapter {
|
||||||
|
pub id: Id,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub data_type: DataType,
|
||||||
|
pub attributes: ChapterAttributes,
|
||||||
|
pub relationships: Vec<Relationship>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ChapterAttributes {
|
||||||
|
#[serde(deserialize_with = "opt_string_to_opt_f32")]
|
||||||
|
pub volume: Option<f32>,
|
||||||
|
#[serde(deserialize_with = "opt_string_to_opt_f32")]
|
||||||
|
pub chapter: Option<f32>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub translated_language: Language,
|
||||||
|
pub external_url: Option<String>,
|
||||||
|
pub is_unavailable: bool,
|
||||||
|
#[serde(rename = "publishAt")]
|
||||||
|
pub published_at: DateTime<FixedOffset>,
|
||||||
|
pub readable_at: DateTime<FixedOffset>,
|
||||||
|
pub created_at: DateTime<FixedOffset>,
|
||||||
|
pub updated_at: DateTime<FixedOffset>,
|
||||||
|
pub pages: u32,
|
||||||
|
pub version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SearchResult {
|
||||||
|
pub result: ResponseResult,
|
||||||
|
pub response: Response,
|
||||||
|
pub data: Vec<Manga>,
|
||||||
|
pub limit: u32,
|
||||||
|
pub offset: u32,
|
||||||
|
pub total: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct IdQueryResult {
|
||||||
|
pub result: ResponseResult,
|
||||||
|
pub response: Response,
|
||||||
|
pub data: Manga,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Manga {
|
||||||
|
pub id: Id,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub data_type: DataType,
|
||||||
|
pub attributes: MangaAttributes,
|
||||||
|
pub relationships: Vec<Relationship>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MangaAttributes {
|
||||||
|
pub title: Titles,
|
||||||
|
pub alt_titles: Vec<AltTitles>,
|
||||||
|
pub description: Description,
|
||||||
|
pub is_locked: bool,
|
||||||
|
pub links: Option<Links>,
|
||||||
|
pub official_links: Option<Vec<String>>,
|
||||||
|
pub original_language: Language,
|
||||||
|
#[serde(deserialize_with = "opt_string_to_opt_f32")]
|
||||||
|
pub last_volume: Option<f32>,
|
||||||
|
#[serde(deserialize_with = "opt_string_to_opt_f32")]
|
||||||
|
pub last_chapter: Option<f32>,
|
||||||
|
pub publication_demographic: Option<PublicationDemographic>,
|
||||||
|
pub status: Status,
|
||||||
|
pub year: Option<u32>,
|
||||||
|
pub content_rating: ContentRating,
|
||||||
|
pub tags: Vec<Tag>,
|
||||||
|
pub state: State,
|
||||||
|
pub chapter_numbers_reset_on_new_volume: bool,
|
||||||
|
pub created_at: DateTime<FixedOffset>,
|
||||||
|
pub updated_at: DateTime<FixedOffset>,
|
||||||
|
pub version: u32,
|
||||||
|
pub available_translated_languages: Vec<Option<Language>>,
|
||||||
|
pub latest_uploaded_chapter: Option<Id>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CoverAttributes {
|
||||||
|
pub description: String,
|
||||||
|
#[serde(deserialize_with = "opt_string_to_opt_f32")]
|
||||||
|
pub volume: Option<f32>,
|
||||||
|
pub file_name: Id,
|
||||||
|
pub locale: Language,
|
||||||
|
pub created_at: DateTime<FixedOffset>,
|
||||||
|
pub updated_at: DateTime<FixedOffset>,
|
||||||
|
pub version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Tag {
|
||||||
|
pub id: Id,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub data_type: DataType,
|
||||||
|
pub attributes: TagAttributes,
|
||||||
|
pub relationships: Vec<Relationship>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Relationship {
|
||||||
|
pub id: Id,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub data_type: DataType,
|
||||||
|
pub related: Option<String>,
|
||||||
|
pub attributes: Option<CoverAttributes>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct TagAttributes {
|
||||||
|
pub name: TagName,
|
||||||
|
pub description: Description,
|
||||||
|
pub group: String,
|
||||||
|
pub version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct TagName {
|
||||||
|
pub en: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Default)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Links {
|
||||||
|
al: Option<String>,
|
||||||
|
ap: Option<String>,
|
||||||
|
bw: Option<String>,
|
||||||
|
kt: Option<String>,
|
||||||
|
mu: Option<String>,
|
||||||
|
amz: Option<String>,
|
||||||
|
cdj: Option<String>,
|
||||||
|
ebj: Option<String>,
|
||||||
|
mal: Option<String>,
|
||||||
|
engtl: Option<String>,
|
||||||
|
raw: Option<String>,
|
||||||
|
nu: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
// #[serde(deny_unknown_fields)]
|
||||||
|
// TODO: Fill
|
||||||
|
pub struct Description {
|
||||||
|
pub en: Option<String>,
|
||||||
|
pub ru: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
// #[serde(deny_unknown_fields)]
|
||||||
|
// TODO: Fill
|
||||||
|
pub struct AltTitles {
|
||||||
|
en: Option<String>,
|
||||||
|
ja: Option<String>,
|
||||||
|
ru: Option<String>,
|
||||||
|
de: Option<String>,
|
||||||
|
lt: Option<String>,
|
||||||
|
ar: Option<String>,
|
||||||
|
zh: Option<String>,
|
||||||
|
hi: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Titles {
|
||||||
|
pub en: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for State {
|
||||||
|
type Error = ();
|
||||||
|
|
||||||
|
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||||
|
Ok(match s {
|
||||||
|
"published" => Self::Published,
|
||||||
|
_ => return Err(()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
458
src/util.rs
458
src/util.rs
@@ -1,13 +1,33 @@
|
|||||||
use crate::Chapter;
|
use crate::{
|
||||||
|
response_deserializer,
|
||||||
|
types::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::{io, io::Write};
|
use std::{fs::File, io, io::Write, path::Path};
|
||||||
|
use zip::{
|
||||||
|
write::{SimpleFileOptions, ZipWriter},
|
||||||
|
CompressionMethod,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Client = reqwest_middleware::ClientWithMiddleware;
|
||||||
|
|
||||||
|
const CONCURRENT_REQUESTS: usize = 20;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct Selection {
|
pub struct Selection {
|
||||||
pub selection_type: SelectionType,
|
pub selection_type: SelectionType,
|
||||||
pub bonus: bool, // Allows including or excluding bonus chapters and volumes
|
pub bonus: bool, // Allows including or excluding bonus chapters and volumes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ImageItem {
|
||||||
|
url: String,
|
||||||
|
chapter: Option<f32>,
|
||||||
|
volume: Option<f32>,
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
impl Selection {
|
impl Selection {
|
||||||
pub fn new(selection_type: SelectionType, bonus: bool) -> Self {
|
pub fn new(selection_type: SelectionType, bonus: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -17,7 +37,7 @@ impl Selection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default, Debug)]
|
||||||
pub enum CoverSize {
|
pub enum CoverSize {
|
||||||
#[default]
|
#[default]
|
||||||
Full,
|
Full,
|
||||||
@@ -25,48 +45,48 @@ pub enum CoverSize {
|
|||||||
W512,
|
W512,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum SelectionType {
|
#[derive(Default, Debug)]
|
||||||
Volume(VolumeSelection),
|
pub enum SelectionRange {
|
||||||
Chapter(ChapterSelection),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum VolumeSelection {
|
|
||||||
All,
|
|
||||||
Range(u32, u32),
|
|
||||||
List(Vec<u32>),
|
|
||||||
Single(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum ChapterSelection {
|
|
||||||
Range(f32, f32),
|
Range(f32, f32),
|
||||||
List(Vec<f32>),
|
List(Vec<f32>),
|
||||||
|
#[default]
|
||||||
All,
|
All,
|
||||||
Single(f32),
|
Single(f32),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SelectionType {
|
||||||
|
Volume(SelectionRange),
|
||||||
|
Chapter(SelectionRange),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub bonus: Option<bool>,
|
pub bonus: Option<bool>,
|
||||||
pub result_limit: u32,
|
pub result_limit: Option<u32>,
|
||||||
pub cover_size: CoverSize,
|
pub cover_size: CoverSize,
|
||||||
pub selection_type: Option<ConfigSelectionType>,
|
pub selection_type: Option<ConfigSelectionType>,
|
||||||
pub selection_range: Option<String>,
|
pub selection_range: Option<SelectionRange>,
|
||||||
pub search: Option<ConfigSearch>,
|
pub search: Option<ConfigSearch>,
|
||||||
|
pub cover: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub enum ConfigSearch {
|
pub enum ConfigSearch {
|
||||||
Query(String),
|
Query(String),
|
||||||
Id(crate::Id),
|
Id(Id),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn filter_bonus(bonus: bool, volume: Option<u32>, chapter: Option<f32>) -> bool {
|
fn filter_bonus(bonus: bool, volume: Option<f32>, chapter: Option<f32>) -> bool {
|
||||||
if bonus {
|
if bonus {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if let Some(volume) = volume {
|
if let Some(volume) = volume {
|
||||||
if volume > 0 {
|
if let Some(chapter) = chapter {
|
||||||
if let Some(chapter) = chapter {
|
return chapter.round() == chapter
|
||||||
return chapter.round() == chapter;
|
&& volume.round() == volume
|
||||||
}
|
&& chapter >= 1.
|
||||||
|
&& volume >= 1.;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
@@ -76,11 +96,11 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
|
|||||||
let bonus = selection.bonus;
|
let bonus = selection.bonus;
|
||||||
match selection.selection_type {
|
match selection.selection_type {
|
||||||
SelectionType::Volume(v) => match v {
|
SelectionType::Volume(v) => match v {
|
||||||
VolumeSelection::All => chapters
|
SelectionRange::All => chapters
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| filter_bonus(bonus, m.attributes.volume, m.attributes.chapter))
|
.filter(|m| filter_bonus(bonus, m.attributes.volume, m.attributes.chapter))
|
||||||
.collect(),
|
.collect(),
|
||||||
VolumeSelection::Single(a) => chapters
|
SelectionRange::Single(a) => chapters
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| {
|
.filter(|m| {
|
||||||
if let Some(n) = m.attributes.volume {
|
if let Some(n) = m.attributes.volume {
|
||||||
@@ -90,7 +110,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
|
|||||||
false
|
false
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
VolumeSelection::List(list) => chapters
|
SelectionRange::List(list) => chapters
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| {
|
.filter(|m| {
|
||||||
if let Some(v) = m.attributes.volume {
|
if let Some(v) = m.attributes.volume {
|
||||||
@@ -100,7 +120,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
|
|||||||
false
|
false
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
VolumeSelection::Range(a, b) => chapters
|
SelectionRange::Range(a, b) => chapters
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| {
|
.filter(|m| {
|
||||||
if let Some(v) = m.attributes.volume {
|
if let Some(v) = m.attributes.volume {
|
||||||
@@ -113,11 +133,11 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
|
|||||||
.collect(),
|
.collect(),
|
||||||
},
|
},
|
||||||
SelectionType::Chapter(c) => match c {
|
SelectionType::Chapter(c) => match c {
|
||||||
ChapterSelection::All => chapters
|
SelectionRange::All => chapters
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| filter_bonus(bonus, m.attributes.volume, m.attributes.chapter))
|
.filter(|m| filter_bonus(bonus, m.attributes.volume, m.attributes.chapter))
|
||||||
.collect(),
|
.collect(),
|
||||||
ChapterSelection::Single(a) => chapters
|
SelectionRange::Single(a) => chapters
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| {
|
.filter(|m| {
|
||||||
if let Some(c) = m.attributes.chapter {
|
if let Some(c) = m.attributes.chapter {
|
||||||
@@ -127,7 +147,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
|
|||||||
false
|
false
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
ChapterSelection::List(list) => chapters
|
SelectionRange::List(list) => chapters
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| {
|
.filter(|m| {
|
||||||
if let Some(n) = m.attributes.chapter {
|
if let Some(n) = m.attributes.chapter {
|
||||||
@@ -137,7 +157,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
|
|||||||
false
|
false
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
ChapterSelection::Range(a, b) => chapters
|
SelectionRange::Range(a, b) => chapters
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| {
|
.filter(|m| {
|
||||||
if let Some(c) = m.attributes.chapter {
|
if let Some(c) = m.attributes.chapter {
|
||||||
@@ -152,54 +172,16 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn choose_volumes(input: &str) -> Option<VolumeSelection> {
|
pub fn ask_selection_range() -> SelectionRange {
|
||||||
if let Some(x) = input.find("..") {
|
loop {
|
||||||
match (input[0..x].parse(), input[x + 2..].parse::<u32>()) {
|
match parse_selection_range(&get_input("Choose chapters/volumes: ")) {
|
||||||
(Ok(a), Ok(b)) => {
|
Some(v) => return v,
|
||||||
if a > b {
|
None => eprintln!("Invalid selection"),
|
||||||
eprintln!("Invalid range: a > b");
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(VolumeSelection::Range(a, b))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
eprintln!("Invalid range");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if let Some(x) = input.find(":") {
|
|
||||||
match (input[0..x].parse(), input[x + 2..].parse::<u32>()) {
|
|
||||||
(Ok(a), Ok(b)) => Some(VolumeSelection::Range(a, b)),
|
|
||||||
_ => {
|
|
||||||
eprintln!("Invalid range");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if input.contains(",") {
|
|
||||||
match input
|
|
||||||
.split(",")
|
|
||||||
.map(|m| m.parse::<u32>())
|
|
||||||
.collect::<Result<Vec<u32>, _>>()
|
|
||||||
{
|
|
||||||
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<ChapterSelection> {
|
fn parse_selection_range(input: &str) -> Option<SelectionRange> {
|
||||||
if let Some(x) = input.find("..") {
|
if let Some(x) = input.find("..") {
|
||||||
match (input[0..x].parse(), input[x + 2..].parse()) {
|
match (input[0..x].parse(), input[x + 2..].parse()) {
|
||||||
// Inclusive range
|
// Inclusive range
|
||||||
@@ -208,7 +190,7 @@ pub fn choose_chapters(input: &str) -> Option<ChapterSelection> {
|
|||||||
eprintln!("Invalid range: a > b");
|
eprintln!("Invalid range: a > b");
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(ChapterSelection::Range(a, b))
|
Some(SelectionRange::Range(a, b))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -223,7 +205,7 @@ pub fn choose_chapters(input: &str) -> Option<ChapterSelection> {
|
|||||||
.map(|m| match m.parse() {
|
.map(|m| match m.parse() {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Invalid input: {:#?}", e);
|
eprintln!("Invalid input: {e:#?}");
|
||||||
invalid = true;
|
invalid = true;
|
||||||
0.
|
0.
|
||||||
}
|
}
|
||||||
@@ -232,21 +214,33 @@ pub fn choose_chapters(input: &str) -> Option<ChapterSelection> {
|
|||||||
if invalid {
|
if invalid {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(ChapterSelection::List(list))
|
Some(SelectionRange::List(list))
|
||||||
}
|
}
|
||||||
} else if input == "all" {
|
} else if input == "all" {
|
||||||
Some(ChapterSelection::All)
|
Some(SelectionRange::All)
|
||||||
|
} else if let Ok(n) = input.parse() {
|
||||||
|
Some(SelectionRange::Single(n))
|
||||||
} else {
|
} else {
|
||||||
if let Ok(n) = input.parse() {
|
|
||||||
return Some(ChapterSelection::Single(n));
|
|
||||||
}
|
|
||||||
eprintln!("Invalid input");
|
eprintln!("Invalid input");
|
||||||
None
|
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 {
|
pub fn get_input(msg: &str) -> String {
|
||||||
print!("{}", msg);
|
print!("{msg}");
|
||||||
io::stdout().flush().expect("failed to flush stdout");
|
io::stdout().flush().expect("failed to flush stdout");
|
||||||
|
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
@@ -275,11 +269,7 @@ pub fn convert_to_sixel(data: &[u8]) -> String {
|
|||||||
pixels.push(m.0[0]);
|
pixels.push(m.0[0]);
|
||||||
pixels.push(m.0[0]);
|
pixels.push(m.0[0]);
|
||||||
}),
|
}),
|
||||||
DynamicImage::ImageLumaA8(_) => println!("Found lumaa8 image"),
|
_ => unimplemented!(),
|
||||||
DynamicImage::ImageRgb16(_) => println!("Found rgb16 image"),
|
|
||||||
DynamicImage::ImageLuma16(_) => println!("Found luma16 image"),
|
|
||||||
DynamicImage::ImageRgba16(_) => println!("Found rgba16 image"),
|
|
||||||
_ => panic!(),
|
|
||||||
}
|
}
|
||||||
icy_sixel::sixel_string(
|
icy_sixel::sixel_string(
|
||||||
&pixels,
|
&pixels,
|
||||||
@@ -289,13 +279,15 @@ pub fn convert_to_sixel(data: &[u8]) -> String {
|
|||||||
DiffusionMethod::Auto,
|
DiffusionMethod::Auto,
|
||||||
MethodForLargest::Auto,
|
MethodForLargest::Auto,
|
||||||
MethodForRep::Auto,
|
MethodForRep::Auto,
|
||||||
Quality::HIGH,
|
Quality::AUTO,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
pub enum ConfigSelectionType {
|
pub enum ConfigSelectionType {
|
||||||
Volume,
|
Volume,
|
||||||
|
#[default]
|
||||||
Chapter,
|
Chapter,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,14 +297,224 @@ impl Config {
|
|||||||
bonus: None,
|
bonus: None,
|
||||||
search: None,
|
search: None,
|
||||||
cover_size: CoverSize::default(),
|
cover_size: CoverSize::default(),
|
||||||
result_limit: 5,
|
result_limit: None,
|
||||||
selection_type: None,
|
selection_type: None,
|
||||||
selection_range: None,
|
selection_range: None,
|
||||||
|
cover: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::Id;
|
pub async fn download_selected_chapters(
|
||||||
|
client: &Client,
|
||||||
|
selected_chapters: &Vec<&Chapter>,
|
||||||
|
title: &str,
|
||||||
|
create_volumes: bool,
|
||||||
|
) {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for (i, chapter) in selected_chapters.iter().enumerate() {
|
||||||
|
let json = client
|
||||||
|
.get(format!("{BASE}/at-home/server/{}", chapter.id))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let chapter_image_data = response_deserializer::deserialize_chapter_images(&json);
|
||||||
|
println!(
|
||||||
|
"\x1b[1A\x1b[2Kdownloaded chapter json image data: [{i}/{}]",
|
||||||
|
selected_chapters.len()
|
||||||
|
);
|
||||||
|
for n in construct_image_items(&chapter_image_data, chapter) {
|
||||||
|
items.push(async move {
|
||||||
|
let data = client
|
||||||
|
.clone()
|
||||||
|
.get(&n.url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!(
|
||||||
|
"\x1b[1A\x1b[2KDownloaded volume: {:?}, chapter: {:?}, title: {:?}, [{}/{}]",
|
||||||
|
chapter.attributes.volume,
|
||||||
|
chapter.attributes.chapter,
|
||||||
|
chapter.attributes.title,
|
||||||
|
n.index,
|
||||||
|
chapter.attributes.pages
|
||||||
|
);
|
||||||
|
(n, image::load_from_memory(&data).unwrap())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
while items.len() >= CONCURRENT_REQUESTS {
|
||||||
|
let mut list = Vec::with_capacity(CONCURRENT_REQUESTS);
|
||||||
|
for _ in 0..CONCURRENT_REQUESTS {
|
||||||
|
list.push(items.pop().unwrap());
|
||||||
|
}
|
||||||
|
let complete = futures::future::join_all(list).await;
|
||||||
|
save_images(complete, title, create_volumes);
|
||||||
|
}
|
||||||
|
if i == selected_chapters.len() - 1 {
|
||||||
|
let mut list = Vec::with_capacity(items.len());
|
||||||
|
for _ in 0..items.len() {
|
||||||
|
list.push(items.pop().unwrap());
|
||||||
|
}
|
||||||
|
let complete = futures::future::join_all(list).await;
|
||||||
|
save_images(complete, title, create_volumes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_images(
|
||||||
|
image_data: Vec<(ImageItem, image::DynamicImage)>,
|
||||||
|
title: &str,
|
||||||
|
create_volumes: bool,
|
||||||
|
) {
|
||||||
|
for (n, image) in image_data {
|
||||||
|
let chapter_text = if let Some(n) = n.chapter {
|
||||||
|
n.to_string()
|
||||||
|
} else {
|
||||||
|
String::from("_none")
|
||||||
|
};
|
||||||
|
let chapter_path = format!(
|
||||||
|
"images/{}/chapter_{:0>4}_page_{:0>4}.png",
|
||||||
|
title, chapter_text, n.index
|
||||||
|
);
|
||||||
|
let path = if let Some(v) = n.volume {
|
||||||
|
if create_volumes {
|
||||||
|
format!(
|
||||||
|
"images/{}/volume_{:0>4}/chapter_{:0>4}_page_{:0>4}.png",
|
||||||
|
title, v, chapter_text, n.index
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
chapter_path
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chapter_path
|
||||||
|
};
|
||||||
|
let path = std::path::Path::new(&path);
|
||||||
|
if n.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_volumes_or_chapters(
|
||||||
|
selected_chapters: &Vec<&Chapter>,
|
||||||
|
create_volumes: bool,
|
||||||
|
title: &str,
|
||||||
|
) {
|
||||||
|
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>4}", title, volume);
|
||||||
|
let image_paths = Path::new(&image_paths);
|
||||||
|
|
||||||
|
let zip_file_path = format!("{} - Volume {:0>4}.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>4}.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;
|
||||||
|
}
|
||||||
|
if entry.file_name().to_str().unwrap()[..18]
|
||||||
|
== *format!("chapter_{:0>4}_page_", chapter).as_str()
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn construct_image_items(image_data: &ChapterImages, chapter: &Chapter) -> Vec<ImageItem> {
|
||||||
|
image_data
|
||||||
|
.chapter
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, m)| ImageItem {
|
||||||
|
url: format!(
|
||||||
|
"{}/data/{}/{m}",
|
||||||
|
image_data.base_url, image_data.chapter.hash
|
||||||
|
),
|
||||||
|
chapter: chapter.attributes.chapter,
|
||||||
|
volume: chapter.attributes.volume,
|
||||||
|
index: i + 1,
|
||||||
|
})
|
||||||
|
.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 {
|
pub fn args() -> Config {
|
||||||
let mut args = std::env::args().skip(1);
|
let mut args = std::env::args().skip(1);
|
||||||
@@ -320,11 +522,28 @@ pub fn args() -> Config {
|
|||||||
let mut config = Config::new();
|
let mut config = Config::new();
|
||||||
while args.len() != 0 {
|
while args.len() != 0 {
|
||||||
match args.next().unwrap().as_ref() {
|
match args.next().unwrap().as_ref() {
|
||||||
|
"--selection-range" => {
|
||||||
|
if config.selection_range.is_some() {
|
||||||
|
eprintln!("Value selection range already set.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
config.selection_range = match args.next() {
|
||||||
|
Some(selection_range) => parse_selection_range(&selection_range),
|
||||||
|
None => {
|
||||||
|
eprintln!("Missing value for selection range, type: String");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
"-t" | "--selection-type" => {
|
"-t" | "--selection-type" => {
|
||||||
|
if config.selection_type.is_some() {
|
||||||
|
eprintln!("Value selection type already set.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
config.selection_type = match args.next() {
|
config.selection_type = match args.next() {
|
||||||
Some(selection_type) => Some(match selection_type.as_str() {
|
Some(selection_type) => Some(match selection_type.as_str() {
|
||||||
"volume" => ConfigSelectionType::Volume,
|
"v" | "volume" => ConfigSelectionType::Volume,
|
||||||
"chapter" => ConfigSelectionType::Chapter,
|
"c" | "chapter" => ConfigSelectionType::Chapter,
|
||||||
_ => {
|
_ => {
|
||||||
eprintln!("Invalid value for selection type, type: SelectionType");
|
eprintln!("Invalid value for selection type, type: SelectionType");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
@@ -337,6 +556,10 @@ pub fn args() -> Config {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
"-i" | "--id" => {
|
"-i" | "--id" => {
|
||||||
|
if config.search.is_some() {
|
||||||
|
eprintln!("Conflicting arguments for search.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
if let Some(id) = args.next() {
|
if let Some(id) = args.next() {
|
||||||
config.search = Some(ConfigSearch::Id(Id(id)));
|
config.search = Some(ConfigSearch::Id(Id(id)));
|
||||||
} else {
|
} else {
|
||||||
@@ -345,6 +568,10 @@ pub fn args() -> Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"-s" | "--search" => {
|
"-s" | "--search" => {
|
||||||
|
if config.search.is_some() {
|
||||||
|
eprintln!("Conflicting arguments for search.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
if let Some(query) = args.next() {
|
if let Some(query) = args.next() {
|
||||||
config.search = Some(ConfigSearch::Query(query));
|
config.search = Some(ConfigSearch::Query(query));
|
||||||
} else {
|
} else {
|
||||||
@@ -353,6 +580,10 @@ pub fn args() -> Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"-b" | "--bonus" => {
|
"-b" | "--bonus" => {
|
||||||
|
if config.bonus.is_some() {
|
||||||
|
eprintln!("Value bonus already set.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
config.bonus = match args.next() {
|
config.bonus = match args.next() {
|
||||||
Some(a) => Some(match a.as_str() {
|
Some(a) => Some(match a.as_str() {
|
||||||
"true" => true,
|
"true" => true,
|
||||||
@@ -371,9 +602,9 @@ pub fn args() -> Config {
|
|||||||
"-r" | "--result-limit" => {
|
"-r" | "--result-limit" => {
|
||||||
config.result_limit = match args.next() {
|
config.result_limit = match args.next() {
|
||||||
Some(a) => match a.parse() {
|
Some(a) => match a.parse() {
|
||||||
Ok(v) => v,
|
Ok(v) => Some(v),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to parse value for result-limit: {:?}, type: u32", e);
|
eprintln!("Failed to parse value for result-limit: {e:?}, type: u32");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -400,6 +631,22 @@ pub fn args() -> Config {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
"--cover" => {
|
||||||
|
config.cover = match args.next() {
|
||||||
|
Some(s) => Some(match s.as_str() {
|
||||||
|
"true" => true,
|
||||||
|
"false" => false,
|
||||||
|
s => {
|
||||||
|
eprintln!("Invalid value for cover size, valid values: [\"256\", \"512\", \"full\"], found: {s}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
None => {
|
||||||
|
eprintln!("Missing value for cover size, valid values: [\"256\", \"512\", \"full\"]");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
s => {
|
s => {
|
||||||
eprintln!("Found invalid argument: {s}");
|
eprintln!("Found invalid argument: {s}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
@@ -408,3 +655,22 @@ pub fn args() -> Config {
|
|||||||
}
|
}
|
||||||
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user