From 20d16363e3f3b0f19440831d8d60367f58c6711d Mon Sep 17 00:00:00 2001 From: Neshura Date: Sun, 14 Jan 2024 03:35:33 +0100 Subject: [PATCH] Badly implement Series Listing from JNC Library --- src/jnovel.rs | 330 +++++++++++++++++++++++++++++++++++++++++--------- src/main.rs | 52 ++++++-- 2 files changed, 311 insertions(+), 71 deletions(-) diff --git a/src/jnovel.rs b/src/jnovel.rs index 2fd9531..e91bcc7 100644 --- a/src/jnovel.rs +++ b/src/jnovel.rs @@ -1,7 +1,10 @@ +use std::collections::HashMap; use std::fmt::{Display, Formatter}; use arrayvec::ArrayString; use chrono::{DateTime, Local}; -use reqwest::StatusCode; +use iced::futures::future::join_all; +use iced::subscription; +use reqwest::{Error, Response, StatusCode}; use serde::Deserialize; macro_rules! api { @@ -16,11 +19,12 @@ pub(crate) enum Message { LibraryState(LibraryState), } -#[derive(Debug, Clone, Default, Eq, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq)] pub(crate) struct State { - pub(crate) token: Option>, + pub(crate) token: Option>, pub(crate) login_state: LoginState, pub(crate) library_state: LibraryState, + pub(crate) library: HashMap } impl State { @@ -52,54 +56,186 @@ impl State { pub(crate) async fn logout(state: State) { let client = reqwest::Client::new(); - let url = api!() + format!("app/v1/auth/logout?{:?}", state.token).as_str(); - - let _ = client.post(url).send().await; + if let Some(token_str) = state.token { + let url = api!() + format!("app/v1/auth/logout?access_token={}", token_str).as_str(); + let res = client.post(url).send().await; + match res { + Ok(data) => println!("{}", data.status()), + Err(e) => println!("{}", e.to_string()), + } + } } - pub(crate) async fn load_library(state: State) -> LibraryState { - /* - 1: lib = https://labs.j-novel.club/app/v1/me/library - 2: every vol in lib.books serie = https://labs.j-novel.club/app/v1/volumes/{vol.volume.legacyId}/serie?format=json - 3: ser[serie.slug].info = serie - 4: ser[serie.slug].volumes.insert(vol) - */ - let client = reqwest::Client::new(); + pub(crate) fn load_library(token: ArrayString<36>) -> iced::Subscription { + subscription::unfold("library-loader", Progress::Ready, move |state| { + Self::fetch_library_all(token, state) + }) + } - let url = api!() + format!("app/v1/me/library?format=json&{:?}", state.token).as_str(); + async fn fetch_library_all(token: ArrayString<36>, state: Progress) -> (LibraryState, Progress) { + match state { + Progress::Ready => { + let client = reqwest::Client::new(); + let url = api!() + format!("app/v1/me/library?format=json&access_token={}", token).as_str(); + match client.get(url).send().await { + Ok(res) => { + match res.text().await { + Ok(text) => { + match serde_json::from_str::(&text) { + Ok(library) => { + let series: HashMap = HashMap::new(); + let total = library.books.len(); - println!("{url}"); + (LibraryState::Loading(0.0), Progress::Downloading { + token, + series_data: series, + books: library.books.clone(), + index: 0, + total, + downloaded: 0, + }) + }, + Err(e) => (LibraryState::Error(e.to_string()), Progress::Finished(None)), + } + }, + Err(e) => (LibraryState::Error(e.to_string()), Progress::Finished(None)), + } + }, + Err(e) => (LibraryState::Error(e.to_string()), Progress::Finished(None)) + } + } + Progress::Downloading { + token, + mut series_data, + books, + index, + total, + downloaded + } => { + let start = Local::now(); //DEBUG + let increment = 3; - let res = match client.get(url) - .send() - .await { - Ok(res) => res, - Err(e) => return LibraryState::Error(e.to_string()), - }; + let client = reqwest::Client::new(); - match res.text().await { - Ok(text) => { - let library = match serde_json::from_str(&text) { - Ok(library) => library, - Err(e) => return LibraryState::Error(e.to_string()), - }; + let mut res_vec = vec![]; + if total - index < increment { + let id = books[index].volume.legacy_id.clone(); + let url = api!() + format!("app/v1/volumes/{}/serie?format=json&access_token={}", id, token).as_str(); + let res = client.get(url).send().await.expect("This is uggly, Error handling here needs to be improved"); + let data = res.text().await; + res_vec.push(data); + } + else { + let mut tasks = vec![]; - LibraryState::Loaded - }, - Err(e) => LibraryState::Error(e.to_string()), + for i in 0..=(increment-1) { + let id = books[index + i].volume.legacy_id.clone(); + let client = client.clone(); + let task = tokio::spawn(async move { + let url = api!() + format!("app/v1/volumes/{}/serie?format=json&access_token={}", id, token).as_str(); + let res = client.get(url).send().await.expect("This is uggly, Error handling here needs to be improved"); + res.text().await + }); + tasks.push(task); + } + let awaited = join_all(tasks).await; + for res in awaited { + res_vec.push(res.expect("Future should have finished by now")); + } + } + + let elapsed = (Local::now() - start); + println!("Elapsed After Fetch: {}.{}s", elapsed.num_seconds(), elapsed.num_milliseconds()); + + for (i, data) in res_vec.iter().enumerate() { + let book = &books[index + i]; + match data { + Ok(json) => { + let data = serde_json::from_str::(json.as_str()).expect("JSON schema should be modeled accurately"); + + match series_data.get_mut(&data.slug) { + Some(entry) => { + // since the vec exists it can be assumed at least one book exists + // so we check for the highest number that is lower than the book.volume.number + entry.volumes.push(book.clone()); + } + None => { + let new_entry = Series { + info: data.clone(), + volumes: vec![book.clone()] + }; + + series_data.insert(data.slug, new_entry); + } + }; + } + Err(e) => println!("{e}") + } + } + + let downloaded = downloaded + increment; + let progress = downloaded as f32 / total as f32; + let index = index + increment; + + if index < total { + (LibraryState::Loading(progress), Progress::Downloading { + token, + series_data, + books, + index, + total, + downloaded, + }) + } + else { + (LibraryState::Loading(progress), Progress::Finished(Some(series_data))) + } + + + } + Progress::Finished(mut series_data) => { + match series_data.clone() { + Some(mut data) => { + data.iter_mut().for_each(|(_, entry)| { + entry.volumes.sort_by(|a, b| a.volume.number.cmp(&b.volume.number)); + }); + + (LibraryState::Loaded(data.clone()), Progress::Idle) + }, + None => (LibraryState::Error("Unexpected Error".to_owned()), Progress::Finished(series_data)) + } + } + Progress::Idle => { + (LibraryState::Idle, Progress::Idle) + } } } } -#[derive(Debug, Clone, Default, Eq, PartialEq, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq)] pub(crate) enum LibraryState { #[default] Unloaded, - Loading, - Loaded, + Loading(f32), + Loaded(HashMap), + Idle, Error(String), } +enum Progress { + Ready, + Downloading { + token: ArrayString<36>, + series_data: HashMap, + books: Vec, + index: usize, + total: usize, + downloaded: usize + }, + Finished (Option>), + Idle +} + #[derive(Debug, Copy, Clone, Eq, PartialEq, Deserialize)] pub(crate) struct Otp { pub(crate) otp: ArrayString<6>, @@ -133,7 +269,7 @@ impl Otp { match serde_json::from_str::(text.as_str()) { Ok(data) => { - let token = data.id.get(3..35).expect("Token should fill the ArrayString<36>").parse().expect("Should fit ArrayString<32>"); + let token = data.id; (LoginState::Success(token), OtpState::Valid(token)) }, Err(e) => (LoginState::Error(e.to_string()), OtpState::Error(e.to_string())) @@ -166,7 +302,7 @@ pub(crate) enum OtpState { Starting(Otp, DateTime), Waiting(Otp, DateTime), Error(String), - Valid(ArrayString<32>) + Valid(ArrayString<36>) } #[derive(Debug, Clone, Default, Eq, PartialEq)] @@ -174,7 +310,7 @@ pub(crate) enum LoginState { #[default] LoggedOut, LoggingIn, - Success(ArrayString<32>), + Success(ArrayString<36>), AwaitingConfirmation(Otp, DateTime), Error(String) } @@ -193,6 +329,7 @@ impl Display for LoginState { } } +#[derive(Deserialize)] struct ApiLibrary { books: Vec, pagination: Pagination @@ -206,11 +343,40 @@ struct Pagination { last_page: bool } -struct Series { - +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Series { + pub(crate) info: ApiSeries, + pub(crate) volumes: Vec } -#[derive(Deserialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct ApiSeries { + id: String, + #[serde(alias="legacyId")] + legacy_id: String, + r#type: Type, + status: VolumeStatus, + pub(crate) title: String, + #[serde(alias="shortTitle")] + short_title: String, + #[serde(alias="originalTitle")] + original_title: String, + pub(crate) slug: String, + hidden: bool, + created: String, + pub(crate) description: String, + #[serde(alias="shortDescription")] + short_description: String, + pub(crate) tags: Vec, + cover: Cover, + following: bool, + catchup: bool, + rentals: bool, + #[serde(alias="topicId")] + topic_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] struct LibraryVolume { id: String, #[serde(alias="legacyId")] @@ -221,22 +387,22 @@ struct LibraryVolume { #[serde(alias="lastDownloaded")] last_downloaded: Option, #[serde(alias="lastUpdated")] - last_updated: Option, + pub(crate) last_updated: Option, downloads: Vec, status: String } -#[derive(Deserialize)] -struct Volume { +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct Volume { id: String, #[serde(alias="legacyId")] legacy_id: String, - title: String, + pub(crate) title: String, #[serde(alias="originalTitle")] original_title: String, slug: String, - number: usize, - #[serde(alias="OriginalPublisher")] + pub(crate) number: usize, + #[serde(alias="originalPublisher")] original_publisher: String, label: Label, creators: Vec, @@ -244,21 +410,21 @@ struct Volume { #[serde(alias="forumTopicId")] forum_topic_id: usize, created: String, - publising: String, + publishing: String, description: String, #[serde(alias="shortDescription")] - short_description: String, + pub(crate) short_description: String, cover: Cover, owned: bool, #[serde(alias="premiumExtras")] - premium_extras: String, + premium_extras: Option, #[serde(alias="noEbook")] no_ebook: bool, #[serde(alias="totalParts")] total_parts: usize } -#[derive(Deserialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] struct Cover { #[serde(alias="originalUrl")] original_url: String, @@ -268,7 +434,7 @@ struct Cover { thumbnail_url: String, } -#[derive(Deserialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] struct Creator { id: String, name: String, @@ -277,29 +443,73 @@ struct Creator { original_name: String } -#[derive(Deserialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] struct Download { link: String, r#type: FileType, label: String, } -#[derive(Deserialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] enum Label { + #[serde(alias="")] + None, + #[serde(alias="J-Novel Heart")] Heart, + #[serde(alias="J-Novel Club")] Club, - Pulp + #[serde(alias="J-Novel Pulp")] + Pulp, + #[serde(alias="TOブックスラノベ", alias="TO Books")] + ToBooks, + #[serde(alias="HJ文庫")] + HobbyJapan, + #[serde(alias="オーバーラップ文庫")] + Overlap, + #[serde(other)] + Unknown } -#[derive(Deserialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] enum FileType { - EPUB + #[serde(alias="EPUB")] + Epub, + #[serde(other)] + Unknown } -#[derive(Deserialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] enum Role { + #[serde(alias="AUTHOR")] AUTHOR, - ILLUSTRATOR, - TRANSLATOR, - EDITOR + #[serde(alias="ARTIST")] + Artist, + #[serde(alias="EDITOR")] + Editor, + #[serde(alias="ILLUSTRATOR")] + Illustrator, + #[serde(alias="LETTERER")] + Letterer, + #[serde(alias="TRANSLATOR")] + Translator, + #[serde(other)] + Unknown +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +enum Type { + #[serde(alias="NOVEL")] + Novel, + #[serde(alias="MANGA")] + Manga, + #[serde(other)] + Unknown +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +enum VolumeStatus { + #[serde(alias="DEFAULT")] + Default, + #[serde(other)] + Unknown } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 03c50cb..06d6a74 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,21 +116,18 @@ impl Application for CalibreWebImporter { } jnovel::Message::Logout => { println!("Logging out from J-Novel Club"); + let jnc = self.jnc.clone(); self.jnc.token = None; self.jnc.login_state = jnovel::LoginState::LoggedOut; - Command::perform(jnovel::State::logout(self.jnc.clone()), |()| Message::JncAction(jnovel::Message::LoginState(jnovel::LoginState::LoggedOut))) + self.jnc.library_state = jnovel::LibraryState::Unloaded; + Command::perform(jnovel::State::logout(jnc), |()| Message::JncAction(jnovel::Message::LoginState(jnovel::LoginState::LoggedOut))) } jnovel::Message::LoginState(status) => { if let jnovel::LoginState::Success(token) = status { self.jnc.token = Some(token); self.jnc.login_state = status; - if self.jnc.library_state == jnovel::LibraryState::Unloaded { - Command::perform(jnovel::State::load_library(self.jnc.clone()), |status| Message::JncAction(jnovel::Message::LibraryState(status))) - } - else { - Command::none() - } + Command::none() } else { self.jnc.login_state = status; @@ -142,13 +139,20 @@ impl Application for CalibreWebImporter { jnovel::LibraryState::Unloaded => { Command::none() } - jnovel::LibraryState::Loading => { + jnovel::LibraryState::Loading(progress) => { + println!("Loading: {}", progress); Command::none() } - jnovel::LibraryState::Loaded => { + jnovel::LibraryState::Loaded(data) => { + self.jnc.library = data; + println!("Loaded"); + Command::none() + } + jnovel::LibraryState::Idle => { Command::none() } jnovel::LibraryState::Error(err) => { + println!("{err}"); Command::none() } } @@ -181,7 +185,15 @@ impl Application for CalibreWebImporter { }, Menu::JNovel => { let jnc_login_button: Element<_, _> = match &self.jnc.login_state { - jnovel::LoginState::Success(_) => Element::from(button("Logout").on_press(Message::JncAction(jnovel::Message::Logout))), + jnovel::LoginState::Success(_) => { + let mut col = Column::new(); + col = col.push(button("Logout").on_press(Message::JncAction(jnovel::Message::Logout))); + + for (id, entry) in &self.jnc.library { + col = col.push(button(entry.info.title.as_str()).on_press(Message::JncAction(jnovel::Message::LoginState(jnovel::LoginState::LoggedOut)))); + } + + Element::from(col)}, jnovel::LoginState::LoggedOut => Element::from(button("Login").on_press(Message::JncAction(jnovel::Message::Login))), jnovel::LoginState::LoggingIn => Element::from(text("Logging in")), jnovel::LoginState::AwaitingConfirmation(code, start) => Element::from(text(format!("Login Code: {}. Expiring: {}s", code.otp, 600 - (Local::now() - start).num_seconds()))), @@ -254,9 +266,27 @@ impl Application for CalibreWebImporter { _ => Subscription::none(), }; + let library_loading = match self.jnc.login_state { + jnovel::LoginState::Success(token) => { + match self.jnc.library_state { + jnovel::LibraryState::Unloaded => { + jnovel::State::load_library(token.clone()) + .map(|status| Message::JncAction(jnovel::Message::LibraryState(status))) + }, + jnovel::LibraryState::Loading(progress) => { + jnovel::State::load_library(token.clone()) + .map(|status| Message::JncAction(jnovel::Message::LibraryState(status))) + }, + _ => Subscription::none() + } + }, + _ => Subscription::none() + }; + Subscription::batch([ system_theme, - jnc_otp_check + jnc_otp_check, + library_loading ]) } }