Compare commits

..

10 Commits

11 changed files with 1767 additions and 1176 deletions

3
.gitignore vendored
View File

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

View File

@@ -4,15 +4,18 @@ version = "0.1.0"
edition = "2021"
[dependencies]
chrono = "0.4.38"
crossterm = "0.28.1"
futures = "0.3.30"
icy_sixel = "0.1.2"
chrono = { version = "0.4.38", default-features = false }
crossterm = { version = "0.29", default-features = false, features = ["events"] }
futures = { version = "0.3.30", default-features = false }
icy_sixel = { version = "0.1.2", default-features = false }
image = { version = "0.25.2", default-features = false, features = ["jpeg", "png"] }
reqwest = "0.12.5"
reqwest-middleware = "0.3.2"
reqwest-retry = "0.6.0"
serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.121"
isolang = { version = "2.4.0", features = ["serde"] }
reqwest = { version = "0.12.5", default-features = false, features = ["default-tls"] }
reqwest-middleware = { version = "0.4", default-features = false }
reqwest-retry = { version = "0.7", default-features = false }
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"] }
zip = "4.0.0"
zip = { version = "4.0.0", default-features = false, features = ["deflate"] }

441
src/client.rs Normal file
View File

@@ -0,0 +1,441 @@
use crate::{
response_deserializer,
response_deserializer::{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(&params)
.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(&params)
.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(&params)
.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)));
}
}

228
src/error.rs Normal file
View File

@@ -0,0 +1,228 @@
#![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(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())
}
}

89
src/language.rs Normal file
View File

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

View File

@@ -1,54 +1,174 @@
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use response_deserializer::{ChapterImages, SearchResult};
// TODO: Only use a single struct for deserializing
use client::{MangaClient, MangaClientBuilder, Search, SearchOptionsBuilder};
use language::Language;
use response_deserializer::{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 select;
mod test;
mod util;
use response_deserializer::{Chapter, Id};
use select::Entry;
mod language;
const BASE: &str = "https://api.mangadex.org";
type Client = ClientWithMiddleware;
const ONLY_METADATA: bool = true;
#[tokio::main]
async fn main() {
let config = util::args();
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
let client = ClientBuilder::new(
reqwest::Client::builder()
.user_agent("manga-cli/version-0.1")
.build()
let manga_client = MangaClientBuilder::new()
.search(match config.search {
None => Search::Query(
SearchOptionsBuilder::new()
.query(util::get_input("Enter search query: "))
.build(),
),
Some(ConfigSearch::Id(id)) => Search::Id(id),
Some(ConfigSearch::Query(query)) => Search::Query(
SearchOptionsBuilder::new()
.query(query)
.result_limit(config.result_limit.unwrap_or(5))
.build(),
),
})
.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 mut choice = 0;
if search_result.len() > 1 {
choice = select_manga_from_search(&manga_client, &search_result).await;
}
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(),
)
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build();
let client = &client;
let filters = [
// ("publicationDemographic[]", "seinen"),
//("status[]", "completed"),
// ("contentRating[]", "suggestive"),
];
let limit = config.result_limit;
let results = if let Some(query) = config.search {
match query {
util::ConfigSearch::Query(query) => search(client, &query, &filters, limit).await,
util::ConfigSearch::Id(_) => todo!(),
.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 {
util::CoverSize::Full => "",
util::CoverSize::W256 => ".256.jpg",
util::CoverSize::W512 => ".512.jpg",
println!("Downloading {} chapters", chapters.len());
chapters.sort_by(|a, b| {
a.attributes
.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();
for result in results.data.iter() {
let mut entry_futures: Vec<Pin<Box<dyn Future<Output = Entry>>>> = Vec::new();
assert!(!results.is_empty());
for result in results.iter() {
let mut entry = Entry::new(result.attributes.title.en.clone());
if let Some(year) = result.attributes.year {
entry.add_info("year", year);
@@ -66,15 +186,18 @@ async fn main() {
if let Some(volumes) = result.attributes.last_volume {
entry.add_info("volumes", volumes);
}
if let Some(cover_data) = &result.relationships[2].attributes {
if matches!(&client.search, Search::Query(q) if q.cover.is_some()) {
// The lib used for converting to sixel is abysmally slow for larger images, this
// 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
cover_data.file_name
);
let data = client
.client
.get(&image_url)
.send()
.await
@@ -87,272 +210,26 @@ async fn main() {
entry.set_image(result);
entry
};
entry_futures.push(future);
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) {
Ok(v) => v,
Err(e) => {
eprintln!("ERROR: Failed to select: {:?}", e);
std::process::exit(1);
eprintln!("ERROR: Failed to select: {e:?}");
process::exit(1);
}
};
let choice_id = &results.data[choice as usize].id;
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(&params).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(&params).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(&params)
.query(filters)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
response_deserializer::deserializer(&json)
choice
}

60
src/metadata.rs Normal file
View File

@@ -0,0 +1,60 @@
#![allow(unused)]
use serde::Serialize;
use crate::response_deserializer::{
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

View File

@@ -6,14 +6,13 @@ use crossterm::{
terminal::{Clear, ClearType},
QueueableCommand,
};
use std::fmt::Display;
use std::time::Duration;
use std::{
fmt::Display,
io,
io::{Stdout, Write},
time::Duration,
};
use thiserror::Error;
const CURRENT: char = '>';
const NON_CURRENT: char = ' ';
@@ -22,10 +21,10 @@ enum Action {
MoveDown,
MoveUp,
Select,
Exit,
// Exit,
}
#[derive(Default)]
#[derive(Default, Debug)]
pub struct Entry {
title: String,
info: Vec<(String, String)>,
@@ -60,11 +59,11 @@ fn get_input() -> Option<Action> {
KeyCode::Char('j') => Action::MoveDown,
KeyCode::Char('k') => Action::MoveUp,
KeyCode::Enter => Action::Select,
KeyCode::Char('q') => Action::Exit,
// KeyCode::Char('q') => Action::Exit,
_ => return None,
}),
Err(e) => {
eprintln!("ERROR: {:#?}", e);
eprintln!("ERROR: {e:#?}");
exit();
}
_ => None,
@@ -72,7 +71,7 @@ fn get_input() -> Option<Action> {
}
Ok(false) => None,
Err(e) => {
eprintln!("ERROR: {:#?}", e);
eprintln!("ERROR: {e:#?}");
exit();
}
}
@@ -81,15 +80,39 @@ fn get_input() -> Option<Action> {
fn exit() -> ! {
io::stdout().queue(Show).unwrap().flush().unwrap();
terminal::disable_raw_mode().unwrap();
std::process::exit(1);
std::process::exit(1)
}
pub fn select(entries: &[Entry]) -> Result<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 mut stdout = io::stdout();
stdout.queue(Hide)?;
let mut selected: u16 = 0;
let offset = width / 3;
let offset = if entries.iter().any(|m| m.image.is_some()) {
width / 3
} else {
width / 2
};
let mut should_render = true;
loop {
if should_render {
@@ -123,14 +146,14 @@ pub fn select(entries: &[Entry]) -> Result<u16, std::io::Error> {
.flush()?;
return Ok(selected);
}
Action::Exit => {
stdout
.queue(MoveTo(0, 0))?
.queue(Clear(ClearType::All))?
.queue(Show)?
.flush()?;
exit();
}
// Action::Exit => {
// stdout
// .queue(MoveTo(0, 0))?
// .queue(Clear(ClearType::All))?
// .queue(Show)?
// .flush()?;
// exit();
// }
}
stdout.queue(MoveTo(0, selected))?;
}

4
src/test.rs Normal file
View File

@@ -0,0 +1,4 @@
#[cfg(test)]
mod tests {
//
}

View File

@@ -1,13 +1,33 @@
use crate::Chapter;
use crate::{
response_deserializer,
response_deserializer::ChapterImages,
Chapter, Id, BASE,
};
use icy_sixel::{DiffusionMethod, MethodForLargest, MethodForRep, PixelFormat, Quality};
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 selection_type: SelectionType,
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 {
pub fn new(selection_type: SelectionType, bonus: bool) -> Self {
Self {
@@ -17,7 +37,7 @@ impl Selection {
}
}
#[derive(Default)]
#[derive(Default, Debug)]
pub enum CoverSize {
#[default]
Full,
@@ -25,48 +45,48 @@ pub enum CoverSize {
W512,
}
pub enum SelectionType {
Volume(VolumeSelection),
Chapter(ChapterSelection),
}
pub enum VolumeSelection {
All,
Range(u32, u32),
List(Vec<u32>),
Single(u32),
}
pub enum ChapterSelection {
#[derive(Default, Debug)]
pub enum SelectionRange {
Range(f32, f32),
List(Vec<f32>),
#[default]
All,
Single(f32),
}
#[derive(Debug)]
pub enum SelectionType {
Volume(SelectionRange),
Chapter(SelectionRange),
}
#[derive(Debug)]
pub struct Config {
pub bonus: Option<bool>,
pub result_limit: u32,
pub result_limit: Option<u32>,
pub cover_size: CoverSize,
pub selection_type: Option<ConfigSelectionType>,
pub selection_range: Option<String>,
pub selection_range: Option<SelectionRange>,
pub search: Option<ConfigSearch>,
pub cover: Option<bool>,
}
#[derive(Debug)]
pub enum ConfigSearch {
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 {
return true;
}
if let Some(volume) = volume {
if volume > 0 {
if let Some(chapter) = chapter {
return chapter.round() == chapter;
}
return chapter.round() == chapter
&& volume.round() == volume
&& chapter >= 1.
&& volume >= 1.;
}
}
false
@@ -76,11 +96,11 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
let bonus = selection.bonus;
match selection.selection_type {
SelectionType::Volume(v) => match v {
VolumeSelection::All => chapters
SelectionRange::All => chapters
.iter()
.filter(|m| filter_bonus(bonus, m.attributes.volume, m.attributes.chapter))
.collect(),
VolumeSelection::Single(a) => chapters
SelectionRange::Single(a) => chapters
.iter()
.filter(|m| {
if let Some(n) = m.attributes.volume {
@@ -90,7 +110,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
false
})
.collect(),
VolumeSelection::List(list) => chapters
SelectionRange::List(list) => chapters
.iter()
.filter(|m| {
if let Some(v) = m.attributes.volume {
@@ -100,7 +120,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
false
})
.collect(),
VolumeSelection::Range(a, b) => chapters
SelectionRange::Range(a, b) => chapters
.iter()
.filter(|m| {
if let Some(v) = m.attributes.volume {
@@ -113,11 +133,11 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
.collect(),
},
SelectionType::Chapter(c) => match c {
ChapterSelection::All => chapters
SelectionRange::All => chapters
.iter()
.filter(|m| filter_bonus(bonus, m.attributes.volume, m.attributes.chapter))
.collect(),
ChapterSelection::Single(a) => chapters
SelectionRange::Single(a) => chapters
.iter()
.filter(|m| {
if let Some(c) = m.attributes.chapter {
@@ -127,7 +147,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
false
})
.collect(),
ChapterSelection::List(list) => chapters
SelectionRange::List(list) => chapters
.iter()
.filter(|m| {
if let Some(n) = m.attributes.chapter {
@@ -137,7 +157,7 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
false
})
.collect(),
ChapterSelection::Range(a, b) => chapters
SelectionRange::Range(a, b) => chapters
.iter()
.filter(|m| {
if let Some(c) = m.attributes.chapter {
@@ -152,54 +172,16 @@ pub fn get_chapters_from_selection(selection: Selection, chapters: &[Chapter]) -
}
}
pub fn choose_volumes(input: &str) -> Option<VolumeSelection> {
if let Some(x) = input.find("..") {
match (input[0..x].parse(), input[x + 2..].parse::<u32>()) {
(Ok(a), Ok(b)) => {
if a > b {
eprintln!("Invalid range: a > b");
None
} else {
Some(VolumeSelection::Range(a, b))
pub fn ask_selection_range() -> SelectionRange {
loop {
match parse_selection_range(&get_input("Choose chapters/volumes: ")) {
Some(v) => return v,
None => eprintln!("Invalid selection"),
}
}
_ => {
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("..") {
match (input[0..x].parse(), input[x + 2..].parse()) {
// Inclusive range
@@ -208,7 +190,7 @@ pub fn choose_chapters(input: &str) -> Option<ChapterSelection> {
eprintln!("Invalid range: a > b");
None
} 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() {
Ok(v) => v,
Err(e) => {
eprintln!("Invalid input: {:#?}", e);
eprintln!("Invalid input: {e:#?}");
invalid = true;
0.
}
@@ -232,21 +214,33 @@ pub fn choose_chapters(input: &str) -> Option<ChapterSelection> {
if invalid {
None
} else {
Some(ChapterSelection::List(list))
Some(SelectionRange::List(list))
}
} else if input == "all" {
Some(ChapterSelection::All)
Some(SelectionRange::All)
} else if let Ok(n) = input.parse() {
Some(SelectionRange::Single(n))
} else {
if let Ok(n) = input.parse() {
return Some(ChapterSelection::Single(n));
}
eprintln!("Invalid input");
None
}
}
pub fn ask_selection_type() -> ConfigSelectionType {
loop {
match get_input("Select by volume or chapter? [v/c] : ").as_str() {
"v" | "volume" => break ConfigSelectionType::Volume,
"c" | "chapter" => break ConfigSelectionType::Chapter,
_ => {
eprintln!("Invalid response");
continue;
}
}
}
}
pub fn get_input(msg: &str) -> String {
print!("{}", msg);
print!("{msg}");
io::stdout().flush().expect("failed to flush stdout");
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]);
}),
DynamicImage::ImageLumaA8(_) => println!("Found lumaa8 image"),
DynamicImage::ImageRgb16(_) => println!("Found rgb16 image"),
DynamicImage::ImageLuma16(_) => println!("Found luma16 image"),
DynamicImage::ImageRgba16(_) => println!("Found rgba16 image"),
_ => panic!(),
_ => unimplemented!(),
}
icy_sixel::sixel_string(
&pixels,
@@ -289,13 +279,15 @@ pub fn convert_to_sixel(data: &[u8]) -> String {
DiffusionMethod::Auto,
MethodForLargest::Auto,
MethodForRep::Auto,
Quality::HIGH,
Quality::AUTO,
)
.unwrap()
}
#[derive(Default, Debug)]
pub enum ConfigSelectionType {
Volume,
#[default]
Chapter,
}
@@ -305,14 +297,224 @@ impl Config {
bonus: None,
search: None,
cover_size: CoverSize::default(),
result_limit: 5,
result_limit: None,
selection_type: 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 {
let mut args = std::env::args().skip(1);
@@ -320,11 +522,28 @@ pub fn args() -> Config {
let mut config = Config::new();
while args.len() != 0 {
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" => {
if config.selection_type.is_some() {
eprintln!("Value selection type already set.");
std::process::exit(1);
}
config.selection_type = match args.next() {
Some(selection_type) => Some(match selection_type.as_str() {
"volume" => ConfigSelectionType::Volume,
"chapter" => ConfigSelectionType::Chapter,
"v" | "volume" => ConfigSelectionType::Volume,
"c" | "chapter" => ConfigSelectionType::Chapter,
_ => {
eprintln!("Invalid value for selection type, type: SelectionType");
std::process::exit(1);
@@ -337,6 +556,10 @@ pub fn args() -> Config {
};
}
"-i" | "--id" => {
if config.search.is_some() {
eprintln!("Conflicting arguments for search.");
std::process::exit(1);
}
if let Some(id) = args.next() {
config.search = Some(ConfigSearch::Id(Id(id)));
} else {
@@ -345,6 +568,10 @@ pub fn args() -> Config {
}
}
"-s" | "--search" => {
if config.search.is_some() {
eprintln!("Conflicting arguments for search.");
std::process::exit(1);
}
if let Some(query) = args.next() {
config.search = Some(ConfigSearch::Query(query));
} else {
@@ -353,6 +580,10 @@ pub fn args() -> Config {
}
}
"-b" | "--bonus" => {
if config.bonus.is_some() {
eprintln!("Value bonus already set.");
std::process::exit(1);
}
config.bonus = match args.next() {
Some(a) => Some(match a.as_str() {
"true" => true,
@@ -371,9 +602,9 @@ pub fn args() -> Config {
"-r" | "--result-limit" => {
config.result_limit = match args.next() {
Some(a) => match a.parse() {
Ok(v) => v,
Ok(v) => Some(v),
Err(e) => {
eprintln!("Failed to parse value for result-limit: {:?}, type: u32", e);
eprintln!("Failed to parse value for result-limit: {e:?}, type: u32");
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 => {
eprintln!("Found invalid argument: {s}");
std::process::exit(1);
@@ -408,3 +655,22 @@ pub fn args() -> Config {
}
config
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter() {
assert!(filter_bonus(false, Some(1.), Some(1.)));
assert!(filter_bonus(false, Some(2.), Some(1.)));
assert!(filter_bonus(false, Some(1.), Some(2.)));
assert!(filter_bonus(false, Some(2.), Some(2.)));
assert!(!filter_bonus(false, Some(1.), Some(0.)));
assert!(!filter_bonus(false, Some(0.), Some(1.)));
assert!(!filter_bonus(false, Some(1.), Some(-1.)));
assert!(!filter_bonus(false, Some(-1.), Some(1.)));
assert!(!filter_bonus(false, Some(1.5), Some(1.)));
assert!(!filter_bonus(false, Some(1.), Some(1.5)));
}
}